summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--public/App.js102
-rw-r--r--public/index.html2
-rw-r--r--src/SomhairlesDream.Cli/Arguments.fs32
-rw-r--r--src/SomhairlesDream.Cli/Program.fs2
-rw-r--r--src/SomhairlesDream.Frontend/App.fs91
-rw-r--r--src/SomhairlesDream.Modeling/BlenderBridge.fs5
-rw-r--r--src/SomhairlesDream.Modeling/Definitions.fs86
-rw-r--r--stage/blender/ship.py624
9 files changed, 846 insertions, 106 deletions
diff --git a/README.md b/README.md
index bc96f29..1562333 100644
--- a/README.md
+++ b/README.md
@@ -124,9 +124,11 @@ The real-probe dataset lives alongside the heritage fixture: steps
`src/SomhairlesDream.Modeling/Definitions.fs` (`Design.probeSteps`) and
rendered by `stage/blender/probe.py` (7 cumulative bpy exports, clay until
`probe-07-livery` applies the livery pass). Steps are selected by project id
-prefix: any `probe-…` project id resolves to the probe design both in the
-server coordinator and in the CLI (`run --design probe` accepts/uses the
-probe id defaults), while all other project ids keep the heritage design.
+prefix: any `probe-…` project id resolves to the probe design, any
+`ship-…` project id resolves to the crewed-starship design (`Design.shipSteps`
+rendered by `stage/blender/ship.py`, same 7-step clay→livery contract), and
+all other project ids keep the heritage design. The CLI mirrors this with
+`run --design probe` and `run --design ship`, each with its own id defaults.
The public landing page boots into a pre-published probe run
(`probe-001 / run-probe-real-2`): after startup the frontend fetches
diff --git a/public/App.js b/public/App.js
index 1f28482..4716bb3 100644
--- a/public/App.js
+++ b/public/App.js
@@ -885,53 +885,7 @@ export function selectionForLiveStart(selection) {
}
}
-export function startRun(selection) {
- const requestBody = {
- projectId: selection.ProjectId,
- runId: selection.RunId,
- baseId: selection.BaseId,
- targetId: selection.TargetId,
- render: selection.Render,
- };
- const requestOptions = {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(requestBody),
- };
- setText("run-message", "正在启动设计运行");
- updateStreamState("实时链路 · 等待服务端确认");
- startImmediate(singleton.Delay(() => singleton.TryWith(singleton.Delay(() => singleton.Bind(awaitPromise(fetch("/api/runs/start", requestOptions)), (_arg) => {
- let arg;
- const response = _arg;
- if (response.ok) {
- return singleton.Bind(awaitPromise(response.json()), (_arg_1) => {
- updateState(_arg_1);
- connectToServer(selection);
- updateStreamState("实时链路 · SSE 已连接");
- return singleton.Zero();
- });
- }
- else {
- setText("run-message", (arg = (response.status | 0), toText(printf("启动失败 · HTTP %d"))(arg)));
- updateStreamState("实时链路 · 启动失败");
- return singleton.Zero();
- }
- })), (_arg_2) => {
- setText("run-message", "启动失败 · 无法连接服务端");
- updateStreamState("实时链路 · 连接失败");
- return singleton.Zero();
- })));
-}
-
-/**
- * Boot-time historical load: surface the pre-published probe build replay so
- * first-time visitors see the real vehicle instead of an empty placeholder.
- * Any failure leaves the honest placeholder idle state untouched.
- */
-export function loadBootHistory() {
- const selection = selectedRun();
+export function loadHistoryFor(selection) {
const projectId = encodeURIComponent(selection.ProjectId);
const runId = encodeURIComponent(selection.RunId);
const url = toText(printf("/api/artifacts/manifest?projectId=%s&runId=%s"))(projectId)(runId);
@@ -985,6 +939,60 @@ export function loadBootHistory() {
})));
}
+export function startRun(selection) {
+ const requestBody = {
+ projectId: selection.ProjectId,
+ runId: selection.RunId,
+ baseId: selection.BaseId,
+ targetId: selection.TargetId,
+ render: selection.Render,
+ };
+ const requestOptions = {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(requestBody),
+ };
+ setText("run-message", "正在启动设计运行");
+ updateStreamState("实时链路 · 等待服务端确认");
+ startImmediate(singleton.Delay(() => singleton.TryWith(singleton.Delay(() => singleton.Bind(awaitPromise(fetch("/api/runs/start", requestOptions)), (_arg) => {
+ let arg;
+ const response = _arg;
+ if (response.ok) {
+ return singleton.Bind(awaitPromise(response.json()), (_arg_1) => {
+ updateState(_arg_1);
+ connectToServer(selection);
+ updateStreamState("实时链路 · SSE 已连接");
+ return singleton.Zero();
+ });
+ }
+ else if (response.status === 409) {
+ setText("run-message", "该运行已完成 · 载入历史回放");
+ loadHistoryFor(selection);
+ return singleton.Zero();
+ }
+ else {
+ setText("run-message", (arg = (response.status | 0), toText(printf("启动失败 · HTTP %d"))(arg)));
+ updateStreamState("实时链路 · 启动失败");
+ return singleton.Zero();
+ }
+ })), (_arg_2) => {
+ setText("run-message", "启动失败 · 无法连接服务端");
+ updateStreamState("实时链路 · 连接失败");
+ return singleton.Zero();
+ })));
+}
+
+/**
+ * Boot-time historical load: surface the pre-published probe build replay so
+ * first-time visitors see the real vehicle instead of an empty placeholder.
+ * Any failure leaves the honest placeholder idle state untouched.
+ */
+export function loadBootHistory() {
+ loadHistoryFor(selectedRun());
+}
+
export function boot() {
showFrameMetadata(item(0, frames));
try {
diff --git a/public/index.html b/public/index.html
index 494d26c..b2f8703 100644
--- a/public/index.html
+++ b/public/index.html
@@ -196,6 +196,6 @@
<span>heritage 参考线保留占位演示 · F# / OPEN CODE</span>
</footer>
</main>
- <script type="module" src="/App.js?v=orbit1"></script>
+ <script type="module" src="/App.js?v=ship1"></script>
</body>
</html>
diff --git a/src/SomhairlesDream.Cli/Arguments.fs b/src/SomhairlesDream.Cli/Arguments.fs
index fa9087b..c1c7bff 100644
--- a/src/SomhairlesDream.Cli/Arguments.fs
+++ b/src/SomhairlesDream.Cli/Arguments.fs
@@ -21,7 +21,7 @@ module Arguments =
let usage =
"usage: dotnet run --project src/SomhairlesDream.Cli -- run [options]\n"
+ " 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"
+ + "options: --project-id --run-id --base-id --target-id --output-root --stage-root --python --render --design heritage|probe|ship"
let private defaultPython () =
let configured = Environment.GetEnvironmentVariable("SOMHAIRLES_BPYTHON")
@@ -39,6 +39,12 @@ module Arguments =
BaseId = "probe-base-000"
TargetId = "probe-target-007" }
+ let private shipIds =
+ { ProjectId = "ship-001"
+ RunId = "run-001"
+ BaseId = "ship-base-000"
+ TargetId = "ship-target-007" }
+
let private defaults () =
{ Ids = heritageIds
OutputRoot = ".artifacts"
@@ -66,21 +72,27 @@ module Arguments =
| _ -> 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
+ if options.Design = "heritage" then
+ options
+ else
+ let designIds =
+ match options.Design with
+ | "probe" -> probeIds
+ | "ship" -> shipIds
+ | _ -> heritageIds
+
+ let swap heritage design value = if value = heritage then design 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 }
+ { ProjectId = swap heritageIds.ProjectId designIds.ProjectId options.Ids.ProjectId
+ RunId = swap heritageIds.RunId designIds.RunId options.Ids.RunId
+ BaseId = swap heritageIds.BaseId designIds.BaseId options.Ids.BaseId
+ TargetId = swap heritageIds.TargetId designIds.TargetId options.Ids.TargetId }
{ options with Ids = ids }
- else
- options
let private validateRunOptions (options: RunOptions) =
- if options.Design <> "heritage" && options.Design <> "probe" then
+ if options.Design <> "heritage" && options.Design <> "probe" && options.Design <> "ship" then
Error $"invalid design: {options.Design}"
else
let resolved = resolveIds options
diff --git a/src/SomhairlesDream.Cli/Program.fs b/src/SomhairlesDream.Cli/Program.fs
index dd58ff0..7f33e1c 100644
--- a/src/SomhairlesDream.Cli/Program.fs
+++ b/src/SomhairlesDream.Cli/Program.fs
@@ -14,7 +14,7 @@ module Program =
let pipelineOptions : PipelineOptions =
{ ArtifactRoot = options.OutputRoot
Ids = options.Ids
- Steps = if options.Design = "probe" then Design.probeSteps else Design.steps
+ Steps = Design.forProject options.Ids.ProjectId
Render = options.Render
Bridge = bridge
Clock = fun () -> DateTimeOffset.UtcNow
diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs
index e6edd51..0e6bcd4 100644
--- a/src/SomhairlesDream.Frontend/App.fs
+++ b/src/SomhairlesDream.Frontend/App.fs
@@ -707,48 +707,7 @@ let selectionForLiveStart (selection: RunSelection) =
else
selection
-let startRun (selection: RunSelection) =
- let requestBody =
- createObj
- [ "projectId" ==> selection.ProjectId
- "runId" ==> selection.RunId
- "baseId" ==> selection.BaseId
- "targetId" ==> selection.TargetId
- "render" ==> selection.Render ]
-
- let requestOptions =
- createObj
- [ "method" ==> "POST"
- "headers" ==> createObj [ "Content-Type" ==> "application/json" ]
- "body" ==> JS.JSON.stringify requestBody ]
-
- setText "run-message" "正在启动设计运行"
- updateStreamState "实时链路 · 等待服务端确认"
-
- async {
- try
- let! response = fetch ("/api/runs/start", requestOptions) |> Async.AwaitPromise
-
- if response.ok then
- let! value = response.json() |> Async.AwaitPromise
- let payload = value :?> SnapshotPayload
- updateState payload
- connectToServer selection
- updateStreamState "实时链路 · SSE 已连接"
- else
- setText "run-message" (sprintf "启动失败 · HTTP %d" response.status)
- updateStreamState "实时链路 · 启动失败"
- with _ ->
- setText "run-message" "启动失败 · 无法连接服务端"
- updateStreamState "实时链路 · 连接失败"
- }
- |> Async.StartImmediate
-
-/// Boot-time historical load: surface the pre-published probe build replay so
-/// first-time visitors see the real vehicle instead of an empty placeholder.
-/// Any failure leaves the honest placeholder idle state untouched.
-let loadBootHistory () =
- let selection = selectedRun ()
+let loadHistoryFor (selection: RunSelection) =
let projectId = JS.encodeURIComponent selection.ProjectId
let runId = JS.encodeURIComponent selection.RunId
let url = sprintf "/api/artifacts/manifest?projectId=%s&runId=%s" projectId runId
@@ -793,6 +752,54 @@ let loadBootHistory () =
()
}
|> Async.StartImmediate
+let startRun (selection: RunSelection) =
+ let requestBody =
+ createObj
+ [ "projectId" ==> selection.ProjectId
+ "runId" ==> selection.RunId
+ "baseId" ==> selection.BaseId
+ "targetId" ==> selection.TargetId
+ "render" ==> selection.Render ]
+
+ let requestOptions =
+ createObj
+ [ "method" ==> "POST"
+ "headers" ==> createObj [ "Content-Type" ==> "application/json" ]
+ "body" ==> JS.JSON.stringify requestBody ]
+
+ setText "run-message" "正在启动设计运行"
+ updateStreamState "实时链路 · 等待服务端确认"
+
+ async {
+ try
+ let! response = fetch ("/api/runs/start", requestOptions) |> Async.AwaitPromise
+
+ if response.ok then
+ let! value = response.json() |> Async.AwaitPromise
+ let payload = value :?> SnapshotPayload
+ updateState payload
+ connectToServer selection
+ updateStreamState "实时链路 · SSE 已连接"
+ elif response.status = 409 then
+ // The run id already exists on the server. If it completed
+ // earlier, replay its published history instead of treating
+ // the submit as a failure.
+ setText "run-message" "该运行已完成 · 载入历史回放"
+ loadHistoryFor selection
+ else
+ setText "run-message" (sprintf "启动失败 · HTTP %d" response.status)
+ updateStreamState "实时链路 · 启动失败"
+ with _ ->
+ setText "run-message" "启动失败 · 无法连接服务端"
+ updateStreamState "实时链路 · 连接失败"
+ }
+ |> Async.StartImmediate
+
+/// Boot-time historical load: surface the pre-published probe build replay so
+/// first-time visitors see the real vehicle instead of an empty placeholder.
+/// Any failure leaves the honest placeholder idle state untouched.
+let loadBootHistory () =
+ loadHistoryFor (selectedRun ())
let boot () =
showFrameMetadata frames[0]
diff --git a/src/SomhairlesDream.Modeling/BlenderBridge.fs b/src/SomhairlesDream.Modeling/BlenderBridge.fs
index c4215ae..0f605b0 100644
--- a/src/SomhairlesDream.Modeling/BlenderBridge.fs
+++ b/src/SomhairlesDream.Modeling/BlenderBridge.fs
@@ -102,7 +102,10 @@ type BlenderProcessBridge(python: string, stageRoot: string, ?timeout: TimeSpan)
File.WriteAllText(path, content)
member private _.ScriptPath(stepId: string) =
- let scriptName = if stepId.StartsWith("probe-") then "probe.py" else "fixture.py"
+ let scriptName =
+ if stepId.StartsWith("probe-") then "probe.py"
+ elif stepId.StartsWith("ship-") then "ship.py"
+ else "fixture.py"
Path.GetFullPath(Path.Combine(stageRoot, "blender", scriptName))
diff --git a/src/SomhairlesDream.Modeling/Definitions.fs b/src/SomhairlesDream.Modeling/Definitions.fs
index 9a9256e..e1d2739 100644
--- a/src/SomhairlesDream.Modeling/Definitions.fs
+++ b/src/SomhairlesDream.Modeling/Definitions.fs
@@ -92,5 +92,89 @@ module Design =
ObjectIds = prior (prior (prior (prior (prior (prior bus booms) rtgs) hga) instruments) mast) [||]
Changes = liveryChanges } |]
+ // HNX-01 crewed interstellar starship (Concept A "Keel"): fusion-torch
+ // study with a spin-gravity habitat torus, water-shadow radiation
+ // shield and an axial keel separating crew from the reactor section.
+ // Display-scale visual research; no mass/thrust/shielding closure is
+ // claimed — the ship.py module header lists the deliberately open
+ // engineering problems.
+ let shipSteps : DesignStep array =
+ let keel =
+ [| "starship.keel.spine"; "starship.nose.cone"; "starship.dock.ring" |]
+ let hab =
+ [| "starship.hab.torus"; "starship.hub.core"
+ "starship.spoke.00"; "starship.spoke.01"; "starship.spoke.02"
+ "starship.spoke.03"; "starship.spoke.04"; "starship.spoke.05" |]
+ let radiation =
+ [| "starship.shadow.collar"; "starship.storm.cellar" |]
+ let reactor =
+ [| "starship.core.pressure"; "starship.core.shield.plug"
+ "starship.nozzle.magnetic"
+ "starship.radiator.00"; "starship.radiator.01"; "starship.radiator.02"
+ "starship.radiator.03"; "starship.radiator.04"; "starship.radiator.05" |]
+ let power =
+ [| "starship.power.truss"; "starship.capacitor.ring"
+ "starship.comm.dish"; "starship.comm.feed" |]
+ let cabin =
+ [| "starship.window.00"; "starship.window.01"; "starship.window.02"
+ "starship.window.03"
+ "starship.airlock.ring"; "starship.airlock.tunnel"
+ "starship.cargo.pod.00"; "starship.cargo.pod.01" |]
+ let prior (prefix: string array) (current: string array) =
+ Array.append prefix current
+
+ let keelChanges =
+ [| { ObjectId = "starship.keel.spine"; Kind = "add"; Summary = "Add axial keel spine from bow to nozzle bay" }
+ { ObjectId = "starship.nose.cone"; Kind = "add"; Summary = "Add forward nose cone" }
+ { ObjectId = "starship.dock.ring"; Kind = "add"; Summary = "Add forward docking ring" } |]
+ let habChanges =
+ Array.append
+ [| { ObjectId = "starship.hab.torus"; Kind = "add"; Summary = "Add rotating spin-gravity habitat torus" }
+ { ObjectId = "starship.hub.core"; Kind = "add"; Summary = "Add zero-g hub with bearing core" } |]
+ (Array.init 6 (fun index ->
+ { ObjectId = sprintf "starship.spoke.%02d" index; Kind = "add"; Summary = "Add pressurized transfer spoke" }))
+ let radiationChanges =
+ [| { ObjectId = "starship.shadow.collar"; Kind = "add"; Summary = "Add water-shadow radiation collar" }
+ { ObjectId = "starship.storm.cellar"; Kind = "add"; Summary = "Add storm cellar shelter volume" } |]
+ let reactorChanges =
+ Array.append
+ [| { ObjectId = "starship.core.pressure"; Kind = "add"; Summary = "Add fusion core pressure vessel" }
+ { ObjectId = "starship.core.shield.plug"; Kind = "add"; Summary = "Add reactor shield plug" }
+ { ObjectId = "starship.nozzle.magnetic"; Kind = "add"; Summary = "Add magnetic exhaust nozzle" } |]
+ (Array.init 6 (fun index ->
+ { ObjectId = sprintf "starship.radiator.%02d" index; Kind = "add"; Summary = "Add waste-heat radiator panel" }))
+ let powerChanges =
+ [| { ObjectId = "starship.power.truss"; Kind = "add"; Summary = "Add power conditioning truss" }
+ { ObjectId = "starship.capacitor.ring"; Kind = "add"; Summary = "Add pulsed-power capacitor hoop" }
+ { ObjectId = "starship.comm.dish"; Kind = "add"; Summary = "Add high-gain communication dish" }
+ { ObjectId = "starship.comm.feed"; Kind = "add"; Summary = "Add communication feed horn" } |]
+ let cabinChanges =
+ Array.append
+ (Array.init 4 (fun index ->
+ { ObjectId = sprintf "starship.window.%02d" index; Kind = "add"; Summary = "Add habitat observation window" }))
+ [| { ObjectId = "starship.airlock.ring"; Kind = "add"; Summary = "Add forward airlock ring" }
+ { ObjectId = "starship.airlock.tunnel"; Kind = "add"; Summary = "Add pressurized airlock tunnel" }
+ { ObjectId = "starship.cargo.pod.00"; Kind = "add"; Summary = "Add port cargo pod" }
+ { ObjectId = "starship.cargo.pod.01"; Kind = "add"; Summary = "Add starboard cargo pod" } |]
+ let liveryChanges =
+ [| { ObjectId = "starship.keel.spine"; Kind = "update"; Summary = "Livery pass: bone-white hull structure" }
+ { ObjectId = "starship.hab.torus"; Kind = "update"; Summary = "Livery pass: habitat ring bone finish" }
+ { ObjectId = "starship.shadow.collar"; Kind = "update"; Summary = "Livery pass: ceramic shield darkening" }
+ { ObjectId = "starship.storm.cellar"; Kind = "update"; Summary = "Livery pass: ceramic cellar finish" }
+ { ObjectId = "starship.dock.ring"; Kind = "update"; Summary = "Livery pass: amber accent on dock ring" }
+ { ObjectId = "starship.capacitor.ring"; Kind = "update"; Summary = "Livery pass: amber capacitor hoop" }
+ { ObjectId = "starship.window.00"; Kind = "update"; Summary = "Livery pass: glass glazing" }
+ { ObjectId = "starship.nozzle.magnetic"; Kind = "update"; Summary = "Livery pass: nozzle coil graphite" } |]
+
+ [| { StepId = "ship-01-keel"; Index = 0; ObjectIds = keel; Changes = keelChanges }
+ { StepId = "ship-02-hab"; Index = 1; ObjectIds = Array.append keel hab; Changes = habChanges }
+ { StepId = "ship-03-radiation"; Index = 2; ObjectIds = Array.append (Array.append keel hab) radiation; Changes = radiationChanges }
+ { StepId = "ship-04-reactor"; Index = 3; ObjectIds = Array.append (Array.append (Array.append keel hab) radiation) reactor; Changes = reactorChanges }
+ { StepId = "ship-05-power"; Index = 4; ObjectIds = Array.append (Array.append (Array.append (Array.append keel hab) radiation) reactor) power; Changes = powerChanges }
+ { StepId = "ship-06-cabin"; Index = 5; ObjectIds = Array.append (Array.append (Array.append (Array.append (Array.append keel hab) radiation) reactor) power) cabin; Changes = cabinChanges }
+ { StepId = "ship-07-livery"; Index = 6; ObjectIds = Array.append (Array.append (Array.append (Array.append (Array.append (Array.append keel hab) radiation) reactor) power) cabin) [||]; Changes = liveryChanges } |]
+
let forProject (projectId: string) : DesignStep array =
- if projectId.StartsWith("probe-") then probeSteps else steps
+ if projectId.StartsWith("probe-") then probeSteps
+ elif projectId.StartsWith("ship-") then shipSteps
+ else steps
diff --git a/stage/blender/ship.py b/stage/blender/ship.py
new file mode 100644
index 0000000..21e2ab0
--- /dev/null
+++ b/stage/blender/ship.py
@@ -0,0 +1,624 @@
+"""HNX-01 crewed interstellar starship dataset for the artifact pipeline.
+
+Concept A "Keel" — a crewed fusion-torch starship study built on the same
+Blender contract as the YJ-01 probe dataset (stage/blender/probe.py):
+
+ --step-id <id> --output <glb> --result-output <json> [--render-output <png>]
+
+Design intent (visual research, NOT a closed engineering design):
+ - Long axial keel so the crew habitat sits as far as practical from the
+ reactor section (inverse-square separation for neutron/gamma dose).
+ - Rotating habitat torus for continuous spin gravity; six spokes carry
+ the pressurized transfer tunnels; the hub is the zero-g docking core.
+ - Heavy water-shadow shield collar and a storm cellar between the
+ habitat and the reactor.
+ - Aft fusion core with a magnetic nozzle; six droop radiators reject the
+ waste heat. These hardware positions are placeholders — the script
+ does not claim thrust, burn time, shielding closure, or life-support
+ closure.
+
+Object ids: starship.<part>[.<index>], lowercase dotted, stable per step.
+Cumulative object lists must stay in lockstep with Design.shipSteps in
+src/SomhairlesDream.Modeling/Definitions.fs.
+"""
+
+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
+
+STEP_OBJECTS = {
+ "ship-01-keel": [
+ "starship.keel.spine",
+ "starship.nose.cone",
+ "starship.dock.ring",
+ ],
+ "ship-02-hab": [
+ "starship.keel.spine",
+ "starship.nose.cone",
+ "starship.dock.ring",
+ "starship.hab.torus",
+ "starship.hub.core",
+ "starship.spoke.00",
+ "starship.spoke.01",
+ "starship.spoke.02",
+ "starship.spoke.03",
+ "starship.spoke.04",
+ "starship.spoke.05",
+ ],
+ "ship-03-radiation": [
+ "starship.keel.spine",
+ "starship.nose.cone",
+ "starship.dock.ring",
+ "starship.hab.torus",
+ "starship.hub.core",
+ "starship.spoke.00",
+ "starship.spoke.01",
+ "starship.spoke.02",
+ "starship.spoke.03",
+ "starship.spoke.04",
+ "starship.spoke.05",
+ "starship.shadow.collar",
+ "starship.storm.cellar",
+ ],
+ "ship-04-reactor": [
+ "starship.keel.spine",
+ "starship.nose.cone",
+ "starship.dock.ring",
+ "starship.hab.torus",
+ "starship.hub.core",
+ "starship.spoke.00",
+ "starship.spoke.01",
+ "starship.spoke.02",
+ "starship.spoke.03",
+ "starship.spoke.04",
+ "starship.spoke.05",
+ "starship.shadow.collar",
+ "starship.storm.cellar",
+ "starship.core.pressure",
+ "starship.core.shield.plug",
+ "starship.nozzle.magnetic",
+ "starship.radiator.00",
+ "starship.radiator.01",
+ "starship.radiator.02",
+ "starship.radiator.03",
+ "starship.radiator.04",
+ "starship.radiator.05",
+ ],
+ "ship-05-power": [
+ "starship.keel.spine",
+ "starship.nose.cone",
+ "starship.dock.ring",
+ "starship.hab.torus",
+ "starship.hub.core",
+ "starship.spoke.00",
+ "starship.spoke.01",
+ "starship.spoke.02",
+ "starship.spoke.03",
+ "starship.spoke.04",
+ "starship.spoke.05",
+ "starship.shadow.collar",
+ "starship.storm.cellar",
+ "starship.core.pressure",
+ "starship.core.shield.plug",
+ "starship.nozzle.magnetic",
+ "starship.radiator.00",
+ "starship.radiator.01",
+ "starship.radiator.02",
+ "starship.radiator.03",
+ "starship.radiator.04",
+ "starship.radiator.05",
+ "starship.power.truss",
+ "starship.capacitor.ring",
+ "starship.comm.dish",
+ "starship.comm.feed",
+ ],
+ "ship-06-cabin": [
+ "starship.keel.spine",
+ "starship.nose.cone",
+ "starship.dock.ring",
+ "starship.hab.torus",
+ "starship.hub.core",
+ "starship.spoke.00",
+ "starship.spoke.01",
+ "starship.spoke.02",
+ "starship.spoke.03",
+ "starship.spoke.04",
+ "starship.spoke.05",
+ "starship.shadow.collar",
+ "starship.storm.cellar",
+ "starship.core.pressure",
+ "starship.core.shield.plug",
+ "starship.nozzle.magnetic",
+ "starship.radiator.00",
+ "starship.radiator.01",
+ "starship.radiator.02",
+ "starship.radiator.03",
+ "starship.radiator.04",
+ "starship.radiator.05",
+ "starship.power.truss",
+ "starship.capacitor.ring",
+ "starship.comm.dish",
+ "starship.comm.feed",
+ "starship.window.00",
+ "starship.window.01",
+ "starship.window.02",
+ "starship.window.03",
+ "starship.airlock.ring",
+ "starship.airlock.tunnel",
+ "starship.cargo.pod.00",
+ "starship.cargo.pod.01",
+ ],
+ "ship-07-livery": [
+ "starship.keel.spine",
+ "starship.nose.cone",
+ "starship.dock.ring",
+ "starship.hab.torus",
+ "starship.hub.core",
+ "starship.spoke.00",
+ "starship.spoke.01",
+ "starship.spoke.02",
+ "starship.spoke.03",
+ "starship.spoke.04",
+ "starship.spoke.05",
+ "starship.shadow.collar",
+ "starship.storm.cellar",
+ "starship.core.pressure",
+ "starship.core.shield.plug",
+ "starship.nozzle.magnetic",
+ "starship.radiator.00",
+ "starship.radiator.01",
+ "starship.radiator.02",
+ "starship.radiator.03",
+ "starship.radiator.04",
+ "starship.radiator.05",
+ "starship.power.truss",
+ "starship.capacitor.ring",
+ "starship.comm.dish",
+ "starship.comm.feed",
+ "starship.window.00",
+ "starship.window.01",
+ "starship.window.02",
+ "starship.window.03",
+ "starship.airlock.ring",
+ "starship.airlock.tunnel",
+ "starship.cargo.pod.00",
+ "starship.cargo.pod.01",
+ ],
+}
+
+LIVERY_STEPS = {"ship-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_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_hull": make_material(bpy, "LIVERY HULL BONE", (0.72, 0.70, 0.64), metallic=0.30, 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_ceramic": make_material(bpy, "LIVERY SHADOW CERAMIC", (0.10, 0.115, 0.125), metallic=0.25, roughness=0.42),
+ "livery_amber": make_material(bpy, "LIVERY AMBER STRIPE", (0.62, 0.34, 0.05), metallic=0.25, roughness=0.30),
+ "livery_white": make_material(bpy, "LIVERY DISH WHITE", (0.78, 0.81, 0.79), metallic=0.15, 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),
+ "livery_nozzle": make_material(bpy, "LIVERY NOZZLE COIL", (0.16, 0.17, 0.20), metallic=0.85, roughness=0.20),
+ }
+
+
+def clear_scene(bpy):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+
+
+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 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_torus(bpy, name, location, major_radius, minor_radius, clay_material, livery_material, *, rotation=(0.0, 0.0, 0.0), minor_segments=12):
+ bpy.ops.mesh.primitive_torus_add(
+ major_radius=major_radius,
+ minor_radius=minor_radius,
+ major_segments=64,
+ minor_segments=minor_segments,
+ 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
+
+
+# ---------------------------------------------------------------------------
+# Starship layout (units are display metres, unscaled示意 dimensions).
+#
+# nose +X : forward docking / sensor section
+# -X : reactor + nozzle (the ship accelerates toward -X)
+#
+# habitat : torus in the Y-Z plane at x=+1.2 (far from reactor at x<-3)
+# shadow : water-shadow collar at x=-1.2, storm cellar hugs the keel
+# reactor : fusion pressure vessel x in [-4.2, -2.6], nozzle to x=-5.6
+# ---------------------------------------------------------------------------
+
+HAB_X = 1.2
+HAB_RADIUS = 1.7
+HAB_MINOR = 0.30
+SPOKE_COUNT = 6
+
+
+def build_geometry(bpy, materials, step_id):
+ """Build every object of the cumulative step, in STEP_OBJECTS order."""
+ clay_body = materials["clay_body"]
+ clay_structure = materials["clay_structure"]
+ clay_dark = materials["clay_dark"]
+ clay_white = materials["clay_white"]
+ clay_glass = materials["clay_glass"]
+ livery_hull = materials["livery_hull"]
+ livery_structure = materials["livery_structure"]
+ livery_dark = materials["livery_dark"]
+ livery_ceramic = materials["livery_ceramic"]
+ livery_amber = materials["livery_amber"]
+ livery_white = materials["livery_white"]
+ livery_glass = materials["livery_glass"]
+ livery_nozzle = materials["livery_nozzle"]
+
+ def spoke_end(index):
+ angle = 2.0 * math.pi * index / SPOKE_COUNT
+ return (HAB_X, HAB_RADIUS * math.cos(angle), HAB_RADIUS * math.sin(angle))
+
+ objects = {}
+ for object_id in STEP_OBJECTS[step_id]:
+ if object_id == "starship.keel.spine":
+ objects[object_id] = add_beveled_cube(bpy, object_id, (-0.8, 0.0, 0.0), (10.0, 0.46, 0.42), clay_structure, livery_structure, bevel=0.05)
+ elif object_id == "starship.nose.cone":
+ objects[object_id] = add_cone(bpy, object_id, (4.6, 0.0, 0.0), 0.42, 0.08, 1.0, clay_body, livery_hull, rotation=(0.0, -math.pi / 2.0, 0.0))
+ elif object_id == "starship.dock.ring":
+ objects[object_id] = add_torus(bpy, object_id, (4.1, 0.0, 0.0), 0.44, 0.06, clay_structure, livery_amber, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id == "starship.hab.torus":
+ objects[object_id] = add_torus(bpy, object_id, (HAB_X, 0.0, 0.0), HAB_RADIUS, HAB_MINOR, clay_body, livery_hull, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=18)
+ elif object_id == "starship.hub.core":
+ objects[object_id] = add_cylinder(bpy, object_id, (HAB_X, 0.0, 0.0), 0.34, 1.4, clay_structure, livery_structure, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id.startswith("starship.spoke."):
+ index = int(object_id.rsplit(".", 1)[-1])
+ end = spoke_end(index)
+ objects[object_id] = add_between(bpy, object_id, (HAB_X, 0.0, 0.0), end, 0.11, clay_structure, livery_structure, vertices=16)
+ elif object_id == "starship.shadow.collar":
+ objects[object_id] = add_torus(bpy, object_id, (-1.35, 0.0, 0.0), 0.78, 0.24, clay_dark, livery_ceramic, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=16)
+ elif object_id == "starship.storm.cellar":
+ objects[object_id] = add_beveled_cube(bpy, object_id, (-0.55, 0.0, 0.0), (1.1, 0.62, 0.58), clay_dark, livery_ceramic, bevel=0.06)
+ elif object_id == "starship.core.pressure":
+ objects[object_id] = add_cylinder(bpy, object_id, (-3.4, 0.0, 0.0), 0.56, 1.7, clay_structure, livery_nozzle, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id == "starship.core.shield.plug":
+ objects[object_id] = add_cylinder(bpy, object_id, (-2.45, 0.0, 0.0), 0.60, 0.5, clay_dark, livery_ceramic, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id == "starship.nozzle.magnetic":
+ objects[object_id] = add_cone(bpy, object_id, (-5.35, 0.0, 0.0), 1.15, 0.60, 2.4, clay_structure, livery_nozzle, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id.startswith("starship.radiator."):
+ index = int(object_id.rsplit(".", 1)[-1])
+ angle = 2.0 * math.pi * index / 6.0 + math.pi / 6.0
+ y = 0.95 * math.cos(angle)
+ z = 0.95 * math.sin(angle)
+ objects[object_id] = add_beveled_cube(bpy, object_id, (-3.6, y, z), (1.5, 0.04, 0.42), clay_white, livery_white, bevel=0.02, rotation=(angle, 0.0, 0.0))
+ elif object_id == "starship.power.truss":
+ objects[object_id] = add_between(bpy, object_id, (-1.2, 0.0, 0.34), (3.9, 0.0, 0.34), 0.09, clay_structure, livery_structure, vertices=14)
+ elif object_id == "starship.capacitor.ring":
+ objects[object_id] = add_torus(bpy, object_id, (2.6, 0.0, 0.34), 0.42, 0.08, clay_structure, livery_amber, rotation=(math.pi / 2.0, 0.0, 0.0))
+ elif object_id == "starship.comm.dish":
+ objects[object_id] = add_dish(bpy, object_id, 3.4, 0.0, 0.50, 0.55, 0.10, clay_white, livery_white)
+ elif object_id == "starship.comm.feed":
+ objects[object_id] = add_cylinder(bpy, object_id, (3.72, 0.0, 0.50), 0.05, 0.09, clay_glass, livery_glass, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id.startswith("starship.window."):
+ index = int(object_id.rsplit(".", 1)[-1])
+ angle = 2.0 * math.pi * index / SPOKE_COUNT + math.pi / 6.0
+ y = (HAB_RADIUS + HAB_MINOR * 0.55) * math.cos(angle)
+ z = (HAB_RADIUS + HAB_MINOR * 0.55) * math.sin(angle)
+ objects[object_id] = add_cylinder(bpy, object_id, (HAB_X + 0.16, y, z), 0.14, 0.10, clay_glass, livery_glass, rotation=(angle, 0.0, 0.0), vertices=18)
+ elif object_id == "starship.airlock.ring":
+ objects[object_id] = add_torus(bpy, object_id, (4.1, 0.0, 0.0), 0.30, 0.05, clay_structure, livery_amber, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id == "starship.airlock.tunnel":
+ objects[object_id] = add_cylinder(bpy, object_id, (4.55, 0.0, 0.0), 0.26, 0.9, clay_body, livery_hull, rotation=(0.0, math.pi / 2.0, 0.0))
+ elif object_id.startswith("starship.cargo.pod."):
+ index = int(object_id.rsplit(".", 1)[-1])
+ y = (-0.62, 0.62)[index]
+ objects[object_id] = add_beveled_cube(bpy, object_id, (0.35, y, 0.0), (1.2, 0.24, 0.30), clay_body, livery_hull, bevel=0.03)
+ 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("ship.world")
+ scene.world.color = (0.025, 0.035, 0.045)
+
+ camera_data = bpy.data.cameras.new("ship.camera")
+ camera_data.type = "ORTHO"
+ camera_data.ortho_scale = 13.5
+ camera = bpy.data.objects.new("ship.camera", camera_data)
+ bpy.context.collection.objects.link(camera)
+ camera.location = (7.0, -13.5, 7.5)
+ camera.rotation_euler = (Vector((-0.5, 0.0, 0.0)) - camera.location).to_track_quat("-Z", "Y").to_euler()
+ scene.camera = camera
+
+ light_data = bpy.data.lights.new("ship.key", type="AREA")
+ light_data.energy = 1400
+ light_data.shape = "DISK"
+ light_data.size = 6.0
+ light = bpy.data.objects.new("ship.key", light_data)
+ bpy.context.collection.objects.link(light)
+ light.location = (5.0, -6.0, 9.0)
+ light.rotation_euler = (Vector((-0.5, 0.0, 0.0)) - 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("starship result written: " + result_output)
+ except SystemExit:
+ raise
+ except Exception as exc:
+ fail("starship failed: " + str(exc), 4)
+
+
+if __name__ == "__main__":
+ main()