summaryrefslogtreecommitdiff
path: root/stage/blender/ship.py
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 23:47:42 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 23:47:42 +0800
commit5d73943e584b1c0c0753dda85fbda34d27726986 (patch)
tree5126127692f3cd399345aae8ade5ec66dd247668 /stage/blender/ship.py
parent7c3a0728570f84f5c7d5f396c5a5d5c11f5fb661 (diff)
downloadsomhairles-dream-fsharp-5d73943e584b1c0c0753dda85fbda34d27726986.tar.gz
feat(ship): HNX-02 Copernicus rework — continuous hull, few-large-element scale, Draco pipeline, side-on framing
Diffstat (limited to 'stage/blender/ship.py')
-rw-r--r--stage/blender/ship.py1705
1 files changed, 1023 insertions, 682 deletions
diff --git a/stage/blender/ship.py b/stage/blender/ship.py
index e1dd30c..0f30d54 100644
--- a/stage/blender/ship.py
+++ b/stage/blender/ship.py
@@ -1,697 +1,1051 @@
-"""HNX-01 crewed interstellar starship dataset for the artifact pipeline.
+"""HNX-02 "Copernicus-class" crewed interplanetary transfer vehicle.
-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):
+Rebuild of the crewed starship with procedural engineering structures
+(space trusses, pressure vessels, habitat modules, a spin-gravity
+centrifuge, an NTP engine cluster, and a docked Orion-class shuttle as
+the scale reference) instead of stacked primitives.
--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.
+Design intent (display-scale visual research, NOT closed engineering):
+ - Nuclear-thermal propulsion (NERVA-class stainless/epoxy composite
+ fuel element heritage) with a shadow shield between reactor and crew.
+ - Axial layout keeps the crew section as far from the reactor as the
+ truss allows; the water-wall tanks double as radiation shielding.
+ - The centrifuge ring provides spin gravity; cars are the pressurized
+ crew volumes. No mass/thrust/shielding closure is claimed.
+
+Object ids: hnx02.<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 random
import sys
-from datetime import datetime, timezone
-import bpy # noqa: F401 (must be imported before mathutils: registers the bundled mathutils module)
+import bpy # noqa: F401 (must be imported first: registers bundled bmesh/mathutils)
+import bmesh
from mathutils import Vector
+# --------------------------------------------------------------- contract
+
STEP_OBJECTS = {
"ship-01-keel": [
- "starship.keel.spine",
- "starship.nose.cone",
- "starship.dock.ring",
+ "hnx02.hull.core",
+ "hnx02.spine.truss",
+ "hnx02.node.forward",
],
"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",
+ "hnx02.hull.core",
+ "hnx02.spine.truss",
+ "hnx02.node.forward",
+ "hnx02.hab.a",
+ "hnx02.hab.b",
+ "hnx02.hab.whipple",
],
"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",
+ "hnx02.hull.core",
+ "hnx02.spine.truss",
+ "hnx02.node.forward",
+ "hnx02.hab.a",
+ "hnx02.hab.b",
+ "hnx02.hab.whipple",
+ "hnx02.rad.waterwall",
+ "hnx02.centrifuge.ring",
],
"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",
+ "hnx02.hull.core",
+ "hnx02.spine.truss",
+ "hnx02.node.forward",
+ "hnx02.hab.a",
+ "hnx02.hab.b",
+ "hnx02.hab.whipple",
+ "hnx02.rad.waterwall",
+ "hnx02.centrifuge.ring",
+ "hnx02.prop.tank",
+ "hnx02.reactor.core",
+ "hnx02.thrust.cone",
+ "hnx02.nozzle.cluster",
],
"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",
+ "hnx02.hull.core",
+ "hnx02.spine.truss",
+ "hnx02.node.forward",
+ "hnx02.hab.a",
+ "hnx02.hab.b",
+ "hnx02.hab.whipple",
+ "hnx02.rad.waterwall",
+ "hnx02.centrifuge.ring",
+ "hnx02.prop.tank",
+ "hnx02.reactor.core",
+ "hnx02.thrust.cone",
+ "hnx02.nozzle.cluster",
+ "hnx02.power.solar",
+ "hnx02.power.radiator",
],
"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",
+ "hnx02.hull.core",
+ "hnx02.spine.truss",
+ "hnx02.node.forward",
+ "hnx02.hab.a",
+ "hnx02.hab.b",
+ "hnx02.hab.whipple",
+ "hnx02.rad.waterwall",
+ "hnx02.centrifuge.ring",
+ "hnx02.prop.tank",
+ "hnx02.reactor.core",
+ "hnx02.thrust.cone",
+ "hnx02.nozzle.cluster",
+ "hnx02.power.solar",
+ "hnx02.power.radiator",
+ "hnx02.cabin.airlock",
+ "hnx02.shuttle.orion",
],
"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",
+ "hnx02.hull.core",
+ "hnx02.spine.truss",
+ "hnx02.node.forward",
+ "hnx02.hab.a",
+ "hnx02.hab.b",
+ "hnx02.hab.whipple",
+ "hnx02.rad.waterwall",
+ "hnx02.centrifuge.ring",
+ "hnx02.prop.tank",
+ "hnx02.reactor.core",
+ "hnx02.thrust.cone",
+ "hnx02.nozzle.cluster",
+ "hnx02.power.solar",
+ "hnx02.power.radiator",
+ "hnx02.cabin.airlock",
+ "hnx02.shuttle.orion",
+ "hnx02.detail.greeble",
+ "hnx02.detail.antenna",
],
}
-LIVERY_STEPS = {"ship-07-livery"}
+# Material keys per object id.
+OBJECT_MATERIAL = {
+ "hnx02.hull.core": "white",
+ "hnx02.spine.truss": "hull",
+ "hnx02.node.forward": "white",
+ "hnx02.hab.a": "white",
+ "hnx02.hab.b": "white",
+ "hnx02.hab.whipple": "shield",
+ "hnx02.rad.waterwall": "hull",
+ "hnx02.centrifuge.ring": "hull",
+ "hnx02.reactor.core": "dark",
+ "hnx02.prop.tank": "gold",
+ "hnx02.thrust.cone": "hull",
+ "hnx02.nozzle.cluster": "dark",
+ "hnx02.power.solar": "pv",
+ "hnx02.power.radiator": "radiator",
+ "hnx02.cabin.airlock": "white",
+ "hnx02.shuttle.orion": "white",
+ "hnx02.detail.greeble": "hull",
+ "hnx02.detail.antenna": "hull",
+}
def utc_now():
- return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
+ from datetime import datetime, timezone
+
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def fail(message, code):
- print(message, file=sys.stderr)
- raise SystemExit(code)
+ print(json.dumps({"status": "failed", "message": message}))
+ sys.exit(code)
-def rgba(rgb, alpha=1.0):
- return (*rgb, alpha)
+# --------------------------------------------------------------- materials
-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)
+def make_material(bpy, name, color, *, metallic=0.0, roughness=0.45):
+ material = bpy.data.materials.get(name)
+ if material is not None:
+ return material
+ material = 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
+ bsdf = material.node_tree.nodes.get("Principled BSDF")
+ bsdf.inputs["Base Color"].default_value = (*color, 1.0)
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
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),
+ "hull": make_material(bpy, "hnx02.hull", (0.62, 0.64, 0.66), metallic=0.88, roughness=0.38),
+ "white": make_material(bpy, "hnx02.white", (0.86, 0.86, 0.84), metallic=0.18, roughness=0.52),
+ "shield": make_material(bpy, "hnx02.shield", (0.44, 0.46, 0.50), metallic=0.30, roughness=0.72),
+ "dark": make_material(bpy, "hnx02.dark", (0.10, 0.11, 0.13), metallic=0.72, roughness=0.48),
+ "radiator": make_material(bpy, "hnx02.radiator", (0.90, 0.91, 0.92), metallic=0.08, roughness=0.30),
+ "pv": make_material(bpy, "hnx02.pv", (0.07, 0.10, 0.24), metallic=0.35, roughness=0.28),
+ "gold": make_material(bpy, "hnx02.gold", (0.82, 0.58, 0.19), metallic=1.0, roughness=0.30),
+ "copper": make_material(bpy, "hnx02.copper", (0.70, 0.44, 0.19), metallic=1.0, roughness=0.34),
}
-def clear_scene(bpy):
- bpy.ops.wm.read_factory_settings(use_empty=True)
-
+# ------------------------------------------------------------ bmesh kit
+
+
+def basis(axis):
+ z = Vector(axis).normalized()
+ ref = Vector((0.0, 0.0, 1.0)) if abs(z.z) < 0.9 else Vector((1.0, 0.0, 0.0))
+ x = ref.cross(z).normalized()
+ y = z.cross(x).normalized()
+ return x, y, z
+
+
+def cyl(bm, p1, p2, r1, r2=None, seg=12, cap1=True, cap2=True):
+ p1, p2 = Vector(p1), Vector(p2)
+ r2 = r1 if r2 is None else r2
+ d = p2 - p1
+ if d.length < 1e-7:
+ return
+ x, y, _ = basis(d)
+ ring1, ring2 = [], []
+ for i in range(seg):
+ a = 2.0 * math.pi * i / seg
+ off = x * math.cos(a) + y * math.sin(a)
+ ring1.append(bm.verts.new(p1 + off * r1))
+ ring2.append(bm.verts.new(p2 + off * r2))
+ for i in range(seg):
+ j = (i + 1) % seg
+ bm.faces.new((ring1[i], ring1[j], ring2[j], ring2[i]))
+ if cap1:
+ bm.faces.new(tuple(reversed(ring1)))
+ if cap2:
+ bm.faces.new(tuple(ring2))
+
+
+def torus(bm, center, R, r, axis=(0.0, 0.0, 1.0), seg=24, rseg=8):
+ center = Vector(center)
+ x, y, z = basis(axis)
+ rings = []
+ for i in range(seg):
+ a = 2.0 * math.pi * i / seg
+ radial = x * math.cos(a) + y * math.sin(a)
+ c = center + radial * R
+ ring = []
+ for j in range(rseg):
+ b = 2.0 * math.pi * j / rseg
+ ring.append(bm.verts.new(c + radial * (r * math.cos(b)) + z * (r * math.sin(b))))
+ rings.append(ring)
+ for i in range(seg):
+ i2 = (i + 1) % seg
+ for j in range(rseg):
+ j2 = (j + 1) % rseg
+ bm.faces.new((rings[i][j], rings[i][j2], rings[i2][j2], rings[i2][j]))
+
+
+def dome(bm, base, direction, radius, seg=20, layers=5):
+ d = Vector(direction).normalized()
+ x, y, _ = basis(d)
+ prev = None
+ for i in range(layers + 1):
+ t = i / layers
+ phi = t * math.pi / 2.0
+ r = radius * math.cos(phi)
+ h = radius * math.sin(phi)
+ c = Vector(base) + d * h
+ if r < 1e-6:
+ tip = bm.verts.new(c)
+ if prev:
+ for j in range(seg):
+ bm.faces.new((prev[j], prev[(j + 1) % seg], tip))
+ break
+ ring = []
+ for j in range(seg):
+ a = 2.0 * math.pi * j / seg
+ ring.append(bm.verts.new(c + (x * math.cos(a) + y * math.sin(a)) * r))
+ if prev:
+ for j in range(seg):
+ j2 = (j + 1) % seg
+ bm.faces.new((prev[j], prev[j2], ring[j2], ring[j]))
+ prev = ring
+
+
+def bell(bm, throat, direction, length, r_throat, r_exit, seg=32, layers=14, exponent=1.7):
+ d = Vector(direction).normalized()
+ x, y, _ = basis(d)
+ prev = None
+ for i in range(layers + 1):
+ t = i / layers
+ r = r_throat + (r_exit - r_throat) * (t ** exponent)
+ c = Vector(throat) + d * (length * t)
+ ring = []
+ for j in range(seg):
+ a = 2.0 * math.pi * j / seg
+ ring.append(bm.verts.new(c + (x * math.cos(a) + y * math.sin(a)) * r))
+ if prev:
+ for j in range(seg):
+ j2 = (j + 1) % seg
+ bm.faces.new((prev[j], prev[j2], ring[j2], ring[j]))
+ prev = ring
+
+
+def box(bm, center, ex, ey, ez, ax, ay, az):
+ c = Vector(center)
+ ax, ay, az = Vector(ax), Vector(ay), Vector(az)
+ vs = {}
+ for sx in (-1, 1):
+ for sy in (-1, 1):
+ for sz in (-1, 1):
+ vs[(sx, sy, sz)] = bm.verts.new(c + ax * (sx * ex) + ay * (sy * ey) + az * (sz * ez))
+ quads = [
+ (vs[(-1, -1, -1)], vs[(-1, 1, -1)], vs[(-1, 1, 1)], vs[(-1, -1, 1)]),
+ (vs[(1, 1, -1)], vs[(1, -1, -1)], vs[(1, -1, 1)], vs[(1, 1, 1)]),
+ (vs[(-1, 1, -1)], vs[(1, 1, -1)], vs[(1, 1, 1)], vs[(-1, 1, 1)]),
+ (vs[(1, -1, -1)], vs[(-1, -1, -1)], vs[(-1, -1, 1)], vs[(1, -1, 1)]),
+ (vs[(-1, -1, 1)], vs[(-1, 1, 1)], vs[(1, 1, 1)], vs[(1, -1, 1)]),
+ (vs[(1, -1, -1)], vs[(1, 1, -1)], vs[(-1, 1, -1)], vs[(-1, -1, -1)]),
+ ]
+ for q in quads:
+ bm.faces.new(q)
+
+
+# ------------------------------------------------------------ structures
+
+
+def truss(bm, p1, p2, width, bays=6, chord_r=0.09, strut_r=0.05, angles=None):
+ p1, p2 = Vector(p1), Vector(p2)
+ if angles is None:
+ angles = [90.0, 210.0, 330.0]
+ x, y, _ = basis(p2 - p1)
+ nodes = []
+ for deg in angles:
+ a = math.radians(deg)
+ off = (x * math.cos(a) + y * math.sin(a)) * width
+ nodes.append((p1 + off, p2 + off))
+ for a, b in nodes:
+ cyl(bm, a, b, chord_r, seg=8)
+ n = len(nodes)
+ for i in range(bays + 1):
+ t = i / bays
+ pts = [a.lerp(b, t) for a, b in nodes]
+ for k in range(n):
+ cyl(bm, pts[k], pts[(k + 1) % n], strut_r, seg=6)
+ if i < bays:
+ t2 = (i + 1) / bays
+ pts2 = [a.lerp(b, t2) for a, b in nodes]
+ for k in range(n):
+ cyl(bm, pts[k], pts2[(k + 1) % n], strut_r, seg=6)
+
+
+def ring_frame(bm, center, radius, axis, tube=0.07, seg=28, spokes=0, spoke_r=0.05):
+ center = Vector(center)
+ torus(bm, center, radius, tube, axis=axis, seg=seg, rseg=6)
+ if spokes:
+ x, y, _ = basis(axis)
+ for k in range(spokes):
+ a = 2.0 * math.pi * k / spokes
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, center + radial * radius * 0.12, center + radial * radius, spoke_r, seg=6)
+
+
+def pressure_vessel(bm, p1, p2, radius, rings=5, seg=24, ports=True):
+ p1, p2 = Vector(p1), Vector(p2)
+ d = (p2 - p1).normalized()
+ cyl(bm, p1, p2, radius, seg=seg, cap1=False, cap2=False)
+ dome(bm, p1, -d, radius, seg=seg)
+ dome(bm, p2, d, radius, seg=seg)
+ for i in range(1, rings + 1):
+ t = i / (rings + 1)
+ torus(bm, p1.lerp(p2, t), radius, 0.06, axis=d, seg=seg, rseg=6)
+ if ports:
+ x, y, _ = basis(d)
+ for t in (0.2, 0.5, 0.8):
+ c = p1.lerp(p2, t)
+ off = x * radius
+ cyl(bm, c + off * 0.88, c + off * 1.45, 0.10, seg=8)
+ torus(bm, c + off * 1.45, 0.13, 0.04, axis=x, seg=10, rseg=5)
+
+
+def hab_module(bm, p1, p2, radius, ribs=8, windows=10, seg=28, hatch=True):
+ p1, p2 = Vector(p1), Vector(p2)
+ d = (p2 - p1).normalized()
+ x, y, _ = basis(d)
+ cyl(bm, p1, p2, radius, seg=seg)
+ for i in range(1, ribs + 1):
+ t = i / (ribs + 1)
+ torus(bm, p1.lerp(p2, t), radius, 0.075, axis=d, seg=seg, rseg=6)
+ length = (p2 - p1).length
+ for row, tt in ((0, 0.34), (1, 0.66)):
+ for k in range(windows):
+ a = 2.0 * math.pi * k / windows + row * math.pi / windows
+ radial = x * math.cos(a) + y * math.sin(a)
+ c = p1.lerp(p2, tt) + radial * radius
+ cyl(bm, c - radial * 0.08, c + radial * 0.14, 0.17, seg=12, cap1=False)
+ torus(bm, c + radial * 0.14, 0.185, 0.04, axis=radial, seg=14, rseg=5)
+ box(bm, c + radial * 0.16, 0.10, 0.30, 0.012, radial, d, d.cross(radial))
+ if hatch:
+ for end, dirv in ((p2, d), (p1, -d)):
+ torus(bm, end + dirv * 0.05, radius * 0.52, 0.11, axis=d, seg=seg, rseg=6)
+ for k in range(8):
+ a = 2.0 * math.pi * k / 8
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, end + radial * radius * 0.52 + dirv * 0.05,
+ end + radial * radius * 0.70 + dirv * 0.05, 0.05, seg=6)
+
+
+def centrifuge_ring(bm, center, radius, axis=(0.0, 1.0, 0.0), cars=6, seg=44):
+ """Rotating gravity ring: twin truss hoops, spokes, suspended cars."""
+ center = Vector(center)
+ x, y, z = basis(axis)
+ for off in (-0.55, 0.55):
+ c = center + z * off
+ torus(bm, c, radius, 0.085, axis=z, seg=seg, rseg=6)
+ torus(bm, c, radius * 0.995, 0.045, axis=z, seg=seg, rseg=5)
+ # cross bracing between the two hoops
+ for k in range(0, seg, 2):
+ a = 2.0 * math.pi * k / seg
+ a2 = 2.0 * math.pi * (k + 1) / seg
+ radial = x * math.cos(a) + y * math.sin(a)
+ radial2 = x * math.cos(a2) + y * math.sin(a2)
+ p_a0 = center + radial * radius + z * -0.55
+ p_a1 = center + radial * radius + z * 0.55
+ p_b0 = center + radial2 * radius + z * -0.55
+ p_b1 = center + radial2 * radius + z * 0.55
+ cyl(bm, p_a0, p_b1, 0.035, seg=5)
+ cyl(bm, p_a1, p_b0, 0.035, seg=5)
+ cyl(bm, p_a0, p_a1, 0.035, seg=5)
+ # spokes to hub
+ for k in range(cars):
+ a = 2.0 * math.pi * k / cars
+ radial = x * math.cos(a) + y * math.sin(a)
+ outer = center + radial * radius
+ cyl(bm, center, outer, 0.17, seg=10)
+ for tang in (-1.0, 1.0):
+ tangent = z * tang
+ p0 = center + radial * radius * 0.18 + tangent * 0.7
+ p1 = center + radial * radius * 0.55 + tangent * 0.7
+ cyl(bm, p0, p1, 0.07, seg=6)
+ cyl(bm, center + radial * radius * 0.5 - z * 0.9, center + radial * radius * 0.5 + z * 0.9, 0.09, seg=6)
+ # de-spin bearing rings at the hub
+ for zz in (-0.9, 0.9):
+ torus(bm, center + z * zz, radius * 0.135, 0.11, axis=z, seg=28, rseg=6)
+ # suspended cars
+ for k in range(cars):
+ a = 2.0 * math.pi * k / cars + math.pi / cars
+ radial = x * math.cos(a) + y * math.sin(a)
+ car = center + radial * (radius + 0.62)
+ cyl(bm, car - z * 0.85, car + z * 0.85, 0.62, seg=18)
+ torus(bm, car - z * 0.85, 0.64, 0.07, axis=z, seg=18, rseg=5)
+ torus(bm, car + z * 0.85, 0.64, 0.07, axis=z, seg=18, rseg=5)
+ cyl(bm, center + radial * radius, car, 0.075, seg=7)
+ for kk in range(5):
+ aa = 2.0 * math.pi * kk / 5
+ off = (z * math.cos(aa) + radial * math.sin(aa)) * 0.2
+ cyl(bm, car + off, car + off * 2.6, 0.03, seg=5)
+
+
+def gimbal(bm, pos, direction, radius=0.28, s=1.0):
+ pos = Vector(pos)
+ d = Vector(direction).normalized()
+ x, _, _ = basis(d)
+ torus(bm, pos, radius * s, 0.055 * s, axis=d, seg=16, rseg=5)
+ torus(bm, pos + d * 0.15 * s, radius * s * 0.9, 0.055 * s, axis=x, seg=16, rseg=5)
+ for k in (0, 1):
+ side = 1.0 if k == 0 else -1.0
+ cyl(bm, pos + x * side * radius * s * 1.35 - d * 0.12 * s,
+ pos + x * side * radius * s * 0.8 + d * 0.3 * s, 0.04 * s, seg=6)
+
+
+def regen_channels(bm, throat, direction, length, r_throat, r_exit, turns=6, tube=0.035, seg_pts=15):
+ throat = Vector(throat)
+ d = Vector(direction).normalized()
+ x, y, _ = basis(d)
+ steps = turns * seg_pts
+ pts = []
+ for i in range(steps + 1):
+ t = i / steps
+ r = r_throat + (r_exit - r_throat) * (t ** 1.7) + tube * 1.3
+ a = 2.0 * math.pi * turns * t
+ c = throat + d * (length * t)
+ pts.append(c + (x * math.cos(a) + y * math.sin(a)) * r)
+ for a, b in zip(pts, pts[1:]):
+ cyl(bm, a, b, tube, seg=5)
+
+
+def ntp_engine(bm, mount, direction, scale=1.0):
+ """Nuclear thermal engine: thrust structure, chamber, bell, turbopump."""
+ mount = Vector(mount)
+ d = Vector(direction).normalized()
+ s = scale
+ x, y, _ = basis(d)
+ cyl(bm, mount, mount + d * 0.5 * s, 0.44 * s, seg=14)
+ chamber = mount + d * 0.5 * s
+ cyl(bm, chamber, chamber + d * 0.45 * s, 0.36 * s, 0.31 * s, seg=18)
+ bell(bm, chamber + d * 0.45 * s, d, 2.3 * s, 0.31 * s, 1.05 * s, seg=32, layers=14)
+ for t in (0.3, 0.55, 0.78, 0.95):
+ c = chamber + d * (0.45 * s + 2.3 * s * t)
+ r = 0.31 * s + (1.05 * s - 0.31 * s) * (t ** 1.7)
+ torus(bm, c, r + 0.02 * s, 0.032 * s, axis=d, seg=24, rseg=5)
+ regen_channels(bm, chamber + d * 0.45 * s, d, 2.3 * s, 0.31 * s, 1.05 * s)
+ # turbopump cluster
+ pump = mount + d * 0.75 * s + x * 0.5 * s
+ cyl(bm, pump, pump + d * 0.6 * s, 0.21 * s, seg=12)
+ cyl(bm, pump - x * 0.22 * s, pump + x * 0.6 * s, 0.10 * s, seg=8)
+ cyl(bm, chamber + x * 0.32 * s, pump + d * 0.32 * s + x * 0.27 * s, 0.075 * s, seg=7)
+ cyl(bm, mount + d * 0.18 * s + x * 0.48 * s, chamber + x * 0.32 * s, 0.055 * s, seg=6)
+ # nozzle exit stiffener ring
+ exit_c = chamber + d * (0.45 * s + 2.3 * s)
+ torus(bm, exit_c - d * 0.05 * s, 1.05 * s, 0.05 * s, axis=d, seg=32, rseg=6)
+
+
+def shadow_shield(bm, center, direction, radius, seg=32, ribs=10):
+ center = Vector(center)
+ d = Vector(direction).normalized()
+ x, y, _ = basis(d)
+ cyl(bm, center - d * 0.12, center + d * 0.12, radius, seg=seg)
+ for k in range(ribs):
+ a = 2.0 * math.pi * k / ribs
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, center + radial * radius * 0.12 - d * 0.12,
+ center + radial * radius * 0.96 - d * 0.12, 0.05, seg=6)
+ torus(bm, center, radius, 0.06, axis=d, seg=seg, rseg=6)
+ for k in range(6):
+ a = 2.0 * math.pi * k / 6
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, center + radial * radius * 0.55, center + radial * radius * 0.55 - d * 1.2, 0.08, seg=7)
+
+
+def thrust_cone(bm, mount, direction, r_base, r_top, length, struts=10):
+ mount = Vector(mount)
+ d = Vector(direction).normalized()
+ x, y, _ = basis(d)
+ top = mount + d * length
+ for i, (c, r) in enumerate(((mount, r_base), (top, r_top))):
+ torus(bm, c, r, 0.06, axis=d, seg=28, rseg=5)
+ for k in range(struts):
+ a = 2.0 * math.pi * k / struts
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, mount + radial * r_base, top + radial * r_top, 0.055, seg=6)
+ for k in range(struts):
+ a = 2.0 * math.pi * k / struts
+ b = 2.0 * math.pi * (k + 1) / struts
+ ra = x * math.cos(a) + y * math.sin(a)
+ rb = x * math.cos(b) + y * math.sin(b)
+ mid = mount + d * (length * 0.5)
+ cyl(bm, mount + ra * r_base, mid + rb * ((r_base + r_top) * 0.5), 0.04, seg=5)
+
+
+def mli_bands(bm, p1, p2, radius, bands=10, seg=24, wobble=0.035):
+ p1, p2 = Vector(p1), Vector(p2)
+ d = (p2 - p1).normalized()
+ for i in range(1, bands + 1):
+ t = i / (bands + 1)
+ c = p1.lerp(p2, t)
+ w = 1.0 + wobble * math.sin(i * 2.3)
+ torus(bm, c, radius * w, 0.085, axis=d, seg=seg, rseg=7)
+
+
+def solar_array(bm, root, direction, span, width, cells=72, rows=14, face_normal=(0.0, 0.0, 1.0)):
+ """Deployable PV blanket: cell grid, frame rails, torque tube, hinge."""
+ root = Vector(root)
+ d = Vector(direction).normalized()
+ x, _y, _z = basis(d)
+ fn = Vector(face_normal).normalized()
+ if abs(fn.dot(d)) > 0.9:
+ fn = _z
+ x = d.cross(fn).normalized()
+ cw = span / cells
+ ch = width / rows
+ for i in range(cells):
+ for j in range(rows):
+ c = root + d * (cw * (i + 0.5)) + x * ((j - rows * 0.5 + 0.5) * ch)
+ box(bm, c, cw * 0.40, ch * 0.38, 0.02, d, x, fn)
+ for side in (-1.0, 1.0):
+ off = x * side * width * 0.5
+ box(bm, root + d * (span * 0.5) + off, span * 0.5, 0.05, 0.05, d, x, fn)
+ cyl(bm, root - x * width * 0.5, root + x * width * 0.5, 0.10, seg=10)
+ cyl(bm, root, root - d * 0.6, 0.13, seg=10)
+ torus(bm, root, 0.24, 0.06, axis=x, seg=16, rseg=5)
+
+
+def fin_radiator(bm, p1, p2, width, fins=48, fin_h=0.42, coolant=True):
+ p1, p2 = Vector(p1), Vector(p2)
+ d = (p2 - p1).normalized()
+ x, y, _ = basis(d)
+ cyl(bm, p1, p2, width * 0.5, seg=4)
+ for i in range(fins):
+ t = (i + 0.5) / fins
+ c = p1.lerp(p2, t)
+ for side in (0.5, -0.5):
+ off = y * side * 0.05
+ box(bm, c + off + y * side * fin_h * 0.5, width * 0.46, fin_h * 0.5, 0.014, x, y, d)
+ if coolant:
+ for side in (-0.30, 0.0, 0.30):
+ off = x * (width * side)
+ cyl(bm, p1 + off, p2 + off, 0.035, seg=6)
+
+
+def pipe_run(bm, points, r=0.06, seg=8):
+ for a, b in zip(points, points[1:]):
+ cyl(bm, a, b, r, seg=seg)
+
+
+def rcs_cluster(bm, pos, direction, s=1.0):
+ pos = Vector(pos)
+ d = Vector(direction).normalized()
+ x, y, _ = basis(d)
+ cyl(bm, pos, pos + d * 0.22 * s, 0.17 * s, seg=12)
+ for k in range(4):
+ a = 2.0 * math.pi * k / 4 + math.pi / 4
+ off = (x * math.cos(a) + y * math.sin(a)) * 0.14 * s
+ cyl(bm, pos + d * 0.22 * s + off, pos + d * 0.40 * s + off * 1.35, 0.055 * s, 0.032 * s, seg=6)
+
+
+def antenna_dish(bm, base, direction, radius, depth=0.4, ribs=8, s=1.0):
+ base = Vector(base)
+ d = Vector(direction).normalized()
+ x, y, _ = basis(d)
+ cyl(bm, base - d * 0.1 * s, base + d * 0.28 * s, 0.16 * s, seg=10)
+ gimbal(bm, base + d * 0.28 * s, d, radius=0.2 * s, s=s)
+ dish_center = base + d * 0.45 * s
+ layers = 5
+ prev = None
+ for i in range(layers + 1):
+ t = i / layers
+ r = radius * t
+ h = depth * (1.0 - t * t)
+ ring = []
+ for j in range(24):
+ a = 2.0 * math.pi * j / 24
+ ring.append(bm.verts.new(dish_center + (x * math.cos(a) + y * math.sin(a)) * r + d * h))
+ if prev:
+ for j in range(24):
+ j2 = (j + 1) % 24
+ bm.faces.new((prev[j], prev[j2], ring[j2], ring[j]))
+ prev = ring
+ for k in range(ribs):
+ a = 2.0 * math.pi * k / ribs
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, dish_center + radial * radius * 0.06, dish_center + radial * radius * 0.92 + d * 0.02, 0.03 * s, seg=5)
+ feed = dish_center + d * (depth + 1.15 * s * 0.6)
+ cyl(bm, dish_center + d * 0.06, feed, 0.035 * s, seg=6)
+ cyl(bm, feed - d * 0.06 * s, feed + d * 0.10 * s, 0.09 * s, seg=8)
+ for k in range(3):
+ a = 2.0 * math.pi * k / 3
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, dish_center + radial * radius * 0.8 + d * depth * 0.9, feed, 0.022 * s, seg=5)
+
+
+def whipple_panels(bm, p1, p2, radius, rows=6, cols=14, gap=0.04):
+ """MMOD shield: arc panels in a regular grid over a cylinder."""
+ p1, p2 = Vector(p1), Vector(p2)
+ d = (p2 - p1).normalized()
+ x, y, _ = basis(d)
+ length = (p2 - p1).length
+ for i in range(cols):
+ t = (i + 0.5) / cols
+ for j in range(rows):
+ a = 2.0 * math.pi * (j + 0.5) / rows
+ radial = x * math.cos(a) + y * math.sin(a)
+ tangent = d.cross(radial)
+ c = p1 + d * (length * t) + radial * (radius + 0.09)
+ box(bm, c, length / cols * 0.44, radius * math.pi / rows * 0.42, 0.035,
+ d, tangent, radial)
+
+
+def equipment_row(bm, start, direction, lateral, count, spacing, s_lo, s_hi, seed, lateral_axis=None):
+ """A deliberately arranged row of machinery boxes (not noise)."""
+ rng = random.Random(seed)
+ start = Vector(start)
+ direction = Vector(direction).normalized()
+ lateral = Vector(lateral).normalized()
+ ax = lateral_axis if lateral_axis is not None else direction.cross(lateral).normalized()
+ for i in range(count):
+ t = (i - (count - 1) * 0.5) * spacing
+ base = start + direction * t
+ w = rng.uniform(s_lo, s_hi)
+ h = rng.uniform(s_lo, s_hi)
+ dep = rng.uniform(s_lo, s_hi * 1.3)
+ box(bm, base + lateral * (dep * 0.5), w, h, dep * 0.5, direction, ax, lateral)
+ if rng.random() < 0.4:
+ cyl(bm, base + lateral * dep * 0.3 - ax * w * 0.6,
+ base + lateral * (dep * 0.5) - ax * w * 0.6, w * 0.18, seg=6)
+
+
+def panel_bank(bm, p1, p2, radius, cols=16, rows=8, depth=0.05):
+ """Regular rectangular panel grid over a cylinder (machined surface look)."""
+ p1, p2 = Vector(p1), Vector(p2)
+ d = (p2 - p1).normalized()
+ x, y, _ = basis(d)
+ length = (p2 - p1).length
+ for i in range(cols):
+ t = (i + 0.5) / cols
+ for j in range(rows):
+ a = 2.0 * math.pi * (j + 0.5) / rows
+ radial = x * math.cos(a) + y * math.sin(a)
+ tangent = d.cross(radial)
+ c = p1 + d * (length * t) + radial * radius
+ box(bm, c, length / cols * 0.42, radius * math.pi / rows * 0.40, depth,
+ d, tangent, radial)
+
+
+# --------------------------------------------------------------- assembly
-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 clear_scene(bpy):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
-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()
+def build_object(bpy, name, material, builder):
+ bm = bmesh.new()
+ builder(bm)
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])
+ mesh = bpy.data.meshes.new(name)
+ bm.to_mesh(mesh)
+ bm.free()
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
- assign_variants(obj, clay_material, livery_material)
- solidify = obj.modifiers.new("Reflector thickness", "SOLIDIFY")
- solidify.thickness = 0.012
- return obj
-
-
-
-
-def 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
+ obj.data.materials.append(material)
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).
-#
-# 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
+# Geometry constants shared across steps (units: 1 = 2 m).
+# Scale philosophy: few large elements, not many small ones. The hull core
+# is a continuous 16 m-diameter pressure body; everything else hangs off it.
+HULL_P1 = (0.0, -4.0, 0.0)
+HULL_P2 = (0.0, 42.0, 0.0)
+HULL_R = 4.0 # 16 m diameter
+NODE_C = (0.0, 44.6, 0.0)
+CENTRIFUGE_CENTER = (0.0, 6.0, 0.0)
+CENTRIFUGE_R = 14.0 # 56 m ring
+SPINE_P1 = (0.0, -34.0, 0.0)
+SPINE_P2 = (0.0, 20.0, 0.0)
+SPINE_R = 1.2
+HAB_A = ((5.6, 12.0, 0.0), (5.6, 30.0, 0.0), 2.6) # side-mounted lab module
+HAB_B = ((-5.6, 12.0, 0.0), (-5.6, 30.0, 0.0), 2.6) # side-mounted hab module
+TANK_X = 7.6
+TANK_R = 2.5 # 10 m diameter tanks
+REACTOR_C = (0.0, -30.5, 0.0)
+NOZZLE_MOUNT_Y = -40.5
+
+
+# --------------------------------------------------------------- builders
+
+
+def b_hull_core(bm):
+ """Continuous 16 m-diameter pressure hull — the ship's body."""
+ p1, p2 = Vector(HULL_P1), Vector(HULL_P2)
+ r = HULL_R
+ seg = 56
+ cyl(bm, p1, p2, r, seg=seg, cap1=False, cap2=False)
+ dome(bm, p1, (0.0, -1.0, 0.0), r, seg=seg)
+ dome(bm, p2, (0.0, 1.0, 0.0), r, seg=seg)
+ frames = 24
+ for i in range(1, frames):
+ t = i / frames
+ torus(bm, p1.lerp(p2, t), r, 0.115, axis=(0, 1, 0), seg=seg, rseg=7)
+ for k in range(8):
+ a = 2.0 * math.pi * k / 8 + math.pi / 8
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ cyl(bm, p1 + radial * r, p2 + radial * r, 0.10, seg=7)
+ for band_t, count in ((0.18, 22), (0.46, 26), (0.74, 22)):
+ c0 = p1.lerp(p2, band_t)
+ for k in range(count):
+ a = 2.0 * math.pi * k / count
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ wc = c0 + radial * r
+ cyl(bm, wc - radial * 0.12, wc + radial * 0.18, 0.22, seg=12, cap1=False)
+ torus(bm, wc + radial * 0.18, 0.245, 0.05, axis=radial, seg=14, rseg=5)
+ panel_bank(bm, p1 + Vector((0.0, 3.5, 0.0)), p2 - Vector((0.0, 3.5, 0.0)),
+ r + 0.035, cols=72, rows=18, depth=0.05)
+ for (t0, count, seed) in ((0.10, 26, 601), (0.60, 30, 602), (0.90, 22, 603)):
+ base = p1.lerp(p2, t0)
+ equipment_row(bm, base + Vector((0.0, 0.0, r + 0.12)), (0, 1, 0), (0, 0, 1), count, 1.35, 0.14, 0.34, seed=seed)
+ equipment_row(bm, base + Vector((0.0, 0.0, -r - 0.12)), (0, 1, 0), (0, 0, -1), count, 1.35, 0.14, 0.34, seed=seed + 50)
+
+
+def b_spine(bm):
+ """Outrigger trusses carrying tanks, arrays and radiators."""
+ for sx in (-1.0, 1.0):
+ x0 = sx * (HULL_R + 1.6)
+ truss(bm, (x0, -34.0, 0.0), (x0, 20.0, 0.0), 0.85, bays=36, chord_r=0.085, strut_r=0.05,
+ angles=[0.0, 120.0, 240.0] if sx > 0 else [180.0, 60.0, 300.0])
+ for st in (-30.0, -22.0, -14.0, -6.0, 2.0, 10.0):
+ cyl(bm, Vector((sx * HULL_R, st, 0.0)), Vector((x0, st, 0.0)), 0.10, seg=7)
+
+
+def b_node(bm):
+ c = Vector(NODE_C)
+ cyl(bm, c - Vector((0, 1.3, 0)), c + Vector((0, 1.3, 0)), 2.6, seg=24)
+ torus(bm, c - Vector((0, 1.3, 0)), 2.63, 0.09, axis=(0, 1, 0), seg=28, rseg=6)
+ torus(bm, c + Vector((0, 1.3, 0)), 2.63, 0.09, axis=(0, 1, 0), seg=28, rseg=6)
+ for dirv in ((0, 1, 0), (1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1)):
+ d = Vector(dirv)
+ x, y, _ = basis(d)
+ base = c + d * 1.25
+ cyl(bm, base, base + d * 0.65, 1.05, seg=18)
+ torus(bm, base + d * 0.65, 1.12, 0.085, axis=d, seg=20, rseg=6)
+ for k in range(10):
+ a = 2.0 * math.pi * k / 10
+ radial = x * math.cos(a) + y * math.sin(a)
+ cyl(bm, base + d * 0.65 + radial * 1.12, base + d * 0.80 + radial * 1.22, 0.055, seg=6)
+
+
+def b_hab_a(bm):
+ p1, p2, r = HAB_A
+ hab_module(bm, p1, p2, r, ribs=9, windows=12, seg=32)
+ panel_bank(bm, p1, p2, r + 0.02, cols=30, rows=18, depth=0.04)
+ equipment_row(bm, (6.6 + r + 0.12, 14.0, 0.0), (0, 1, 0), (1, 0, 0), 12, 1.25, 0.12, 0.28, seed=201)
+ equipment_row(bm, (6.6, 14.5, r + 0.12), (0, 1, 0), (0, 0, 1), 10, 1.4, 0.10, 0.26, seed=203)
+
+
+def b_hab_b(bm):
+ p1, p2, r = HAB_B
+ hab_module(bm, p1, p2, r, ribs=9, windows=12, seg=32)
+ panel_bank(bm, p1, p2, r + 0.02, cols=30, rows=18, depth=0.04)
+ equipment_row(bm, (-6.6 - r - 0.12, 15.0, 0.0), (0, 1, 0), (-1, 0, 0), 12, 1.25, 0.12, 0.28, seed=211)
+ equipment_row(bm, (-6.6, 15.5, r + 0.12), (0, 1, 0), (0, 0, 1), 10, 1.4, 0.10, 0.26, seed=213)
+
+
+def b_whipple(bm):
+ p1, p2 = Vector(HULL_P1), Vector(HULL_P2)
+ whipple_panels(bm, p1 + Vector((0, 3.0, 0)), p2 - Vector((0, 3.0, 0)), HULL_R, rows=14, cols=30)
+
+
+def b_waterwall(bm):
+ """Water tanks ringed around the forward hull as radiation shielding."""
+ for k in range(14):
+ a = 2.0 * math.pi * k / 14
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ p1 = Vector((0.0, 22.0, 0.0)) + radial * (HULL_R + 1.15)
+ p2 = Vector((0.0, 38.0, 0.0)) + radial * (HULL_R + 1.15)
+ pressure_vessel(bm, p1, p2, 1.15, rings=3, seg=16, ports=False)
+ mli_bands(bm, p1, p2, 1.18, bands=7, seg=18, wobble=0.02)
+ for t in (0.25, 0.75):
+ cco = p1.lerp(p2, t)
+ cyl(bm, cco - radial * 1.1, cco - radial * 0.4, 0.08, seg=6)
+
+
+def b_centrifuge(bm):
+ centrifuge_ring(bm, CENTRIFUGE_CENTER, CENTRIFUGE_R, axis=(0, 1, 0), cars=8, seg=72)
+ hub = Vector(CENTRIFUGE_CENTER)
+ cyl(bm, hub - Vector((0, 2.2, 0)), hub + Vector((0, 2.2, 0)), 1.9, seg=24)
+ torus(bm, hub + Vector((0, 2.2, 0)), 1.93, 0.09, axis=(0, 1, 0), seg=28, rseg=6)
+ torus(bm, hub - Vector((0, 2.2, 0)), 1.93, 0.09, axis=(0, 1, 0), seg=28, rseg=6)
+ for k in range(8):
+ a = 2.0 * math.pi * k / 8
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ cyl(bm, hub + radial * 1.9, hub + radial * 3.0, 0.11, seg=7)
+ for k in range(32):
+ a = 2.0 * math.pi * k / 32
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ c = Vector(CENTRIFUGE_CENTER) + radial * (CENTRIFUGE_R - 1.9)
+ cyl(bm, c - Vector((0, 1.1, 0)), c + Vector((0, 1.1, 0)), 0.62, seg=16)
+ torus(bm, c - Vector((0, 1.1, 0)), 0.64, 0.055, axis=(0, 1, 0), seg=16, rseg=5)
+ torus(bm, c + Vector((0, 1.1, 0)), 0.64, 0.055, axis=(0, 1, 0), seg=16, rseg=5)
+ for k in range(28):
+ a = 2.0 * math.pi * k / 28 + 0.22
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ c = Vector(CENTRIFUGE_CENTER) + radial * (CENTRIFUGE_R + 0.4)
+ box(bm, c, 0.38, 0.62, 0.38, radial, Vector((0, 1, 0)), Vector((-math.sin(a), 0.0, math.cos(a))))
+
+
+def b_prop_tank(bm):
+ for sx in (-1.0, 1.0):
+ for yc in (-14.0, -23.0):
+ x0 = sx * TANK_X
+ p1 = (x0, yc - 4.5, 0.0)
+ p2 = (x0, yc + 4.5, 0.0)
+ pressure_vessel(bm, p1, p2, TANK_R, rings=5, seg=30)
+ mli_bands(bm, p1, p2, TANK_R + 0.05, bands=13, seg=30, wobble=0.03)
+ panel_bank(bm, Vector(p1) + Vector((0, 1.4, 0)), Vector(p2) - Vector((0, 1.4, 0)),
+ TANK_R + 0.05, cols=22, rows=16, depth=0.035)
+ for st in (yc - 3.0, yc, yc + 3.0):
+ cyl(bm, Vector((sx * (HULL_R + 1.6), st, 0.0)), Vector((x0, st, 0.0)), 0.16, seg=8)
+ cyl(bm, Vector((sx * (HULL_R + 1.6), st - 0.5, 0.0)), Vector((x0, st, 0.6)), 0.07, seg=6)
+ torus(bm, Vector((x0, st, 0.0)), TANK_R + 0.07, 0.06, axis=(0, 1, 0), seg=24, rseg=5)
+
+
+def b_reactor(bm):
+ base = Vector(REACTOR_C)
+ cyl(bm, base - Vector((0, 1.8, 0)), base + Vector((0, 1.8, 0)), 2.1, seg=26)
+ for yy in (-1.8, 1.8):
+ torus(bm, base + Vector((0, yy, 0)), 2.13, 0.09, axis=(0, 1, 0), seg=28, rseg=6)
+ for k in range(14):
+ a = 2.0 * math.pi * k / 14
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ cyl(bm, base + radial * 2.0 - Vector((0, 1.5, 0)), base + radial * 2.0 + Vector((0, 1.5, 0)), 0.11, seg=6)
+ for k in range(8):
+ a = 2.0 * math.pi * k / 8
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ cyl(bm, base + radial * 2.1, base + radial * 3.1 - Vector((0, 3.4, 0)), 0.10, seg=7)
+
+
+def b_thrust_cone(bm):
+ thrust_cone(bm, (0.0, -33.5, 0.0), (0, -1, 0), 3.6, 2.9, 1.8, struts=14)
+ shadow_shield(bm, (0.0, -35.6, 0.0), (0, -1, 0), 4.4, seg=36, ribs=12)
+
+
+def b_nozzle_cluster(bm):
+ for ex in (-2.4, 2.4):
+ mount = Vector((ex, NOZZLE_MOUNT_Y, 0.0))
+ gimbal(bm, mount, (0, -1, 0), radius=0.55, s=2.0)
+ ntp_engine(bm, mount, (0, -1, 0), scale=2.0)
+
+
+def b_solar(bm):
+ for sx in (-1.0, 1.0):
+ for yy in (24.0, -18.0):
+ root = Vector((sx * (HULL_R + 2.4), yy, 0.0))
+ solar_array(bm, root, (sx, 0, 0), 30.0, 13.0, cells=84, rows=18)
+ d = Vector((sx, 0, 0))
+ x, _, _ = basis(d)
+ for k in range(16):
+ t = (k + 0.5) / 16
+ c = root + d * (30.0 * t)
+ cyl(bm, c - x * 6.5, c + x * 6.5, 0.05, seg=6)
+ for side in (-1.0, 1.0):
+ off = x * 6.5 * side
+ cyl(bm, root + off, root + d * 30.0 + off, 0.07, seg=6)
+ cyl(bm, root, root + Vector((sx * 2.4, 0, 0)), 0.20, seg=10)
+ for k in range(4):
+ a = 2.0 * math.pi * k / 4
+ cyl(bm, Vector((sx * (HULL_R + 0.4), yy, 0.0)),
+ root + Vector((sx * 1.2, 0, 0)) + x * 0.8 * math.cos(a), 0.06, seg=6)
+
+
+def b_radiator(bm):
+ for sx in (-1.0, 1.0):
+ for yy in (-32.0, -12.0, 32.0):
+ p1 = Vector((sx * (HULL_R + 3.4), yy - 6.0, 0.0))
+ p2 = Vector((sx * (HULL_R + 3.4), yy + 6.0, 0.0))
+ fin_radiator(bm, p1, p2, 3.4, fins=64, fin_h=0.55)
+ mid = p1.lerp(p2, 0.5)
+ cyl(bm, Vector((sx * (HULL_R + 0.6), mid.y, 0.0)), mid, 0.11, seg=8)
+ torus(bm, mid, 1.7, 0.06, axis=(1, 0, 0), seg=20, rseg=5)
+
+
+def b_airlock(bm):
+ p1 = Vector((0.0, 16.0, HULL_R - 0.5))
+ p2 = Vector((0.0, 16.0, HULL_R + 4.6))
+ cyl(bm, p1, p2, 1.6, seg=22)
+ for zz in (HULL_R - 0.5, HULL_R + 4.6):
+ torus(bm, Vector((0.0, 16.0, zz)), 1.63, 0.08, axis=(0, 0, 1), seg=24, rseg=6)
+ hatch = Vector((0.0, 16.0, HULL_R + 4.6 + 0.4))
+ cyl(bm, hatch - Vector((0, 0, 0.4)), hatch + Vector((0, 0, 0.25)), 1.0, seg=18)
+ torus(bm, hatch + Vector((0, 0, 0.25)), 1.06, 0.075, axis=(0, 0, 1), seg=20, rseg=6)
+ for k in range(8):
+ a = 2.0 * math.pi * k / 8
+ off = Vector((math.cos(a), math.sin(a), 0.0)) * 1.06
+ cyl(bm, hatch + off + Vector((0, 0, 0.25)), hatch + off + Vector((0, 0, 0.42)), 0.055, seg=6)
+ equipment_row(bm, (1.9, 16.0, HULL_R + 2.4), (0, 0, 1), (1, 0, 0), 8, 0.5, 0.10, 0.22, seed=401)
+
+
+def b_orion(bm):
+ """Orion-class shuttle docked forward — the human-scale reference."""
+ base = Vector((0.0, 47.6, 0.0))
+ cyl(bm, base, base + Vector((0, 5.4, 0)), 1.5, seg=20)
+ for t in (0.3, 0.62):
+ torus(bm, base + Vector((0, 5.4 * t, 0)), 1.53, 0.065, axis=(0, 1, 0), seg=22, rseg=5)
+ cyl(bm, base + Vector((0, 5.4, 0)), base + Vector((0, 7.8, 0)), 1.5, 0.55, seg=20)
+ torus(bm, base + Vector((0, 5.4, 0)), 1.53, 0.08, axis=(0, 1, 0), seg=22, rseg=6)
+ cyl(bm, base + Vector((0, 7.8, 0)), base + Vector((0, 8.5, 0)), 0.55, seg=14)
+ torus(bm, base + Vector((0, 8.5, 0)), 0.65, 0.07, axis=(0, 1, 0), seg=16, rseg=6)
+ for k in range(8):
+ a = 2.0 * math.pi * k / 8
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ c = base + Vector((0, 6.9, 0)) + radial * 1.06
+ cyl(bm, c - radial * 0.05, c + radial * 0.11, 0.13, seg=10, cap1=False)
+ for k in range(4):
+ a = 2.0 * math.pi * k / 4 + math.pi / 4
+ radial = Vector((math.cos(a), 0.0, math.sin(a)))
+ root = base + Vector((0, 5.4, 0)) + radial * 1.5
+ solar_array(bm, root, radial, 4.6, 2.0, cells=22, rows=8)
+ bell(bm, base - Vector((0, 0.6, 0)), (0, -1, 0), 1.5, 0.22, 0.68, seg=22, layers=9)
+
+
+def b_greeble(bm):
+ """Organised machinery, cable looms, nav lights, hatch plates."""
+ p1, p2 = Vector(HULL_P1), Vector(HULL_P2)
+ r = HULL_R
+ for rx, (yy, ang) in ((0, (28.0, 0.9)), (0, (36.0, 2.5)), (0, (10.0, 4.2))):
+ radial = Vector((math.cos(ang), 0.0, math.sin(ang)))
+ base = Vector((0.0, yy, 0.0)) + radial * r
+ cyl(bm, base - radial * 0.05, base + radial * 0.13, 0.55, seg=14)
+ torus(bm, base + radial * 0.13, 0.60, 0.055, axis=radial, seg=16, rseg=5)
+ for sx in (-1.0, 1.0):
+ x0 = sx * (HULL_R + 1.6)
+ for dz in (0.95, -0.95):
+ pipe_run(bm, [(x0, -34.0, dz), (x0, 20.0, dz)], 0.055)
+ for (pos, dirv, rad) in (((-4.0, 40.0, 0.0), (-1, 0, 0), 0.20),
+ ((4.0, 40.0, 0.0), (1, 0, 0), 0.20),
+ ((0.0, 40.0, 4.0), (0, 0, 1), 0.20),
+ ((-4.0, -2.0, 0.0), (-1, 0, 0), 0.16),
+ ((4.0, -2.0, 0.0), (1, 0, 0), 0.16)):
+ d = Vector(dirv)
+ cyl(bm, Vector(pos), Vector(pos) + d * 0.15, rad, seg=10)
+ cyl(bm, Vector(pos) + d * 0.15, Vector(pos) + d * 0.34, rad * 0.7, seg=10)
+ equipment_row(bm, (0.0, 34.0, r + 0.15), (0, 1, 0), (0, 0, 1), 12, 1.0, 0.10, 0.24, seed=501)
+ equipment_row(bm, (0.0, 6.0, -r - 0.15), (0, 1, 0), (0, 0, -1), 14, 1.0, 0.10, 0.24, seed=502)
+
+
+def b_antenna(bm):
+ antenna_dish(bm, (0.0, 30.0, HULL_R + 1.4), (0.0, 0.3, 1.0), 2.6, depth=0.7, ribs=12)
+ antenna_dish(bm, (HULL_R + 1.2, 38.0, 0.0), (1.0, 0.25, 0.1), 1.4, depth=0.4, ribs=9)
+ for (bx, by, bz, ll) in ((0.0, 42.5, 3.0, 2.2), (0.0, 8.0, -3.0, 2.0), (3.0, 41.0, 0.0, 1.6)):
+ cyl(bm, Vector((bx, by, bz)), Vector((bx, by + 0.2, bz + ll)), 0.055, seg=6)
+ torus(bm, Vector((bx, by + 0.2, bz + ll)), 0.4, 0.045, axis=(0, 0, 1), seg=16, rseg=5)
+
+
+BUILDERS = {
+ "hnx02.hull.core": b_hull_core,
+ "hnx02.spine.truss": b_spine,
+ "hnx02.node.forward": b_node,
+ "hnx02.hab.a": b_hab_a,
+ "hnx02.hab.b": b_hab_b,
+ "hnx02.hab.whipple": b_whipple,
+ "hnx02.rad.waterwall": b_waterwall,
+ "hnx02.centrifuge.ring": b_centrifuge,
+ "hnx02.prop.tank": b_prop_tank,
+ "hnx02.reactor.core": b_reactor,
+ "hnx02.thrust.cone": b_thrust_cone,
+ "hnx02.nozzle.cluster": b_nozzle_cluster,
+ "hnx02.power.solar": b_solar,
+ "hnx02.power.radiator": b_radiator,
+ "hnx02.cabin.airlock": b_airlock,
+ "hnx02.shuttle.orion": b_orion,
+ "hnx02.detail.greeble": b_greeble,
+ "hnx02.detail.antenna": b_antenna,
+}
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 = {}
+ 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)
-
- # 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:
- fail("source object custom property verification failed", 4)
- return ordered
+ material = materials[OBJECT_MATERIAL[object_id]]
+ objects.append(build_object(bpy, object_id, material, BUILDERS[object_id]))
+ return objects
-def add_render_setup(bpy):
- from mathutils import Vector
+# ------------------------------------------------------------ render / io
+
+def add_render_setup(bpy):
scene = bpy.context.scene
- scene.render.resolution_x = 512
- scene.render.resolution_y = 512
+ scene.render.resolution_x = 1280
+ scene.render.resolution_y = 860
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)
+ scene.world = bpy.data.worlds.new("hnx02.world")
+ scene.world.color = (0.02, 0.03, 0.04)
- camera_data = bpy.data.cameras.new("ship.camera")
+ camera_data = bpy.data.cameras.new("hnx02.camera")
camera_data.type = "ORTHO"
- camera_data.ortho_scale = 13.5
- camera = bpy.data.objects.new("ship.camera", camera_data)
+ camera_data.ortho_scale = 118.0
+ camera = bpy.data.objects.new("hnx02.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()
+ camera.location = (52.0, -66.0, 40.0)
+ camera.rotation_euler = (Vector((0.0, 2.0, 0.0)) - camera.location).to_track_quat("-Z", "Y").to_euler()
scene.camera = camera
def add_light(name, location, energy, size, color=(1.0, 1.0, 1.0)):
@@ -703,12 +1057,12 @@ def add_render_setup(bpy):
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()
+ light.rotation_euler = (Vector((0.0, 2.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))
+ add_light("hnx02.key", (34.0, -30.0, 40.0), 16000, 26.0, (1.0, 0.97, 0.92))
+ add_light("hnx02.fill", (-30.0, -42.0, 10.0), 5200, 30.0, (0.68, 0.79, 1.0))
+ add_light("hnx02.rim", (-36.0, 26.0, -16.0), 9000, 16.0, (1.0, 0.85, 0.58))
def export_glb(bpy, objects, output):
@@ -717,7 +1071,17 @@ def export_glb(bpy, objects, output):
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)
+ result = bpy.ops.export_scene.gltf(
+ filepath=output,
+ export_format="GLB",
+ use_selection=True,
+ export_apply=True,
+ export_draco_mesh_compression_enable=True,
+ export_draco_mesh_compression_level=6,
+ export_draco_position_quantization=14,
+ export_draco_normal_quantization=10,
+ export_draco_texcoord_quantization=12,
+ )
if "FINISHED" not in result or not os.path.isfile(output) or os.path.getsize(output) <= 0:
fail("GLB export failed", 4)
@@ -738,11 +1102,9 @@ def verify_reimport(bpy, output, expected):
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]
@@ -754,61 +1116,40 @@ def main():
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)
+ step_id = args.step_id
+ if step_id not in STEP_OBJECTS:
+ fail("unknown step id: " + step_id, 3)
+
+ clear_scene(bpy)
+ materials = build_materials(bpy)
+ objects = build_geometry(bpy, materials, step_id)
+
+ render_path = None
+ if args.render_output:
+ add_render_setup(bpy)
+ scene = bpy.context.scene
+ scene.render.filepath = args.render_output
+ bpy.ops.render.render(write_still=True)
+ if os.path.isfile(args.render_output):
+ render_path = os.path.abspath(args.render_output)
+
+ exported_at = utc_now()
+ export_glb(bpy, objects, args.output)
+ reimported = verify_reimport(bpy, args.output, STEP_OBJECTS[step_id])
+
+ response = {
+ "ok": True,
+ "blenderVersion": ".".join(str(part) for part in bpy.app.version),
+ "exportedAt": exported_at,
+ "objectIds": STEP_OBJECTS[step_id],
+ "verifiedObjectIds": reimported,
+ "glbBytes": os.path.getsize(args.output),
+ }
+ if render_path is not None:
+ response["renderPath"] = render_path
+ response["renderedAt"] = utc_now()
+ write_result(args.result_output, response)
+ print("hnx02 result written: " + args.result_output)
if __name__ == "__main__":