|
| 1 | +"""Slotted-actions turntable -- a runnable BDT example. |
| 2 | +
|
| 3 | +Keyframes a Z-rotation turntable through the slotted-actions cross-version channelbag path |
| 4 | +(`get_channelbag_for_slot`) and selects the engine with the version-branch EEVEE-id helper. |
| 5 | +It witnesses the slotted-actions fix: on Blender 5.x the channelbag comes from |
| 6 | +`action_ensure_channelbag_for_slot`; on 4.4/4.5 from `strip.channelbag(slot, ensure=True)`. |
| 7 | +
|
| 8 | +By default it runs only the cheap, frame-independent correctness check (no render): insert |
| 9 | +the rotation keys, sample the object's Z rotation at frame 1 vs a later frame, and assert |
| 10 | +they DIFFER -- proving the keys drive playback. Exits non-zero on failure. This is the check |
| 11 | +the CI smoke gate runs on both builds. |
| 12 | +
|
| 13 | + blender --background --python turntable.py -- # correctness check only |
| 14 | + blender --background --python turntable.py -- --output t.png # also render one still |
| 15 | + blender --background --python turntable.py -- --output t.png --engine cycles # GPU-less |
| 16 | +""" |
| 17 | +import bpy, sys, os, math, argparse |
| 18 | + |
| 19 | +FRAMES = 36 |
| 20 | + |
| 21 | +def get_eevee_engine_id(): |
| 22 | + return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' |
| 23 | + |
| 24 | +def get_channelbag_for_slot(action, slot): |
| 25 | + if bpy.app.version >= (5, 0, 0): |
| 26 | + from bpy_extras.anim_utils import action_ensure_channelbag_for_slot |
| 27 | + return action_ensure_channelbag_for_slot(action, slot) |
| 28 | + layer = action.layers[0] if action.layers else action.layers.new("Layer") |
| 29 | + strip = layer.strips[0] if layer.strips else layer.strips.new(type='KEYFRAME') |
| 30 | + return strip.channelbag(slot, ensure=True) |
| 31 | + |
| 32 | +def build(): |
| 33 | + bpy.ops.wm.read_factory_settings(use_empty=True) |
| 34 | + bpy.ops.mesh.primitive_monkey_add(location=(0, 0, 1.0)) |
| 35 | + obj = bpy.context.active_object |
| 36 | + for p in obj.data.polygons: |
| 37 | + p.use_smooth = True |
| 38 | + mat = bpy.data.materials.new("M"); mat.use_nodes = True |
| 39 | + b = mat.node_tree.nodes.get('Principled BSDF') |
| 40 | + b.inputs['Base Color'].default_value = (0.85, 0.35, 0.10, 1) |
| 41 | + b.inputs['Metallic'].default_value = 0.7 |
| 42 | + b.inputs['Roughness'].default_value = 0.25 |
| 43 | + obj.data.materials.append(mat) |
| 44 | + # rotation keyframes via the slotted-actions channelbag path |
| 45 | + obj.animation_data_create() |
| 46 | + act = bpy.data.actions.new("Turn"); obj.animation_data.action = act |
| 47 | + slot = obj.animation_data.action_slot |
| 48 | + if slot is None: |
| 49 | + slot = act.slots.new(id_type='OBJECT', name=obj.name); obj.animation_data.action_slot = slot |
| 50 | + cbag = get_channelbag_for_slot(act, slot) |
| 51 | + fc = cbag.fcurves.new("rotation_euler", index=2) |
| 52 | + fc.keyframe_points.insert(1, 0.0) |
| 53 | + fc.keyframe_points.insert(FRAMES, math.radians(360)) |
| 54 | + for kp in fc.keyframe_points: |
| 55 | + kp.interpolation = 'LINEAR' |
| 56 | + fc.update() |
| 57 | + return obj |
| 58 | + |
| 59 | +def correctness(obj): |
| 60 | + sc = bpy.context.scene; sc.frame_start = 1; sc.frame_end = FRAMES |
| 61 | + def rz(f): |
| 62 | + sc.frame_set(f); dg = bpy.context.evaluated_depsgraph_get() |
| 63 | + return round(obj.evaluated_get(dg).rotation_euler.z, 4) |
| 64 | + r1, rmid, rend = rz(1), rz(FRAMES // 2), rz(FRAMES) |
| 65 | + branch = '5.0+ ensure-helper' if bpy.app.version >= (5, 0, 0) else '4.4/4.5 strip.channelbag' |
| 66 | + drives = (r1 != rmid != rend) and abs(rend - r1) > 0.5 |
| 67 | + print(f"branch={branch} rot_z f1={r1} fmid={rmid} fend={rend} drives={drives}") |
| 68 | + return drives |
| 69 | + |
| 70 | +def render_still(obj, path, engine): |
| 71 | + import bmesh |
| 72 | + sc = bpy.context.scene |
| 73 | + fme = bpy.data.meshes.new("Floor"); bm = bmesh.new() |
| 74 | + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0); bm.to_mesh(fme); bm.free() |
| 75 | + floor = bpy.data.objects.new("Floor", fme); bpy.context.collection.objects.link(floor) |
| 76 | + w = bpy.data.worlds.new("W"); w.use_nodes = True |
| 77 | + w.node_tree.nodes["Background"].inputs[0].default_value = (0.04, 0.05, 0.07, 1); sc.world = w |
| 78 | + aim = bpy.data.objects.new("Aim", None); aim.location = (0, 0, 1.0); bpy.context.collection.objects.link(aim) |
| 79 | + cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")); cam.location = (0, -7, 3.0) |
| 80 | + bpy.context.collection.objects.link(cam); sc.camera = cam |
| 81 | + c = cam.constraints.new('TRACK_TO'); c.target = aim; c.track_axis = 'TRACK_NEGATIVE_Z'; c.up_axis = 'UP_Y' |
| 82 | + for nm, loc, en in [("K", (-4, -5, 7), 900), ("F2", (5, -4, 2), 350)]: |
| 83 | + ld = bpy.data.lights.new(nm, 'AREA'); ld.energy = en; ld.size = 5.0 |
| 84 | + lo = bpy.data.objects.new(nm, ld); lo.location = loc; bpy.context.collection.objects.link(lo) |
| 85 | + lc = lo.constraints.new('TRACK_TO'); lc.target = aim; lc.track_axis = 'TRACK_NEGATIVE_Z'; lc.up_axis = 'UP_Y' |
| 86 | + sc.render.engine = 'CYCLES' if engine == 'cycles' else get_eevee_engine_id() |
| 87 | + if sc.render.engine == 'CYCLES': |
| 88 | + try: sc.cycles.samples = 16 |
| 89 | + except Exception: pass |
| 90 | + else: |
| 91 | + try: sc.eevee.taa_render_samples = 16 |
| 92 | + except Exception: pass |
| 93 | + sc.frame_set(FRAMES // 4) |
| 94 | + sc.render.resolution_x = 1280; sc.render.resolution_y = 720 |
| 95 | + sc.render.image_settings.file_format = 'PNG'; sc.render.filepath = path |
| 96 | + bpy.ops.render.render(write_still=True) |
| 97 | + return os.path.exists(path) and os.path.getsize(path) > 0 |
| 98 | + |
| 99 | +def main(): |
| 100 | + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] |
| 101 | + p = argparse.ArgumentParser() |
| 102 | + p.add_argument("--output", default=None, help="optional: render one still to this PNG") |
| 103 | + p.add_argument("--engine", choices=["auto", "cycles"], default="auto") |
| 104 | + args = p.parse_args(argv) |
| 105 | + |
| 106 | + # the EEVEE-id mapping is asserted regardless of whether we render |
| 107 | + eid = get_eevee_engine_id() |
| 108 | + expected = 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT' |
| 109 | + bpy.context.scene.render.engine = eid |
| 110 | + if bpy.context.scene.render.engine != expected: |
| 111 | + print(f"ERROR: EEVEE id {eid} != expected {expected}", file=sys.stderr); return 5 |
| 112 | + |
| 113 | + obj = build() |
| 114 | + if not correctness(obj): |
| 115 | + print("ERROR: rotation keys do not drive playback", file=sys.stderr); return 3 |
| 116 | + |
| 117 | + if args.output: |
| 118 | + if not render_still(obj, args.output, args.engine): |
| 119 | + print("ERROR: still render produced no file", file=sys.stderr); return 4 |
| 120 | + print(f"rendered still {args.output} ({os.path.getsize(args.output)} bytes)") |
| 121 | + print("turntable OK") |
| 122 | + return 0 |
| 123 | + |
| 124 | +if __name__ == "__main__": |
| 125 | + try: |
| 126 | + sys.exit(main()) |
| 127 | + except Exception as exc: |
| 128 | + import traceback; traceback.print_exc() |
| 129 | + print(f"FATAL: {type(exc).__name__}: {exc}", file=sys.stderr); sys.exit(1) |
0 commit comments