summaryrefslogtreecommitdiff
path: root/stage
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
commit7b29dbff7e43e87741476f47df17ad1582b16546 (patch)
tree4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9 /stage
parent47add3088d50f12c5f48591f26c53cdb59d8283b (diff)
downloadsomhairles-dream-fsharp-7b29dbff7e43e87741476f47df17ad1582b16546.tar.gz
feat(pipeline): add runnable artifact pipeline increment
[Change Nature] - This commit adds the first runnable increment of the F# artifact pipeline: CLI, server, frontend bundle, stage fixture, and tests. It is feature work, not a bug fix. [New Capability] - CLI `run` executes the three-step Blender fixture through the process bridge and writes GLBs, step logs, events, and a manifest; `verify` validates a manifest without Blender. - Server exposes run start, run-scoped SSE, artifact manifest/file routes, and serves the Fable frontend from its build output. - Frontend starts runs, follows run-scoped SSE progress, and links the artifact manifest on completion. [Implementation] - Shared layer holds domain, run state, replay, and artifact contracts; Modeling holds the pipeline, 120s bridge timeout, and result-file protocol; stage/blender/fixture.py owns geometry in Python (bpy owns geometry, F# owns orchestration). - Fable bundle is regenerated into public/ and copied by the server project; solution includes all projects with warnings-as-errors. - README documents build, Fable, server, interpreter/env, fixture limitations, and current verification status. [Impact] - Verified: 40/40 solution tests pass; Release build 0 warnings/errors; real CLI run with bpy 5.0.1 produced 3 GLBs, 3 logs, and a verify-clean manifest; isolated-port server smoke flow succeeded. - Not yet verified: browser end-to-end behavior and production fidelity. Artifacts and caches are gitignored.
Diffstat (limited to 'stage')
-rw-r--r--stage/blender/fixture.py202
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()