#!/usr/bin/env python3 import hashlib import json import os import pathlib import subprocess import sys import tempfile import bpy class DriverButtonPastePanel(bpy.types.Panel): bl_label = "Driver Button Paste" bl_idname = "WEBGAP_PT_driver_button_paste" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "object" bl_order = -1000 def draw(self, context): obj = context.object if obj is None: return self.layout.label(text="Copy source") self.layout.prop(obj, '["source_target"]', text="source_target") self.layout.label(text="Paste target") self.layout.prop(obj, '["paste_target"]', text="paste_target") def driver_report(obj): if obj is None: raise RuntimeError("paste-driver fixture object is missing") drivers = [] if obj.animation_data is not None: for curve in obj.animation_data.drivers: driver = curve.driver drivers.append( { "path": curve.data_path, "index": curve.array_index, "expression": driver.expression if driver else "", "type": driver.type if driver else "", "variableCount": len(driver.variables) if driver else 0, } ) drivers.sort(key=lambda value: (value["path"], value["index"])) return drivers def state_report(obj): if obj is None: raise RuntimeError("paste-driver fixture object is missing") return { "source": { "value": round(float(obj["source_target"]), 6), "drivers": [driver for driver in driver_report(obj) if driver["path"] == '["source_target"]'], }, "target": { "value": round(float(obj["paste_target"]), 6), "drivers": [driver for driver in driver_report(obj) if driver["path"] == '["paste_target"]'], }, } def preserved_report(fixture, output, current): descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-paste-driver-button-preserved-", suffix=".blend") os.close(descriptor) temporary_path = pathlib.Path(temporary) try: bpy.ops.wm.save_as_mainfile(filepath=str(temporary_path), check_existing=False, compress=True) bpy.ops.wm.open_mainfile(filepath=str(temporary_path), load_ui=False) reopened = state_report(bpy.data.objects.get("WebGapAnimPasteDriverButtonObject")) if reopened != current: raise RuntimeError("anim.paste_driver_button preserved fixture save/reopen drift") report = { "schemaVersion": 1, "task": "M16-GAP-00199", "operation": "ANIM_PASTE_DRIVER_BUTTON_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": current, "after": reopened, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "DRIVER_PASTED", "evidenceStatus": "PRESERVED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string, } output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") print("anim-paste-driver-button-desktop-ok preserved=exact status=FINISHED mainMutation=driver_pasted saveReopen=exact") finally: temporary_path.unlink(missing_ok=True) def foreground_driver_button(fixture, output, before): bpy.utils.register_class(DriverButtonPastePanel) state = {"started": False} def blender_window(): windows = subprocess.check_output(["xdotool", "search", "--name", "Blender"], text=True).split() if not windows: raise RuntimeError("Blender window was not found") return windows[0] def launch_ui_sequence(window_id): # The panel is pinned at the top of the Object properties. First copy # the driven source, then paste it into the undriven target. if os.environ.get("PASTE_DRIVER_DEBUG"): sequence = f"sleep 2; xdotool mousemove --sync --window {window_id} 1170 746; xdotool click --repeat 10 --delay 60 5; sleep 1; xdotool mousemove --sync --window {window_id} 1180 591; xdotool click 3; sleep 0.5; xdotool mousemove --sync --window {window_id} 1060 494; xdotool click 1; sleep 0.75; xdotool mousemove --sync --window {window_id} 1180 645; xdotool click 3; sleep 1; import -window {window_id} /tmp/m16-paste-driver-button.png; sleep 20" subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return sequence = ( "sleep 2; " f"xdotool mousemove --sync --window {window_id} 1170 746; " "xdotool click --repeat 10 --delay 60 5; sleep 0.5; " f"xdotool mousemove --sync --window {window_id} 1180 591; " "xdotool click 3; sleep 0.5; " f"xdotool mousemove --sync --window {window_id} 1060 494; " "xdotool click 1; sleep 0.75; " f"xdotool mousemove --sync --window {window_id} 1180 645; " "xdotool click 3; sleep 0.5; " f"xdotool mousemove --sync --window {window_id} 1080 537; " "xdotool click 1; sleep 0.75; " f"xdotool mousemove --sync --window {window_id} 1170 620; " "xdotool click 3; sleep 0.5; " f"xdotool mousemove --sync --window {window_id} 1080 537; " "xdotool click 1" ) subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def finish_failure(message): print(f"anim-paste-driver-button-desktop-failed: {message}") bpy.ops.wm.quit_blender() return None def poll_result(): obj = bpy.data.objects.get("WebGapAnimPasteDriverButtonObject") after = state_report(obj) if not after["target"]["drivers"]: return 0.25 descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-paste-driver-button-reopen-", suffix=".blend") os.close(descriptor) temporary_path = pathlib.Path(temporary) try: bpy.ops.wm.save_as_mainfile(filepath=str(temporary_path), check_existing=False, compress=True) bpy.ops.wm.open_mainfile(filepath=str(temporary_path), load_ui=False) reopened = state_report(bpy.data.objects.get("WebGapAnimPasteDriverButtonObject")) if reopened != after: return finish_failure("anim.paste_driver_button save/reopen drift") bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True) report = { "schemaVersion": 1, "task": "M16-GAP-00199", "operation": "ANIM_PASTE_DRIVER_BUTTON_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "DRIVER_PASTED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string, } output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") print("anim-paste-driver-button-desktop-ok poll=true status=FINISHED mainMutation=driver_pasted saveReopen=exact") bpy.ops.wm.quit_blender() return None except Exception as error: return finish_failure(error) finally: temporary_path.unlink(missing_ok=True) def start(): if state["started"]: return 0.25 window = bpy.context.window if window is None: return 0.25 area = next((candidate for candidate in window.screen.areas if candidate.type == "PROPERTIES"), None) if area is None: return 0.25 area.spaces.active.context = "OBJECT" state["started"] = True try: bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1) launch_ui_sequence(blender_window()) except Exception as error: return finish_failure(f"UI automation failed: {error}") bpy.app.timers.register(poll_result, first_interval=0.5) return None def timeout(): current = state_report(bpy.data.objects.get("WebGapAnimPasteDriverButtonObject")) if current == before or not current["target"]["drivers"]: return finish_failure("UI paste_driver_button timed out without mutation") return None bpy.app.timers.register(start, first_interval=1.5) bpy.app.timers.register(timeout, first_interval=30.0) def main(): arguments = sys.argv[sys.argv.index("--") + 1 :] if len(arguments) != 2: raise SystemExit("usage: blender -b --python check-action-paste-driver-button-desktop.py -- FIXTURE REPORT") fixture, output = (pathlib.Path(value).resolve() for value in arguments) output.unlink(missing_ok=True) bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False) obj = bpy.data.objects.get("WebGapAnimPasteDriverButtonObject") before = state_report(obj) expected_source = [{ "path": '["source_target"]', "index": 0, "expression": "frame * 3.0 + 2.0", "type": "SCRIPTED", "variableCount": 0, }] target_value = 5.0 if before["target"]["drivers"] else 7.5 if before["source"]["value"] != 5.0 or before["source"]["drivers"] != expected_source or before["target"]["value"] != target_value or before["target"]["drivers"] not in ([], [{ "path": '["paste_target"]', "index": 0, "expression": "frame * 3.0 + 2.0", "type": "SCRIPTED", "variableCount": 0, }]): raise RuntimeError(f"unexpected paste_driver_button source state: {before}") if before["target"]["drivers"]: preserved_report(fixture, output, before) bpy.ops.wm.quit_blender() return if not bpy.app.background: foreground_driver_button(fixture, output, before) return raise RuntimeError("paste_driver_button requires a foreground Blender UI") if __name__ == "__main__": try: main() except Exception as error: print(f"anim-paste-driver-button-desktop-failed: {error}") raise SystemExit(1)