diff options
Diffstat (limited to 'stage')
| -rw-r--r-- | stage/blender/fixture.py | 202 |
1 files changed, 202 insertions, 0 deletions
diff --git a/stage/blender/fixture.py b/stage/blender/fixture.py new file mode 100644 index 0000000..fe5a232 --- /dev/null +++ b/stage/blender/fixture.py @@ -0,0 +1,202 @@ +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() |
