diff options
Diffstat (limited to 'stage/blender/ship.py')
| -rw-r--r-- | stage/blender/ship.py | 1705 |
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__": |
