From d2a4cb4e645979f20c22a5c46c1c767db460f54a Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 15:16:23 +0800 Subject: feat(ship): HNX-01 crewed starship design branch (7-step ship.py, --design ship, 409 replays completed run history) --- README.md | 8 +- public/App.js | 102 +++-- public/index.html | 2 +- src/SomhairlesDream.Cli/Arguments.fs | 32 +- src/SomhairlesDream.Cli/Program.fs | 2 +- src/SomhairlesDream.Frontend/App.fs | 91 ++-- src/SomhairlesDream.Modeling/BlenderBridge.fs | 5 +- src/SomhairlesDream.Modeling/Definitions.fs | 86 +++- stage/blender/ship.py | 624 ++++++++++++++++++++++++++ 9 files changed, 846 insertions(+), 106 deletions(-) create mode 100644 stage/blender/ship.py 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 @@ heritage 参考线保留占位演示 · F# / OPEN CODE - + 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 \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 --output --result-output [--render-output ] + +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.[.], 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() -- cgit v1.2.3