import argparse import json import os import sys from datetime import datetime, timezone STEP_OBJECTS = { "01-foundation": ["heritage.foundation", "heritage.deck"], "02-frame": [ "heritage.foundation", "heritage.deck", "heritage.frame.left", "heritage.frame.right", "heritage.spine", ], "03-cabin": [ "heritage.foundation", "heritage.deck", "heritage.frame.left", "heritage.frame.right", "heritage.spine", "heritage.cabin", "heritage.crossbeam", ], } OBJECT_GEOMETRY = { "heritage.foundation": ((0.0, 0.0, 0.0), (4.8, 0.35, 2.8)), "heritage.deck": ((0.0, 0.35, 0.0), (4.2, 0.22, 2.3)), "heritage.frame.left": ((-1.65, 1.35, 0.0), (0.24, 2.0, 2.0)), "heritage.frame.right": ((1.65, 1.35, 0.0), (0.24, 2.0, 2.0)), "heritage.spine": ((0.0, 1.45, 0.0), (0.24, 2.2, 1.8)), "heritage.cabin": ((0.0, 2.15, 0.0), (2.8, 1.1, 1.8)), "heritage.crossbeam": ((0.0, 2.95, 0.0), (3.3, 0.22, 2.1)), } 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 clear_scene(bpy): bpy.ops.wm.read_factory_settings(use_empty=True) def create_objects(bpy, step_id): objects = [] for object_id in STEP_OBJECTS[step_id]: location, dimensions = OBJECT_GEOMETRY[object_id] bpy.ops.mesh.primitive_cube_add(size=1.0, location=location) obj = bpy.context.object obj.name = object_id obj.data.name = object_id + ".mesh" obj.dimensions = dimensions bpy.context.view_layer.objects.active = obj bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) obj["object_id"] = object_id objects.append(obj) for obj, object_id in zip(objects, STEP_OBJECTS[step_id]): if obj.get("object_id") != object_id: fail("source object custom property verification failed", 4) return objects 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("fixture.world") scene.world.color = (0.025, 0.035, 0.045) camera_data = bpy.data.cameras.new("fixture.camera") camera = bpy.data.objects.new("fixture.camera", camera_data) bpy.context.collection.objects.link(camera) camera.location = (7.5, -9.0, 6.5) camera.rotation_euler = (Vector((0.0, 1.0, 0.0)) - camera.location).to_track_quat("-Z", "Y").to_euler() camera_data.lens = 52 scene.camera = camera light_data = bpy.data.lights.new("fixture.key", type="AREA") light_data.energy = 900 light_data.shape = "DISK" light_data.size = 5.0 light = bpy.data.objects.new("fixture.key", light_data) bpy.context.collection.objects.link(light) light.location = (4.0, -4.0, 8.0) light.rotation_euler = (Vector((0.0, 1.0, 0.0)) - light.location).to_track_quat("-Z", "Y").to_euler() def export_glb(bpy, objects, output): for obj in bpy.context.selected_objects: obj.select_set(False) for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[-1] result = bpy.ops.export_scene.gltf(filepath=output, export_format="GLB", use_selection=True) 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) objects = create_objects(bpy, args.step_id) 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("fixture result written: " + result_output) except SystemExit: raise except Exception as exc: fail("fixture failed: " + str(exc), 4) if __name__ == "__main__": main()