summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 20:12:44 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 20:12:44 +0800
commit8d594b6a541c5ee2ab3ddf9fe870df790d0bb357 (patch)
treef01c242ae826fb74add371bdb650f16603d433ad
parent8a1e79e46605291234314bd1a02dcab7a8c9bdc1 (diff)
downloadsomhairles-dream-fsharp-8d594b6a541c5ee2ab3ddf9fe870df790d0bb357.tar.gz
feat(detail): engineering detail pass on both models + PBR studio pipeline (ACES, env reflections, rim/bounce lights)
-rw-r--r--public/App.js15
-rw-r--r--public/index.html2
-rw-r--r--src/SomhairlesDream.Frontend/App.fs18
-rw-r--r--src/SomhairlesDream.Frontend/Bindings.fs27
-rw-r--r--stage/blender/probe.py152
-rw-r--r--stage/blender/ship.py207
6 files changed, 402 insertions, 19 deletions
diff --git a/public/App.js b/public/App.js
index f63ae7e..eb47dbc 100644
--- a/public/App.js
+++ b/public/App.js
@@ -306,17 +306,30 @@ export function initializeView() {
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height, false);
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.toneMappingExposure = 1.15;
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = (new THREE.Color("#11171a"));
+ const envScene = new THREE.Scene();
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ const envTexture = pmrem.fromScene(envScene, 0.04).texture;
+ scene.environment = envTexture;
+ scene.environmentIntensity = 0.55;
const camera = new THREE.PerspectiveCamera(34, (width / height), 0.1, 100);
camera.position.set(6.4, 4.6, 7.2);
camera.lookAt(0, 0, 0);
const group = new THREE.Group();
scene.add(group);
- scene.add(new THREE.AmbientLight("#f0e6c8", 1.9));
+ scene.add(new THREE.AmbientLight("#f0e6c8", 0.55));
const keyLight = new THREE.DirectionalLight("#f2c76b", 2.8);
keyLight.position.set(4, 7, 5);
scene.add(keyLight);
+ const rimLight = new THREE.DirectionalLight("#7fa8ff", 1.4);
+ rimLight.position.set(-6, -3, -4);
+ scene.add(rimLight);
+ const bounceLight = new THREE.HemisphereLight("#3d4a56", "#141a1e", 0.9);
+ scene.add(bounceLight);
const controls = new OrbitControls(camera, canvas);
controls.target.copy({ x: 0, y: 0, z: 0 });
controls.enableDamping = true;
diff --git a/public/index.html b/public/index.html
index 63fa727..267d21b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -179,6 +179,6 @@
<span>heritage 参考线保留占位演示 · F# / OPEN CODE</span>
</footer>
</main>
- <script type="module" src="/App.js?v=model1"></script>
+ <script type="module" src="/App.js?v=detail1"></script>
</body>
</html>
diff --git a/src/SomhairlesDream.Frontend/App.fs b/src/SomhairlesDream.Frontend/App.fs
index dbb267c..315a0fc 100644
--- a/src/SomhairlesDream.Frontend/App.fs
+++ b/src/SomhairlesDream.Frontend/App.fs
@@ -241,17 +241,33 @@ let initializeView () =
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(width, height, false)
+ // PBR pipeline: ACES tone mapping + a procedural studio environment map
+ // so metalness/roughness from the GLB materials read as machined
+ // hardware instead of flat plastic.
+ setToneMappingACES renderer
+ setToneMappingExposure (renderer, 1.15)
+ setSRGBOutput renderer
let scene = createScene()
scene.background <- createColor("#11171a")
+ let envScene = createEnvScene()
+ let pmrem = createPMREMGenerator renderer
+ let envTexture = pmremFromScene (pmrem, envScene)
+ setSceneEnvironment (scene, envTexture)
+ setSceneEnvironmentIntensity (scene, 0.55)
let camera = createPerspectiveCamera(34.0, width / height, 0.1, 100.0)
camera.position.set(6.4, 4.6, 7.2) |> ignore
camera.lookAt(0.0, 0.0, 0.0)
let group = createGroup()
scene.add(group)
- scene.add(createAmbientLight("#f0e6c8", 1.9))
+ scene.add(createAmbientLight("#f0e6c8", 0.55))
let keyLight = createDirectionalLight("#f2c76b", 2.8)
keyLight.position.set(4.0, 7.0, 5.0) |> ignore
scene.add(keyLight)
+ let rimLight = createDirectionalLight("#7fa8ff", 1.4)
+ rimLight.position.set(-6.0, -3.0, -4.0) |> ignore
+ scene.add(rimLight)
+ let bounceLight = createHemisphereLight("#3d4a56", "#141a1e", 0.9)
+ scene.add(bounceLight)
let controls = createOrbitControls camera canvas
setControlsTarget controls 0.0 0.0 0.0
diff --git a/src/SomhairlesDream.Frontend/Bindings.fs b/src/SomhairlesDream.Frontend/Bindings.fs
index 19056a5..b6c4353 100644
--- a/src/SomhairlesDream.Frontend/Bindings.fs
+++ b/src/SomhairlesDream.Frontend/Bindings.fs
@@ -154,6 +154,33 @@ let createAmbientLight (color: string, intensity: float) : ThreeNode = jsNative
[<Emit("new THREE.DirectionalLight($0, $1)")>]
let createDirectionalLight (color: string, intensity: float) : ThreeNode = jsNative
+[<Emit("new THREE.HemisphereLight($0, $1, $2)")>]
+let createHemisphereLight (skyColor: string, groundColor: string, intensity: float) : ThreeNode = jsNative
+
+[<Emit("$0.toneMapping = THREE.ACESFilmicToneMapping")>]
+let setToneMappingACES (renderer: obj) : unit = jsNative
+
+[<Emit("$0.toneMappingExposure = $1")>]
+let setToneMappingExposure (renderer: obj, exposure: float) : unit = jsNative
+
+[<Emit("$0.outputColorSpace = THREE.SRGBColorSpace")>]
+let setSRGBOutput (renderer: obj) : unit = jsNative
+
+[<Emit("new THREE.PMREMGenerator($0)")>]
+let createPMREMGenerator (renderer: obj) : obj = jsNative
+
+[<Emit("new THREE.Scene()")>]
+let createEnvScene () : obj = jsNative
+
+[<Emit("$0.fromScene($1, 0.04).texture")>]
+let pmremFromScene (generator: obj, scene: obj) : obj = jsNative
+
+[<Emit("$0.environment = $1")>]
+let setSceneEnvironment (scene: obj, texture: obj) : unit = jsNative
+
+[<Emit("$0.environmentIntensity = $1")>]
+let setSceneEnvironmentIntensity (scene: obj, intensity: float) : unit = jsNative
+
[<Emit("new EventSource($0)")>]
let createEventSource (url: string) : EventSource = jsNative
diff --git a/stage/blender/probe.py b/stage/blender/probe.py
index aaffaf4..9b9eb34 100644
--- a/stage/blender/probe.py
+++ b/stage/blender/probe.py
@@ -563,6 +563,129 @@ def apply_material_mode(bpy, materials, mode):
obj.data.materials.append(material)
+def merge_details(bpy, master, extras):
+ """Join detail meshes into the master object so the exported GLB keeps
+ exactly the contract object names while carrying real secondary detail."""
+ if not extras:
+ return master
+ for obj in bpy.context.selected_objects:
+ obj.select_set(False)
+ for extra in extras:
+ extra.select_set(True)
+ master.select_set(True)
+ bpy.context.view_layer.objects.active = master
+ bpy.ops.object.join()
+ return master
+
+
+def detail_torus(bpy, name, location, major_radius, minor_radius, rotation=(0.0, 0.0, 0.0), minor_segments=10):
+ bpy.ops.mesh.primitive_torus_add(
+ major_radius=major_radius,
+ minor_radius=minor_radius,
+ major_segments=48,
+ minor_segments=minor_segments,
+ location=location,
+ rotation=rotation,
+ )
+ obj = bpy.context.object
+ obj.name = name
+ return obj
+
+
+def detail_cube(bpy, name, location, dimensions, rotation=(0.0, 0.0, 0.0)):
+ bpy.ops.mesh.primitive_cube_add(location=location, rotation=rotation)
+ obj = bpy.context.object
+ obj.name = name
+ obj.dimensions = dimensions
+ bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
+ return obj
+
+
+def detail_cylinder(bpy, name, location, radius, depth, rotation=(0.0, 0.0, 0.0), vertices=20):
+ bpy.ops.mesh.primitive_cylinder_add(
+ vertices=vertices,
+ radius=radius,
+ depth=depth,
+ location=location,
+ rotation=rotation,
+ )
+ obj = bpy.context.object
+ obj.name = name
+ return obj
+
+
+def build_details(bpy, object_id, master, materials):
+ """Secondary engineering detail per contract object, joined into the
+ master mesh before export; detail names never reach the GLB."""
+ details = []
+
+ if object_id == "probe.bus.electronics.decagon":
+ # Panel seams around the decagon bus + mounting bosses on both faces.
+ for index in range(10):
+ angle = 2.0 * math.pi * index / 10.0
+ y = 0.455 * math.cos(angle)
+ z = 0.455 * math.sin(angle)
+ details.append(detail_cube(bpy, f"{master.name}.detail.seam.{index:02d}", (0.0, y, z), (0.50, 0.03, 0.03), rotation=(angle, 0.0, 0.0)))
+ for offset in (-0.24, 0.24):
+ details.append(detail_cylinder(bpy, f"{master.name}.detail.boss.{offset:+.2f}", (offset, 0.0, 0.0), 0.30, 0.03, 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)
+ # Fastener posts and a hinge rib per bay panel.
+ details.append(detail_cylinder(bpy, master.name + ".detail.hinge", (0.10, y, z), 0.018, 0.30, rotation=(angle, 0.0, 0.0), vertices=8))
+ details.append(detail_cube(bpy, master.name + ".detail.latch", (-0.14, y * 1.02, z * 1.02), (0.05, 0.04, 0.10), rotation=(angle, 0.0, 0.0)))
+ 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]
+ # Thruster mount plate + feed line to the bus core.
+ details.append(detail_cube(bpy, master.name + ".detail.mount", (-0.24, y, z), (0.10, 0.12, 0.12)))
+ details.append(detail_cylinder(bpy, master.name + ".detail.feed", (-0.24, y * 0.55, z * 0.55), 0.014, 0.5, vertices=8))
+ 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
+ details.append(detail_torus(bpy, master.name + ".detail.collar", (-0.12, y, -0.16), 0.118, 0.010, rotation=(math.pi / 2.0, 0.0, 0.0), minor_segments=10))
+ elif object_id == "probe.rtg.boom":
+ # Boom truss cross-bracing between the three RTG housings.
+ for index in range(3):
+ y = -0.98 - index * 0.42
+ details.append(detail_torus(bpy, f"{master.name}.detail.brace.{index:02d}", (-0.12, y, -0.16), 0.035, 0.008, minor_segments=10))
+ elif object_id.startswith("probe.rtg."):
+ index = int(object_id.rsplit(".", 1)[-1])
+ y = (-0.80, -1.22, -1.64)[index - 1]
+ # Radiator fins and end caps along each RTG housing.
+ for offset in (-0.09, 0.0, 0.09):
+ details.append(detail_torus(bpy, f"{master.name}.detail.fins.{offset:+.2f}", (-0.12, y + offset, -0.16), 0.115, 0.008, rotation=(math.pi / 2.0, 0.0, 0.0), minor_segments=10))
+ elif object_id == "probe.mag.head":
+ details.append(detail_torus(bpy, master.name + ".detail.collar", (0.0, 6.50, 0.18), 0.105, 0.014, rotation=(math.pi / 2.0, 0.0, 0.0), minor_segments=10))
+ elif object_id == "probe.hga.reflector":
+ # Back-side ribs across the dish so the reflector reads as a rigid
+ # structure, not a floating surface.
+ for index in range(6):
+ angle = 2.0 * math.pi * index / 6.0
+ y = 0.55 * math.cos(angle)
+ z = 0.55 * math.sin(angle)
+ details.append(detail_cube(bpy, f"{master.name}.detail.rib.{index:02d}", (0.22, y * 0.55, z * 0.55), (0.06, 0.04, 0.04)))
+ details.append(detail_cylinder(bpy, master.name + ".detail.hub", (0.30, 0.0, 0.0), 0.10, 0.06, rotation=(0.0, math.pi / 2.0, 0.0)))
+ elif object_id == "probe.scan.platform":
+ for offset in (-0.12, 0.0, 0.12):
+ details.append(detail_cylinder(bpy, f"{master.name}.detail.bolt.{offset:+.2f}", (0.0, offset, 1.67), 0.014, 0.02, vertices=8))
+ details.append(detail_torus(bpy, master.name + ".detail.ring", (0.0, 0.0, 1.665), 0.16, 0.012, minor_segments=10))
+ elif object_id.startswith("probe.pws.tip."):
+ side = object_id.rsplit(".", 1)[-1]
+ sign = -1.0 if side == "left" else 1.0
+ details.append(detail_cylinder(bpy, master.name + ".detail.cap", (-0.12, sign * 2.50, 1.10), 0.020, 0.03, rotation=(math.pi / 2.0, 0.0, 0.0), vertices=8))
+ elif object_id == "probe.science.boom":
+ for index in range(4):
+ z = 0.60 + index * 0.30
+ details.append(detail_torus(bpy, f"{master.name}.detail.guide.{index:02d}", (0.0, 0.0, z), 0.042, 0.008, minor_segments=10))
+ return details
+
+
+
def build_geometry(bpy, materials, step_id):
"""Build every object of the cumulative step, in STEP_OBJECTS order.
@@ -674,6 +797,12 @@ def build_geometry(bpy, materials, step_id):
else:
fail("unknown object id: " + object_id, 4)
+ # Secondary engineering detail is generated per contract object and
+ # merged into the master mesh, keeping the GLB object contract intact.
+ for object_id in list(STEP_OBJECTS[step_id]):
+ master = objects[object_id]
+ merge_details(bpy, master, build_details(bpy, object_id, master, materials))
+
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:
@@ -702,14 +831,21 @@ def add_render_setup(bpy):
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 add_light(name, location, energy, size, color=(1.0, 1.0, 1.0)):
+ light_data = bpy.data.lights.new(name, type="AREA")
+ light_data.energy = energy
+ light_data.shape = "DISK"
+ light_data.size = size
+ light_data.color = color
+ light = bpy.data.objects.new(name, light_data)
+ bpy.context.collection.objects.link(light)
+ light.location = location
+ light.rotation_euler = (Vector((0.0, 2.0, 0.2)) - Vector(location)).to_track_quat("-Z", "Y").to_euler()
+ return light
+
+ add_light("probe.key", (4.0, -4.0, 8.0), 900, 5.0, (1.0, 0.98, 0.94))
+ add_light("probe.fill", (-6.0, -8.0, 3.0), 300, 8.0, (0.72, 0.82, 1.0))
+ add_light("probe.rim", (-5.0, 7.0, -2.5), 650, 3.0, (1.0, 0.86, 0.62))
def export_glb(bpy, objects, output):
diff --git a/stage/blender/ship.py b/stage/blender/ship.py
index 21e2ab0..e1dd30c 100644
--- a/stage/blender/ship.py
+++ b/stage/blender/ship.py
@@ -388,6 +388,184 @@ def add_dish(bpy, name, base_x, center_y, center_z, radius, depth, clay_material
return obj
+
+
+def merge_details(bpy, master, extras):
+ """Join detail meshes into the master object so the exported GLB keeps
+ exactly the contract object names while carrying real secondary detail."""
+ if not extras:
+ return master
+ for obj in bpy.context.selected_objects:
+ obj.select_set(False)
+ for extra in extras:
+ extra.select_set(True)
+ master.select_set(True)
+ bpy.context.view_layer.objects.active = master
+ bpy.ops.object.join()
+ return master
+
+
+def detail_torus(bpy, name, location, major_radius, minor_radius, rotation=(0.0, 0.0, 0.0), minor_segments=10):
+ bpy.ops.mesh.primitive_torus_add(
+ major_radius=major_radius,
+ minor_radius=minor_radius,
+ major_segments=48,
+ minor_segments=minor_segments,
+ location=location,
+ rotation=rotation,
+ )
+ obj = bpy.context.object
+ obj.name = name
+ return obj
+
+
+def detail_cube(bpy, name, location, dimensions, rotation=(0.0, 0.0, 0.0)):
+ bpy.ops.mesh.primitive_cube_add(location=location, rotation=rotation)
+ obj = bpy.context.object
+ obj.name = name
+ obj.dimensions = dimensions
+ bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
+ return obj
+
+
+def detail_cylinder(bpy, name, location, radius, depth, rotation=(0.0, 0.0, 0.0), vertices=20):
+ bpy.ops.mesh.primitive_cylinder_add(
+ vertices=vertices,
+ radius=radius,
+ depth=depth,
+ location=location,
+ rotation=rotation,
+ )
+ obj = bpy.context.object
+ obj.name = name
+ return obj
+
+
+def build_details(bpy, object_id, master, materials):
+ """Secondary engineering detail per contract object (joined back into the
+ master mesh before export). Names below never reach the GLB as objects."""
+ clay = None
+ details = []
+
+ if object_id == "starship.keel.spine":
+ # Reinforcement rings along the spine + twin conduit lines + equipment
+ # blocks riding the top ridge.
+ for index in range(14):
+ x = -5.2 + index * 0.72
+ details.append(detail_torus(
+ bpy,
+ f"{master.name}.detail.ring.{index:02d}",
+ (x, 0.0, 0.0),
+ 0.40,
+ 0.028,
+ rotation=(0.0, math.pi / 2.0, 0.0),
+ ))
+ details.append(detail_cylinder(bpy, master.name + ".detail.conduit.0", (-0.8, 0.30, 0.10), 0.035, 9.4, rotation=(0.0, math.pi / 2.0, 0.0)))
+ details.append(detail_cylinder(bpy, master.name + ".detail.conduit.1", (-0.8, -0.30, 0.10), 0.035, 9.4, rotation=(0.0, math.pi / 2.0, 0.0)))
+ for index, x in enumerate((-3.9, -1.9, 0.6, 2.9)):
+ details.append(detail_cube(bpy, f"{master.name}.detail.box.{index:02d}", (x, 0.0, 0.30), (0.5, 0.30, 0.16)))
+ elif object_id == "starship.hab.torus":
+ # Outer panel ribs every 15 degrees, inner twin conduits, and two
+ # comm masts riding the ring.
+ for index in range(24):
+ angle = 2.0 * math.pi * index / 24.0
+ y = HAB_RADIUS * math.cos(angle)
+ z = HAB_RADIUS * math.sin(angle)
+ rotation = (angle, 0.0, 0.0)
+ details.append(detail_cube(
+ bpy,
+ f"{master.name}.detail.rib.{index:02d}",
+ (HAB_X + 0.06, y, z),
+ (0.10, 0.08, 0.09),
+ rotation=rotation,
+ ))
+ for offset in (-0.10, 0.10):
+ details.append(detail_torus(
+ bpy,
+ f"{master.name}.detail.conduit.{offset:+.2f}",
+ (HAB_X + offset, 0.0, 0.0),
+ HAB_RADIUS - HAB_MINOR - 0.04,
+ 0.020,
+ rotation=(0.0, math.pi / 2.0, 0.0),
+ minor_segments=8,
+ ))
+ details.append(detail_cylinder(bpy, master.name + ".detail.mast.0", (HAB_X - 0.02, HAB_RADIUS + HAB_MINOR, 0.0), 0.02, 0.5, rotation=(0.0, math.pi / 2.0, 0.0)))
+ details.append(detail_cylinder(bpy, master.name + ".detail.mast.1", (HAB_X + 0.02, 0.0, -(HAB_RADIUS + HAB_MINOR)), 0.02, 0.5))
+ elif object_id == "starship.hub.core":
+ for offset, radius in ((-0.74, 0.40), (0.74, 0.40)):
+ details.append(detail_cylinder(bpy, f"{master.name}.detail.flange.{offset:+.2f}", (HAB_X + offset, 0.0, 0.0), radius, 0.06, rotation=(0.0, math.pi / 2.0, 0.0)))
+ details.append(detail_torus(bpy, master.name + ".detail.bearing", (HAB_X, 0.0, 0.0), 0.34, 0.035, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=10))
+ elif object_id == "starship.core.pressure":
+ for index in range(4):
+ x = -4.05 + index * 0.42
+ details.append(detail_torus(bpy, f"{master.name}.detail.band.{index:02d}", (x, 0.0, 0.0), 0.58, 0.030, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=10))
+ for offset in (-0.92, 0.92):
+ details.append(detail_cylinder(bpy, f"{master.name}.detail.flange.{offset:+.2f}", (-3.4 + offset, 0.0, 0.0), 0.60, 0.07, rotation=(0.0, math.pi / 2.0, 0.0)))
+ elif object_id == "starship.shadow.collar":
+ for index in range(8):
+ angle = 2.0 * math.pi * index / 8.0
+ y = 0.86 * math.cos(angle)
+ z = 0.86 * math.sin(angle)
+ details.append(detail_cube(bpy, f"{master.name}.detail.rib.{index:02d}", (-1.35, y, z), (0.34, 0.10, 0.10), rotation=(angle, 0.0, 0.0)))
+ elif object_id == "starship.storm.cellar":
+ details.append(detail_cube(bpy, master.name + ".detail.doorframe", (-0.55, 0.0, 0.30), (0.66, 0.52, 0.05)))
+ details.append(detail_cylinder(bpy, master.name + ".detail.hinge", (-0.55, -0.32, 0.0), 0.035, 0.5))
+ elif object_id == "starship.nozzle.magnetic":
+ # Coil grooves down the bell + coolant lines hugging the cone.
+ for index in range(5):
+ fraction = 0.25 + index * 0.14
+ x = -6.35 + 2.2 * fraction
+ radius = 1.15 - 0.55 * fraction
+ details.append(detail_torus(bpy, f"{master.name}.detail.coil.{index:02d}", (x, 0.0, 0.0), radius, 0.034, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=10))
+ for index in range(4):
+ angle = 2.0 * math.pi * index / 4.0 + math.pi / 4.0
+ y = 0.95 * math.cos(angle)
+ z = 0.95 * math.sin(angle)
+ details.append(detail_cylinder(bpy, f"{master.name}.detail.coolant.{index:02d}", (-5.35, y, z), 0.028, 2.5, rotation=(0.0, math.pi / 2.0, 0.0)))
+ elif object_id == "starship.power.truss":
+ for index in range(6):
+ t = index / 5.0
+ x = -1.2 + t * 5.1
+ details.append(detail_cylinder(bpy, f"{master.name}.detail.diagonal.{index:02d}", (x, 0.0, 0.34), 0.022, 0.34, rotation=(0.0, math.pi / 2.0, 0.0), vertices=8))
+ details.append(detail_torus(bpy, master.name + ".detail.hoop", (1.6, 0.0, 0.34), 0.40, 0.028, rotation=(math.pi / 2.0, 0.0, 0.0), minor_segments=10))
+ 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)
+ details.append(detail_cube(bpy, master.name + ".detail.backbone", (-3.6, y, z), (1.5, 0.05, 0.06)))
+ for rib in (-0.45, 0.0, 0.45):
+ details.append(detail_cube(bpy, f"{master.name}.detail.stiff.{rib:+.2f}", (-3.6 + rib, y, z), (0.05, 0.06, 0.40)))
+ elif object_id.startswith("starship.cargo.pod."):
+ index = int(object_id.rsplit(".", 1)[-1])
+ y = (-0.62, 0.62)[index]
+ for offset in (-0.5, 0.0, 0.5):
+ details.append(detail_cube(bpy, f"{master.name}.detail.rib.{offset:+.2f}", (0.35 + offset, y, 0.0), (0.05, 0.26, 0.32)))
+ details.append(detail_cylinder(bpy, master.name + ".detail.endcap", (0.97, y, 0.0), 0.10, 0.05, rotation=(0.0, math.pi / 2.0, 0.0)))
+ elif object_id == "starship.nose.cone":
+ details.append(detail_torus(bpy, master.name + ".detail.seam.0", (4.45, 0.0, 0.0), 0.26, 0.02, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=8))
+ details.append(detail_torus(bpy, master.name + ".detail.seam.1", (4.75, 0.0, 0.0), 0.16, 0.018, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=8))
+ elif object_id == "starship.comm.dish":
+ # Mounting mast rising from the keel deck + gimbal block under the
+ # dish base, so the dish reads as bolted hardware instead of floating.
+ details.append(detail_cylinder(bpy, master.name + ".detail.post", (3.40, 0.0, 0.37), 0.045, 0.30, vertices=12))
+ details.append(detail_cube(bpy, master.name + ".detail.gimbal", (3.40, 0.0, 0.46), (0.16, 0.16, 0.10)))
+ details.append(detail_cube(bpy, master.name + ".detail.baseplate", (3.40, 0.0, 0.24), (0.26, 0.26, 0.05)))
+ elif object_id.startswith("starship.spoke."):
+ index = int(object_id.rsplit(".", 1)[-1])
+ angle = 2.0 * math.pi * index / SPOKE_COUNT
+ end_y = HAB_RADIUS * math.cos(angle)
+ end_z = HAB_RADIUS * math.sin(angle)
+ # Collar at the ring end and a hinge ring at the hub end.
+ details.append(detail_torus(bpy, master.name + ".detail.cuff", (HAB_X, end_y * 0.92, end_z * 0.92), 0.16, 0.045, rotation=(angle, 0.0, 0.0), minor_segments=12))
+ details.append(detail_torus(bpy, master.name + ".detail.root", (HAB_X + 0.06, 0.0, 0.0), 0.40, 0.045, rotation=(0.0, math.pi / 2.0, 0.0), minor_segments=12))
+ else:
+ return []
+
+ _ = clay
+ return details
+
+
# ---------------------------------------------------------------------------
# Starship layout (units are display metres, unscaled示意 dimensions).
#
@@ -482,6 +660,12 @@ def build_geometry(bpy, materials, step_id):
else:
fail("unknown object id: " + object_id, 4)
+ # Secondary engineering detail is generated per contract object and
+ # merged into the master mesh, keeping the GLB object contract intact.
+ for object_id in list(STEP_OBJECTS[step_id]):
+ master = objects[object_id]
+ merge_details(bpy, master, build_details(bpy, object_id, master, materials))
+
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:
@@ -510,14 +694,21 @@ def add_render_setup(bpy):
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 add_light(name, location, energy, size, color=(1.0, 1.0, 1.0)):
+ light_data = bpy.data.lights.new(name, type="AREA")
+ light_data.energy = energy
+ light_data.shape = "DISK"
+ light_data.size = size
+ light_data.color = color
+ light = bpy.data.objects.new(name, light_data)
+ bpy.context.collection.objects.link(light)
+ light.location = location
+ light.rotation_euler = (Vector((-0.5, 0.0, 0.0)) - Vector(location)).to_track_quat("-Z", "Y").to_euler()
+ return light
+
+ add_light("ship.key", (5.0, -6.0, 9.0), 1400, 6.0, (1.0, 0.98, 0.94))
+ add_light("ship.fill", (-4.0, -9.0, 2.0), 420, 8.0, (0.72, 0.82, 1.0))
+ add_light("ship.rim", (-7.5, 5.0, -3.0), 900, 3.0, (1.0, 0.86, 0.62))
def export_glb(bpy, objects, output):