"""YJ-01 Voyager-class current-tech probe dataset for the artifact pipeline. Geometry ported from the legacy reference model: /home/somhairle/Projects/interstellar-dual-track/current-tech-probe/scripts/build_probe.py (line ranges cited per stage below). Object ids are mapped from the legacy UPPER_SNAKE names onto lowercase dotted ids, e.g.: BUS_ELECTRONICS_DECAGON -> probe.bus.electronics.decagon BUS_BAY_PANEL_00 -> probe.bus.bay.panel.00 RTG_01_FIN_0 -> probe.rtg.01.fin.00 HGA_FEED_STRUT_3 -> probe.hga.strut.03 MAGNETOMETER_SENSOR_A -> probe.mag.sensor.a Display plinth, information card and MODEL_LABEL are intentionally excluded. 工程限制: 非飞行设计 heritage 参考. Contract (identical to fixture.py): --step-id --output --result-output [--render-output ] """ import argparse import json import math import os import sys from datetime import datetime, timezone import bpy # noqa: F401 (must be imported before mathutils: registers the bundled mathutils module) from mathutils import Vector # Cumulative object id lists per step. Must stay in lockstep with # Design.probeSteps in src/SomhairlesDream.Modeling/Definitions.fs. STEP_OBJECTS = { "probe-01-bus": [ "probe.bus.electronics.decagon", "probe.bus.ring.front", "probe.bus.ring.rear", "probe.bus.bay.panel.00", "probe.bus.bay.panel.01", "probe.bus.bay.panel.02", "probe.bus.bay.panel.03", "probe.bus.bay.panel.04", "probe.bus.bay.panel.05", "probe.bus.bay.panel.06", "probe.bus.bay.panel.07", "probe.bus.bay.panel.08", "probe.bus.bay.panel.09", "probe.bus.mli.top", "probe.bus.mli.bottom", "probe.bus.radiator.port", "probe.bus.radiator.starboard", "probe.aacs", "probe.thruster.01", "probe.thruster.02", "probe.thruster.03", "probe.thruster.04", ], "probe-02-boom": [ "probe.bus.electronics.decagon", "probe.bus.ring.front", "probe.bus.ring.rear", "probe.bus.bay.panel.00", "probe.bus.bay.panel.01", "probe.bus.bay.panel.02", "probe.bus.bay.panel.03", "probe.bus.bay.panel.04", "probe.bus.bay.panel.05", "probe.bus.bay.panel.06", "probe.bus.bay.panel.07", "probe.bus.bay.panel.08", "probe.bus.bay.panel.09", "probe.bus.mli.top", "probe.bus.mli.bottom", "probe.bus.radiator.port", "probe.bus.radiator.starboard", "probe.aacs", "probe.thruster.01", "probe.thruster.02", "probe.thruster.03", "probe.thruster.04", "probe.rtg.boom", "probe.mag.boom", "probe.mag.head", "probe.mag.sensor.a", "probe.mag.sensor.b", "probe.pws.antenna.left", "probe.pws.antenna.right", "probe.pws.tip.left", "probe.pws.tip.right", ], "probe-03-rtg": [ "probe.bus.electronics.decagon", "probe.bus.ring.front", "probe.bus.ring.rear", "probe.bus.bay.panel.00", "probe.bus.bay.panel.01", "probe.bus.bay.panel.02", "probe.bus.bay.panel.03", "probe.bus.bay.panel.04", "probe.bus.bay.panel.05", "probe.bus.bay.panel.06", "probe.bus.bay.panel.07", "probe.bus.bay.panel.08", "probe.bus.bay.panel.09", "probe.bus.mli.top", "probe.bus.mli.bottom", "probe.bus.radiator.port", "probe.bus.radiator.starboard", "probe.aacs", "probe.thruster.01", "probe.thruster.02", "probe.thruster.03", "probe.thruster.04", "probe.rtg.boom", "probe.mag.boom", "probe.mag.head", "probe.mag.sensor.a", "probe.mag.sensor.b", "probe.pws.antenna.left", "probe.pws.antenna.right", "probe.pws.tip.left", "probe.pws.tip.right", "probe.rtg.01", "probe.rtg.01.fin.00", "probe.rtg.01.fin.01", "probe.rtg.01.fin.02", "probe.rtg.02", "probe.rtg.02.fin.00", "probe.rtg.02.fin.01", "probe.rtg.02.fin.02", "probe.rtg.03", "probe.rtg.03.fin.00", "probe.rtg.03.fin.01", "probe.rtg.03.fin.02", ], "probe-04-hga": [ "probe.bus.electronics.decagon", "probe.bus.ring.front", "probe.bus.ring.rear", "probe.bus.bay.panel.00", "probe.bus.bay.panel.01", "probe.bus.bay.panel.02", "probe.bus.bay.panel.03", "probe.bus.bay.panel.04", "probe.bus.bay.panel.05", "probe.bus.bay.panel.06", "probe.bus.bay.panel.07", "probe.bus.bay.panel.08", "probe.bus.bay.panel.09", "probe.bus.mli.top", "probe.bus.mli.bottom", "probe.bus.radiator.port", "probe.bus.radiator.starboard", "probe.aacs", "probe.thruster.01", "probe.thruster.02", "probe.thruster.03", "probe.thruster.04", "probe.rtg.boom", "probe.mag.boom", "probe.mag.head", "probe.mag.sensor.a", "probe.mag.sensor.b", "probe.pws.antenna.left", "probe.pws.antenna.right", "probe.pws.tip.left", "probe.pws.tip.right", "probe.rtg.01", "probe.rtg.01.fin.00", "probe.rtg.01.fin.01", "probe.rtg.01.fin.02", "probe.rtg.02", "probe.rtg.02.fin.00", "probe.rtg.02.fin.01", "probe.rtg.02.fin.02", "probe.rtg.03", "probe.rtg.03.fin.00", "probe.rtg.03.fin.01", "probe.rtg.03.fin.02", "probe.hga.reflector", "probe.hga.feed", "probe.hga.strut.00", "probe.hga.strut.01", "probe.hga.strut.02", "probe.hga.strut.03", ], "probe-05-instruments": [ "probe.bus.electronics.decagon", "probe.bus.ring.front", "probe.bus.ring.rear", "probe.bus.bay.panel.00", "probe.bus.bay.panel.01", "probe.bus.bay.panel.02", "probe.bus.bay.panel.03", "probe.bus.bay.panel.04", "probe.bus.bay.panel.05", "probe.bus.bay.panel.06", "probe.bus.bay.panel.07", "probe.bus.bay.panel.08", "probe.bus.bay.panel.09", "probe.bus.mli.top", "probe.bus.mli.bottom", "probe.bus.radiator.port", "probe.bus.radiator.starboard", "probe.aacs", "probe.thruster.01", "probe.thruster.02", "probe.thruster.03", "probe.thruster.04", "probe.rtg.boom", "probe.mag.boom", "probe.mag.head", "probe.mag.sensor.a", "probe.mag.sensor.b", "probe.pws.antenna.left", "probe.pws.antenna.right", "probe.pws.tip.left", "probe.pws.tip.right", "probe.rtg.01", "probe.rtg.01.fin.00", "probe.rtg.01.fin.01", "probe.rtg.01.fin.02", "probe.rtg.02", "probe.rtg.02.fin.00", "probe.rtg.02.fin.01", "probe.rtg.02.fin.02", "probe.rtg.03", "probe.rtg.03.fin.00", "probe.rtg.03.fin.01", "probe.rtg.03.fin.02", "probe.hga.reflector", "probe.hga.feed", "probe.hga.strut.00", "probe.hga.strut.01", "probe.hga.strut.02", "probe.hga.strut.03", "probe.scan.pivot", "probe.scan.platform", "probe.crs", "probe.plasma", "probe.optical", "probe.optical.aperture", ], "probe-06-mast": [ "probe.bus.electronics.decagon", "probe.bus.ring.front", "probe.bus.ring.rear", "probe.bus.bay.panel.00", "probe.bus.bay.panel.01", "probe.bus.bay.panel.02", "probe.bus.bay.panel.03", "probe.bus.bay.panel.04", "probe.bus.bay.panel.05", "probe.bus.bay.panel.06", "probe.bus.bay.panel.07", "probe.bus.bay.panel.08", "probe.bus.bay.panel.09", "probe.bus.mli.top", "probe.bus.mli.bottom", "probe.bus.radiator.port", "probe.bus.radiator.starboard", "probe.aacs", "probe.thruster.01", "probe.thruster.02", "probe.thruster.03", "probe.thruster.04", "probe.rtg.boom", "probe.mag.boom", "probe.mag.head", "probe.mag.sensor.a", "probe.mag.sensor.b", "probe.pws.antenna.left", "probe.pws.antenna.right", "probe.pws.tip.left", "probe.pws.tip.right", "probe.rtg.01", "probe.rtg.01.fin.00", "probe.rtg.01.fin.01", "probe.rtg.01.fin.02", "probe.rtg.02", "probe.rtg.02.fin.00", "probe.rtg.02.fin.01", "probe.rtg.02.fin.02", "probe.rtg.03", "probe.rtg.03.fin.00", "probe.rtg.03.fin.01", "probe.rtg.03.fin.02", "probe.hga.reflector", "probe.hga.feed", "probe.hga.strut.00", "probe.hga.strut.01", "probe.hga.strut.02", "probe.hga.strut.03", "probe.scan.pivot", "probe.scan.platform", "probe.crs", "probe.plasma", "probe.optical", "probe.optical.aperture", "probe.science.boom", ], "probe-07-livery": [ "probe.bus.electronics.decagon", "probe.bus.ring.front", "probe.bus.ring.rear", "probe.bus.bay.panel.00", "probe.bus.bay.panel.01", "probe.bus.bay.panel.02", "probe.bus.bay.panel.03", "probe.bus.bay.panel.04", "probe.bus.bay.panel.05", "probe.bus.bay.panel.06", "probe.bus.bay.panel.07", "probe.bus.bay.panel.08", "probe.bus.bay.panel.09", "probe.bus.mli.top", "probe.bus.mli.bottom", "probe.bus.radiator.port", "probe.bus.radiator.starboard", "probe.aacs", "probe.thruster.01", "probe.thruster.02", "probe.thruster.03", "probe.thruster.04", "probe.rtg.boom", "probe.mag.boom", "probe.mag.head", "probe.mag.sensor.a", "probe.mag.sensor.b", "probe.pws.antenna.left", "probe.pws.antenna.right", "probe.pws.tip.left", "probe.pws.tip.right", "probe.rtg.01", "probe.rtg.01.fin.00", "probe.rtg.01.fin.01", "probe.rtg.01.fin.02", "probe.rtg.02", "probe.rtg.02.fin.00", "probe.rtg.02.fin.01", "probe.rtg.02.fin.02", "probe.rtg.03", "probe.rtg.03.fin.00", "probe.rtg.03.fin.01", "probe.rtg.03.fin.02", "probe.hga.reflector", "probe.hga.feed", "probe.hga.strut.00", "probe.hga.strut.01", "probe.hga.strut.02", "probe.hga.strut.03", "probe.scan.pivot", "probe.scan.platform", "probe.crs", "probe.plasma", "probe.optical", "probe.optical.aperture", "probe.science.boom", ], } LIVERY_STEPS = {"probe-07-livery"} def utc_now(): return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def fail(message, code): print(message, file=sys.stderr) raise SystemExit(code) def rgba(rgb, alpha=1.0): return (*rgb, alpha) def make_material(bpy, name, color, *, metallic=0.0, roughness=0.45, emission=None, emission_strength=0.0): material = bpy.data.materials.get(name) or bpy.data.materials.new(name) material.use_nodes = True material.diffuse_color = rgba(color) shader = material.node_tree.nodes.get("Principled BSDF") shader.inputs["Base Color"].default_value = rgba(color) shader.inputs["Metallic"].default_value = metallic shader.inputs["Roughness"].default_value = roughness if emission is not None: emission_input = shader.inputs.get("Emission Color") or shader.inputs.get("Emission") if emission_input: emission_input.default_value = rgba(emission) strength_input = shader.inputs.get("Emission Strength") if strength_input: strength_input.default_value = emission_strength return material def build_materials(bpy): return { "clay_body": make_material(bpy, "CLAY BODY", (0.38, 0.42, 0.46), metallic=0.20, roughness=0.38), "clay_structure": make_material(bpy, "CLAY STRUCTURE", (0.17, 0.21, 0.25), metallic=0.65, roughness=0.32), "clay_dark": make_material(bpy, "CLAY DARK", (0.045, 0.055, 0.065), metallic=0.45, roughness=0.28), "clay_mli": make_material(bpy, "CLAY MLI", (0.54, 0.56, 0.52), metallic=0.08, roughness=0.50), "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_body": make_material(bpy, "LIVERY BODY BLUE", (0.045, 0.16, 0.22), metallic=0.35, 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_mli": make_material(bpy, "LIVERY MLI GOLD", (0.58, 0.34, 0.10), metallic=0.30, roughness=0.35), "livery_white": make_material(bpy, "LIVERY DISH WHITE", (0.78, 0.81, 0.79), metallic=0.15, roughness=0.28), "livery_red": make_material(bpy, "LIVERY RESTRAINED RED", (0.52, 0.025, 0.018), metallic=0.22, 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), } def clear_scene(bpy): bpy.ops.wm.read_factory_settings(use_empty=True) 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_uv_sphere(bpy, name, location, radius, clay_material, livery_material): bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=16, radius=radius, location=location) 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)): bpy.ops.mesh.primitive_torus_add( major_radius=major_radius, minor_radius=minor_radius, major_segments=48, minor_segments=10, location=location, rotation=rotation, ) obj = bpy.context.object obj.name = name obj.data.name = name + ".mesh" return assign_variants(obj, clay_material, livery_material) def add_between(bpy, name, start, end, radius, clay_material, livery_material, vertices=20): start_vec = Vector(start) end_vec = Vector(end) delta = end_vec - start_vec obj = add_cylinder( bpy, name, (start_vec + end_vec) / 2.0, radius, delta.length, clay_material, livery_material, vertices=vertices, ) obj.rotation_euler = delta.to_track_quat("Z", "Y").to_euler() return obj def add_dish(bpy, name, base_x, center_y, center_z, radius, depth, clay_material, livery_material): """Create a shallow open paraboloid whose boresight points along +X.""" segments = 64 rings = 9 vertices = [(base_x, center_y, center_z)] for ring in range(1, rings + 1): fraction = ring / rings ring_radius = radius * fraction ring_x = base_x + depth * fraction * fraction for segment in range(segments): angle = 2.0 * math.pi * segment / segments vertices.append( ( ring_x, center_y + ring_radius * math.cos(angle), center_z + ring_radius * math.sin(angle), ) ) faces = [] for segment in range(segments): faces.append((0, 1 + segment, 1 + (segment + 1) % segments)) for ring in range(1, rings): first = 1 + (ring - 1) * segments next_first = first + segments for segment in range(segments): current = first + segment following = first + (segment + 1) % segments next_current = next_first + segment next_following = next_first + (segment + 1) % segments faces.append((current, next_current, next_following, following)) mesh = bpy.data.meshes.new(name + ".mesh") mesh.from_pydata(vertices, [], faces) mesh.update() obj = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(obj) assign_variants(obj, clay_material, livery_material) solidify = obj.modifiers.new("Reflector thickness", "SOLIDIFY") solidify.thickness = 0.012 return obj 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 merge_details(bpy, master, extras): """Join detail meshes into the master object so the exported GLB keeps exactly the contract object names while carrying real secondary detail.""" if not extras: return master for obj in bpy.context.selected_objects: obj.select_set(False) for extra in extras: extra.select_set(True) master.select_set(True) bpy.context.view_layer.objects.active = master bpy.ops.object.join() return master def detail_torus(bpy, name, location, major_radius, minor_radius, rotation=(0.0, 0.0, 0.0), minor_segments=10): bpy.ops.mesh.primitive_torus_add( major_radius=major_radius, minor_radius=minor_radius, major_segments=48, minor_segments=minor_segments, location=location, rotation=rotation, ) obj = bpy.context.object obj.name = name return obj def detail_cube(bpy, name, location, dimensions, rotation=(0.0, 0.0, 0.0)): bpy.ops.mesh.primitive_cube_add(location=location, rotation=rotation) obj = bpy.context.object obj.name = name obj.dimensions = dimensions bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) return obj def detail_cylinder(bpy, name, location, radius, depth, rotation=(0.0, 0.0, 0.0), vertices=20): bpy.ops.mesh.primitive_cylinder_add( vertices=vertices, radius=radius, depth=depth, location=location, rotation=rotation, ) obj = bpy.context.object obj.name = name return obj def build_details(bpy, object_id, master, materials): """Secondary engineering detail per contract object, joined into the master mesh before export; detail names never reach the GLB.""" details = [] if object_id == "probe.bus.electronics.decagon": # Panel seams around the decagon bus + mounting bosses on both faces. for index in range(10): angle = 2.0 * math.pi * index / 10.0 y = 0.455 * math.cos(angle) z = 0.455 * math.sin(angle) details.append(detail_cube(bpy, f"{master.name}.detail.seam.{index:02d}", (0.0, y, z), (0.50, 0.03, 0.03), rotation=(angle, 0.0, 0.0))) for offset in (-0.24, 0.24): details.append(detail_cylinder(bpy, f"{master.name}.detail.boss.{offset:+.2f}", (offset, 0.0, 0.0), 0.30, 0.03, rotation=(0.0, math.pi / 2.0, 0.0))) elif object_id.startswith("probe.bus.bay.panel."): index = int(object_id.rsplit(".", 1)[-1]) angle = 2.0 * math.pi * index / 10.0 y = 0.46 * math.cos(angle) z = 0.46 * math.sin(angle) # Fastener posts and a hinge rib per bay panel. details.append(detail_cylinder(bpy, master.name + ".detail.hinge", (0.10, y, z), 0.018, 0.30, rotation=(angle, 0.0, 0.0), vertices=8)) details.append(detail_cube(bpy, master.name + ".detail.latch", (-0.14, y * 1.02, z * 1.02), (0.05, 0.04, 0.10), rotation=(angle, 0.0, 0.0))) elif object_id.startswith("probe.thruster."): index = int(object_id.rsplit(".", 1)[-1]) y, z = ((-0.30, -0.25), (-0.30, 0.25), (0.30, -0.25), (0.30, 0.25))[index - 1] # Thruster mount plate + feed line to the bus core. details.append(detail_cube(bpy, master.name + ".detail.mount", (-0.24, y, z), (0.10, 0.12, 0.12))) details.append(detail_cylinder(bpy, master.name + ".detail.feed", (-0.24, y * 0.55, z * 0.55), 0.014, 0.5, vertices=8)) elif object_id.startswith("probe.rtg.") and object_id.endswith(tuple(f".fin.{i:02d}" for i in range(3))): rtg_part, fin_part = object_id.rsplit(".fin.", 1) rtg_index = int(rtg_part.rsplit(".", 1)[-1]) fin_index = int(fin_part) y = (-0.80, -1.22, -1.64)[rtg_index - 1] - 0.09 + fin_index * 0.09 details.append(detail_torus(bpy, master.name + ".detail.collar", (-0.12, y, -0.16), 0.118, 0.010, rotation=(math.pi / 2.0, 0.0, 0.0), minor_segments=10)) elif object_id == "probe.rtg.boom": # Boom truss cross-bracing between the three RTG housings. for index in range(3): y = -0.98 - index * 0.42 details.append(detail_torus(bpy, f"{master.name}.detail.brace.{index:02d}", (-0.12, y, -0.16), 0.035, 0.008, minor_segments=10)) elif object_id.startswith("probe.rtg."): index = int(object_id.rsplit(".", 1)[-1]) y = (-0.80, -1.22, -1.64)[index - 1] # Radiator fins and end caps along each RTG housing. for offset in (-0.09, 0.0, 0.09): details.append(detail_torus(bpy, f"{master.name}.detail.fins.{offset:+.2f}", (-0.12, y + offset, -0.16), 0.115, 0.008, rotation=(math.pi / 2.0, 0.0, 0.0), minor_segments=10)) elif object_id == "probe.mag.head": details.append(detail_torus(bpy, master.name + ".detail.collar", (0.0, 6.50, 0.18), 0.105, 0.014, rotation=(math.pi / 2.0, 0.0, 0.0), minor_segments=10)) elif object_id == "probe.hga.reflector": # Back-side ribs across the dish so the reflector reads as a rigid # structure, not a floating surface. for index in range(6): angle = 2.0 * math.pi * index / 6.0 y = 0.55 * math.cos(angle) z = 0.55 * math.sin(angle) details.append(detail_cube(bpy, f"{master.name}.detail.rib.{index:02d}", (0.22, y * 0.55, z * 0.55), (0.06, 0.04, 0.04))) details.append(detail_cylinder(bpy, master.name + ".detail.hub", (0.30, 0.0, 0.0), 0.10, 0.06, rotation=(0.0, math.pi / 2.0, 0.0))) elif object_id == "probe.scan.platform": for offset in (-0.12, 0.0, 0.12): details.append(detail_cylinder(bpy, f"{master.name}.detail.bolt.{offset:+.2f}", (0.0, offset, 1.67), 0.014, 0.02, vertices=8)) details.append(detail_torus(bpy, master.name + ".detail.ring", (0.0, 0.0, 1.665), 0.16, 0.012, minor_segments=10)) elif object_id.startswith("probe.pws.tip."): side = object_id.rsplit(".", 1)[-1] sign = -1.0 if side == "left" else 1.0 details.append(detail_cylinder(bpy, master.name + ".detail.cap", (-0.12, sign * 2.50, 1.10), 0.020, 0.03, rotation=(math.pi / 2.0, 0.0, 0.0), vertices=8)) elif object_id == "probe.science.boom": for index in range(4): z = 0.60 + index * 0.30 details.append(detail_torus(bpy, f"{master.name}.detail.guide.{index:02d}", (0.0, 0.0, z), 0.042, 0.008, minor_segments=10)) return details def build_geometry(bpy, materials, step_id): """Build every object of the cumulative step, in STEP_OBJECTS order. Stage sources (legacy build_probe.py): probe-01-bus :364-390, :401-404 probe-02-boom :406-410, :418-433 probe-03-rtg :411-416 probe-04-hga :392-399 probe-05-instruments :437-442 probe-06-mast :436, :443-444 probe-07-livery :450-473 (livery material mode over the full model) """ clay_body = materials["clay_body"] clay_structure = materials["clay_structure"] clay_dark = materials["clay_dark"] clay_mli = materials["clay_mli"] clay_white = materials["clay_white"] clay_glass = materials["clay_glass"] livery_body = materials["livery_body"] livery_structure = materials["livery_structure"] livery_dark = materials["livery_dark"] livery_mli = materials["livery_mli"] livery_white = materials["livery_white"] livery_red = materials["livery_red"] livery_glass = materials["livery_glass"] objects = {} bay_panel_livery = {} for index in range(10): bay_panel_livery[f"probe.bus.bay.panel.{index:02d}"] = livery_red if index in {1, 6} else livery_structure for object_id in STEP_OBJECTS[step_id]: if object_id == "probe.bus.electronics.decagon": objects[object_id] = add_cylinder(bpy, object_id, (0.0, 0.0, 0.0), 0.45, 0.48, clay_body, livery_body, rotation=(0.0, math.pi / 2.0, 0.0), vertices=10) elif object_id == "probe.bus.ring.front": objects[object_id] = add_torus(bpy, object_id, (0.25, 0.0, 0.0), 0.41, 0.025, clay_structure, livery_structure, rotation=(0.0, math.pi / 2.0, 0.0)) elif object_id == "probe.bus.ring.rear": objects[object_id] = add_torus(bpy, object_id, (-0.25, 0.0, 0.0), 0.41, 0.025, clay_structure, livery_structure, rotation=(0.0, math.pi / 2.0, 0.0)) elif object_id.startswith("probe.bus.bay.panel."): index = int(object_id.rsplit(".", 1)[-1]) angle = 2.0 * math.pi * index / 10.0 y = 0.46 * math.cos(angle) z = 0.46 * math.sin(angle) objects[object_id] = add_beveled_cube(bpy, object_id, (-0.01, y, z), (0.36, 0.055, 0.18), clay_structure, bay_panel_livery[object_id], bevel=0.018, rotation=(angle, 0.0, 0.0)) elif object_id == "probe.bus.mli.top": objects[object_id] = add_beveled_cube(bpy, object_id, (-0.02, 0.0, 0.465), (0.34, 0.56, 0.055), clay_mli, livery_mli, bevel=0.012) elif object_id == "probe.bus.mli.bottom": objects[object_id] = add_beveled_cube(bpy, object_id, (-0.02, 0.0, -0.465), (0.34, 0.56, 0.055), clay_mli, livery_mli, bevel=0.012) elif object_id == "probe.bus.radiator.port": objects[object_id] = add_beveled_cube(bpy, object_id, (-0.05, -0.47, 0.0), (0.28, 0.045, 0.34), clay_dark, livery_dark, bevel=0.012) elif object_id == "probe.bus.radiator.starboard": objects[object_id] = add_beveled_cube(bpy, object_id, (-0.05, 0.47, 0.0), (0.28, 0.045, 0.34), clay_dark, livery_dark, bevel=0.012) elif object_id == "probe.aacs": objects[object_id] = add_beveled_cube(bpy, object_id, (-0.28, 0.0, 0.18), (0.08, 0.22, 0.18), clay_dark, livery_dark, bevel=0.018) elif object_id.startswith("probe.thruster."): index = int(object_id.rsplit(".", 1)[-1]) y, z = ((-0.30, -0.25), (-0.30, 0.25), (0.30, -0.25), (0.30, 0.25))[index - 1] objects[object_id] = add_cone(bpy, object_id, (-0.28, y, z), 0.045, 0.018, 0.10, clay_dark, livery_dark, rotation=(0.0, math.pi / 2.0, 0.0)) elif object_id == "probe.rtg.boom": objects[object_id] = add_between(bpy, object_id, (-0.12, -0.42, -0.16), (-0.12, -1.85, -0.16), 0.028, clay_structure, livery_structure) elif object_id == "probe.mag.boom": objects[object_id] = add_between(bpy, object_id, (0.0, 0.18, 0.18), (0.0, 6.50, 0.18), 0.022, clay_structure, livery_structure) elif object_id == "probe.mag.head": objects[object_id] = add_cylinder(bpy, object_id, (0.0, 6.56, 0.18), 0.10, 0.20, clay_dark, livery_dark, rotation=(math.pi / 2.0, 0.0, 0.0)) elif object_id == "probe.mag.sensor.a": objects[object_id] = add_uv_sphere(bpy, object_id, (0.0, 6.68, 0.25), 0.045, clay_glass, livery_glass) elif object_id == "probe.mag.sensor.b": objects[object_id] = add_uv_sphere(bpy, object_id, (0.0, 6.68, 0.11), 0.045, clay_glass, livery_glass) elif object_id == "probe.pws.antenna.left": objects[object_id] = add_between(bpy, object_id, (-0.12, 0.0, 0.28), (-0.12, -2.50, 1.10), 0.012, clay_structure, livery_structure, vertices=12) elif object_id == "probe.pws.antenna.right": objects[object_id] = add_between(bpy, object_id, (-0.12, 0.0, 0.28), (-0.12, 2.50, 1.10), 0.012, clay_structure, livery_structure, vertices=12) elif object_id == "probe.pws.tip.left": objects[object_id] = add_uv_sphere(bpy, object_id, (-0.12, -2.50, 1.10), 0.035, clay_structure, livery_red) elif object_id == "probe.pws.tip.right": objects[object_id] = add_uv_sphere(bpy, object_id, (-0.12, 2.50, 1.10), 0.035, clay_structure, livery_red) elif object_id.startswith("probe.rtg.") and object_id.endswith(tuple(f".fin.{i:02d}" for i in range(3))): rtg_part, fin_part = object_id.rsplit(".fin.", 1) rtg_index = int(rtg_part.rsplit(".", 1)[-1]) fin_index = int(fin_part) y = (-0.80, -1.22, -1.64)[rtg_index - 1] - 0.09 + fin_index * 0.09 objects[object_id] = add_torus(bpy, object_id, (-0.12, y, -0.16), 0.112, 0.015, clay_structure, livery_structure, rotation=(math.pi / 2.0, 0.0, 0.0)) elif object_id.startswith("probe.rtg."): index = int(object_id.rsplit(".", 1)[-1]) y = (-0.80, -1.22, -1.64)[index - 1] objects[object_id] = add_cylinder(bpy, object_id, (-0.12, y, -0.16), 0.105, 0.27, clay_dark, livery_dark, rotation=(math.pi / 2.0, 0.0, 0.0)) elif object_id == "probe.hga.reflector": objects[object_id] = add_dish(bpy, object_id, 0.30, 0.0, 0.0, 0.925, 0.14, clay_white, livery_white) elif object_id == "probe.hga.feed": objects[object_id] = add_cylinder(bpy, object_id, (0.95, 0.0, 0.0), 0.075, 0.12, clay_glass, livery_glass, rotation=(0.0, math.pi / 2.0, 0.0)) elif object_id.startswith("probe.hga.strut."): index = int(object_id.rsplit(".", 1)[-1]) point = ((0.41, -0.60, -0.60), (0.41, -0.60, 0.60), (0.41, 0.60, -0.60), (0.41, 0.60, 0.60))[index] objects[object_id] = add_between(bpy, object_id, point, (0.95, 0.0, 0.0), 0.018, clay_structure, livery_structure, vertices=12) elif object_id == "probe.scan.pivot": objects[object_id] = add_cylinder(bpy, object_id, (0.0, 0.0, 1.53), 0.11, 0.12, clay_dark, livery_dark) elif object_id == "probe.scan.platform": objects[object_id] = add_beveled_cube(bpy, object_id, (0.0, 0.0, 1.60), (0.32, 0.48, 0.12), clay_body, livery_body, bevel=0.025) elif object_id == "probe.crs": objects[object_id] = add_beveled_cube(bpy, object_id, (0.15, 0.0, 1.76), (0.18, 0.18, 0.15), clay_dark, livery_dark, bevel=0.02) elif object_id == "probe.plasma": objects[object_id] = add_beveled_cube(bpy, object_id, (-0.15, 0.0, 1.76), (0.18, 0.18, 0.15), clay_dark, livery_dark, bevel=0.02) elif object_id == "probe.optical": objects[object_id] = add_beveled_cube(bpy, object_id, (0.0, -0.16, 1.76), (0.20, 0.12, 0.15), clay_body, livery_body, bevel=0.018) elif object_id == "probe.optical.aperture": objects[object_id] = add_cylinder(bpy, object_id, (0.11, -0.16, 1.76), 0.045, 0.035, clay_glass, livery_glass, rotation=(0.0, math.pi / 2.0, 0.0)) elif object_id == "probe.science.boom": objects[object_id] = add_between(bpy, object_id, (0.0, 0.0, 0.34), (0.0, 0.0, 1.50), 0.035, clay_structure, livery_structure) 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 def add_render_setup(bpy): from mathutils import Vector scene = bpy.context.scene scene.render.resolution_x = 512 scene.render.resolution_y = 512 scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = "PNG" if scene.world is None: scene.world = bpy.data.worlds.new("probe.world") scene.world.color = (0.025, 0.035, 0.045) camera_data = bpy.data.cameras.new("probe.camera") camera_data.type = "ORTHO" camera_data.ortho_scale = 15.0 camera = bpy.data.objects.new("probe.camera", camera_data) bpy.context.collection.objects.link(camera) camera.location = (8.2, -12.5, 6.3) camera.rotation_euler = (Vector((0.0, 2.0, 0.2)) - 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)): light_data = bpy.data.lights.new(name, type="AREA") light_data.energy = energy light_data.shape = "DISK" light_data.size = size light_data.color = color light = bpy.data.objects.new(name, light_data) bpy.context.collection.objects.link(light) light.location = location light.rotation_euler = (Vector((0.0, 2.0, 0.2)) - Vector(location)).to_track_quat("-Z", "Y").to_euler() return light add_light("probe.key", (4.0, -4.0, 8.0), 900, 5.0, (1.0, 0.98, 0.94)) add_light("probe.fill", (-6.0, -8.0, 3.0), 300, 8.0, (0.72, 0.82, 1.0)) add_light("probe.rim", (-5.0, 7.0, -2.5), 650, 3.0, (1.0, 0.86, 0.62)) def export_glb(bpy, objects, output): for obj in bpy.context.selected_objects: obj.select_set(False) for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[-1] result = bpy.ops.export_scene.gltf(filepath=output, export_format="GLB", use_selection=True, export_apply=True) if "FINISHED" not in result or not os.path.isfile(output) or os.path.getsize(output) <= 0: fail("GLB export failed", 4) def write_result(path, response): path = os.path.abspath(path) os.makedirs(os.path.dirname(path), exist_ok=True) temporary_path = path + ".tmp" with open(temporary_path, "w", encoding="utf-8") as stream: json.dump(response, stream, separators=(",", ":")) stream.flush() os.fsync(stream.fileno()) os.replace(temporary_path, path) def verify_reimport(bpy, output, expected): clear_scene(bpy) result = bpy.ops.import_scene.gltf(filepath=output) if "FINISHED" not in result: fail("GLB reimport failed", 4) names = [obj.name for obj in bpy.context.scene.objects if obj.type == "MESH"] if len(names) != len(expected) or set(names) != set(expected): fail("GLB reimport object IDs did not match", 4) return [object_id for object_id in expected if object_id in names] def main(): parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--step-id", required=True) parser.add_argument("--output", required=True) parser.add_argument("--render-output") parser.add_argument("--result-output", required=True) args = parser.parse_args() if args.step_id not in STEP_OBJECTS: fail("unknown step id", 2) if bool(args.render_output) and os.path.abspath(args.render_output) == os.path.abspath(args.output): fail("render output must differ from GLB output", 2) try: import bpy except Exception as exc: fail("bpy is unavailable: " + str(exc), 3) if tuple(bpy.app.version) < (3, 0, 0): fail("bpy version is unsupported", 3) output = os.path.abspath(args.output) result_output = os.path.abspath(args.result_output) os.makedirs(os.path.dirname(output), exist_ok=True) try: clear_scene(bpy) materials = build_materials(bpy) objects = build_geometry(bpy, materials, args.step_id) apply_material_mode(bpy, materials, "livery" if args.step_id in LIVERY_STEPS else "clay") export_glb(bpy, objects, output) exported_at = utc_now() glb_bytes = os.path.getsize(output) rendered_at = None if args.render_output: render_output = os.path.abspath(args.render_output) os.makedirs(os.path.dirname(render_output), exist_ok=True) add_render_setup(bpy) bpy.context.scene.render.filepath = render_output result = bpy.ops.render.render(write_still=True) if "FINISHED" not in result or not os.path.isfile(render_output) or os.path.getsize(render_output) <= 0: fail("PNG render failed", 4) rendered_at = utc_now() verified = verify_reimport(bpy, output, STEP_OBJECTS[args.step_id]) response = { "ok": True, "blenderVersion": ".".join(str(part) for part in bpy.app.version), "exportedAt": exported_at, "objectIds": STEP_OBJECTS[args.step_id], "verifiedObjectIds": verified, "glbBytes": glb_bytes, } if args.render_output: response["renderPath"] = os.path.abspath(args.render_output) response["renderedAt"] = rendered_at write_result(result_output, response) print("probe result written: " + result_output) except SystemExit: raise except Exception as exc: fail("probe failed: " + str(exc), 4) if __name__ == "__main__": main()