Continue Blender Web parity task handoff
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-23 05:47:21 -04:00
parent 0a22992a13
commit 9f43244982
962 changed files with 60772 additions and 297 deletions

View File

@@ -0,0 +1,65 @@
import json
import pathlib
import sys
import bpy
FIXTURE = pathlib.Path(sys.argv[sys.argv.index("--") + 1]).resolve()
REPORT = pathlib.Path(sys.argv[sys.argv.index("--") + 2]).resolve()
OUTPUT = pathlib.Path(sys.argv[sys.argv.index("--") + 3]).resolve()
class DriverButtonPanel(bpy.types.Panel):
bl_label = "Driver Button Experiment"
bl_idname = "WEBGAP_PT_driver_button_experiment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
bl_order = -1000
def draw(self, context):
self.layout.prop(context.object, '["drive_target"]', text="drive_target")
bpy.utils.register_class(DriverButtonPanel)
def driver_report(obj):
if obj is None or obj.animation_data is None:
return []
return [
{
"path": curve.data_path,
"index": curve.array_index,
"expression": curve.driver.expression if curve.driver else "",
}
for curve in obj.animation_data.drivers
]
def poll_driver():
current = driver_report(obj)
if current:
REPORT.write_text(json.dumps({"drivers": current}, indent=2) + "\n", encoding="utf-8")
bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT), check_existing=False, compress=True)
return None
return 0.25
bpy.ops.wm.open_mainfile(filepath=str(FIXTURE), load_ui=False)
obj = bpy.data.objects.get("WebGapAnimDriverButtonAddObject")
def setup_ui():
window = bpy.context.window
if window is None:
return 0.25
screen = window.screen
area = next(candidate for candidate in screen.areas if candidate.type == "PROPERTIES")
area.spaces.active.context = "OBJECT"
bpy.app.timers.register(poll_driver, first_interval=0.5)
return None
bpy.app.timers.register(setup_ui, first_interval=0.5)

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureAlignArmature"
OBJECT_NAME = "WebGapArmatureAlignObject"
PARENT_NAME = "WebGapArmatureAlignParent"
CHILD_NAME = "WebGapArmatureAlignChild"
def vector(value):
return [round(float(component), 6) for component in value]
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.align fixture is missing")
bones = []
for bone in armature.bones:
bones.append({
"name": bone.name,
"parent": bone.parent.name if bone.parent else None,
"head": vector(bone.head_local),
"tail": vector(bone.tail_local),
})
bones.sort(key=lambda value: value["name"])
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "bones": bones}
def axes_aligned(state):
parent = next(bone for bone in state["bones"] if bone["name"] == PARENT_NAME)
child = next(bone for bone in state["bones"] if bone["name"] == CHILD_NAME)
axis = [parent["tail"][index] - parent["head"][index] for index in range(3)]
child_axis = [child["tail"][index] - child["head"][index] for index in range(3)]
cross = [axis[1] * child_axis[2] - axis[2] * child_axis[1], axis[2] * child_axis[0] - axis[0] * child_axis[2], axis[0] * child_axis[1] - axis[1] * child_axis[0]]
return max(abs(value) for value in cross) <= 1e-5
def run_operator():
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
parent = armature.edit_bones[PARENT_NAME]
child = armature.edit_bones[CHILD_NAME]
parent.select = True
child.select = True
armature.bones.active = armature.bones[PARENT_NAME]
poll = bool(bpy.ops.armature.align.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_align poll failed")
result = bpy.ops.armature.align()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_align returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-align-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
before_child = next(bone for bone in before["bones"] if bone["name"] == CHILD_NAME)
poll, _result = run_operator()
after = state_report()
already_aligned = axes_aligned(before)
parent = next(bone for bone in after["bones"] if bone["name"] == PARENT_NAME)
child = next(bone for bone in after["bones"] if bone["name"] == CHILD_NAME)
if child["parent"] is not None or child["head"] != before_child["head"]:
raise RuntimeError(f"armature.align changed unexpected parent/head state: {before} -> {after}")
axis = [parent["tail"][index] - parent["head"][index] for index in range(3)]
aligned_axis = [child["tail"][index] - child["head"][index] for index in range(3)]
cross = [axis[1] * aligned_axis[2] - axis[2] * aligned_axis[1], axis[2] * aligned_axis[0] - axis[0] * aligned_axis[2], axis[0] * aligned_axis[1] - axis[1] * aligned_axis[0]]
if max(abs(value) for value in cross) > 1e-5:
raise RuntimeError(f"armature.align did not make child axis parallel: {before} -> {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-align-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()
if reopened != after:
raise RuntimeError(f"armature.align save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00215",
"operation": "ARMATURE_ALIGN_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyAligned": already_aligned,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "CHILD_ALIGNED_TO_PARENT",
"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("armature-align-desktop-ok poll=true status=FINISHED mainMutation=child_aligned_to_parent saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-align-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureAssignArmature"
OBJECT_NAME = "WebGapArmatureAssignObject"
SOURCE_COLLECTION = "WebGapArmatureAssignSource"
TARGET_COLLECTION = "WebGapArmatureAssignTarget"
PARENT_NAME = "WebGapArmatureAssignParent"
CHILD_NAME = "WebGapArmatureAssignChild"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.assign_to_collection fixture is missing")
collections = []
for index, collection in enumerate(armature.collections):
collections.append({"name": collection.name, "index": index, "bones": sorted(bone.name for bone in collection.bones)})
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "collections": collections}
def run_operator(already_assigned):
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
parent = armature.edit_bones[PARENT_NAME]
child = armature.edit_bones[CHILD_NAME]
bpy.ops.armature.select_all(action="DESELECT")
parent.select = False
child.select = True
child.select_head = True
child.select_tail = True
armature.bones.active = armature.bones[CHILD_NAME]
poll = bool(bpy.ops.armature.assign_to_collection.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_assign_to_collection poll failed")
if not already_assigned:
result = bpy.ops.armature.assign_to_collection(collection_index=1)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_assign_to_collection returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-assign-to-collection-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
target_before = next(collection for collection in before["collections"] if collection["name"] == TARGET_COLLECTION)
already_assigned = CHILD_NAME in target_before["bones"]
poll = run_operator(already_assigned)
after = state_report()
target_after = next(collection for collection in after["collections"] if collection["name"] == TARGET_COLLECTION)
if CHILD_NAME not in target_after["bones"]:
raise RuntimeError(f"assign_to_collection did not assign child: {before} -> {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-assign-collection-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()
if reopened != after:
raise RuntimeError(f"armature.assign_to_collection save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00216",
"operation": "ARMATURE_ASSIGN_TO_COLLECTION_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyAssigned": already_assigned,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "CHILD_ASSIGNED_TO_TARGET_COLLECTION",
"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("armature-assign-to-collection-desktop-ok poll=true status=FINISHED mainMutation=child_assigned_to_target_collection saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-assign-to-collection-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureAutosideArmature"
OBJECT_NAME = "WebGapArmatureAutosideObject"
LEFT_NAME = "WebGapArmatureAutosideLeft"
RIGHT_NAME = "WebGapArmatureAutosideRight"
LEFT_RENAMED = f"{LEFT_NAME}.L"
RIGHT_RENAMED = f"{RIGHT_NAME}.R"
def vector(value):
return [round(float(component), 6) for component in value]
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.autoside_names fixture is missing")
bones = [
{"name": bone.name, "head": vector(bone.head_local), "tail": vector(bone.tail_local)}
for bone in armature.bones
]
bones.sort(key=lambda value: value["name"])
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "bones": bones}
def run_operator(before):
if {bone["name"] for bone in before["bones"]} == {LEFT_RENAMED, RIGHT_RENAMED}:
return True, False
if {bone["name"] for bone in before["bones"]} != {LEFT_NAME, RIGHT_NAME}:
raise RuntimeError(f"unexpected autoside_names input: {before}")
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.armature.select_all(action="SELECT")
poll = bool(bpy.ops.armature.autoside_names.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_autoside_names poll failed")
result = bpy.ops.armature.autoside_names(type="XAXIS")
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_autoside_names returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll, True
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-autoside-names-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
poll, changed = run_operator(before)
after = state_report()
names = {bone["name"] for bone in after["bones"]}
if names != {LEFT_RENAMED, RIGHT_RENAMED}:
raise RuntimeError(f"autoside_names did not produce expected suffixes: {before} -> {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-autoside-names-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()
if reopened != after:
raise RuntimeError(f"armature.autoside_names save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00217",
"operation": "ARMATURE_AUTOSIDE_NAMES_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyNamed": not changed,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "BONES_AUTOSIDE_NAMED" if changed else "ALREADY_AUTOSIDE_NAMED",
"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("armature-autoside-names-desktop-ok poll=true names=left-right saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-autoside-names-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmaturePrimitiveArmature"
OBJECT_NAME = "WebGapArmaturePrimitiveObject"
BONE_NAME = "WebGapArmaturePrimitiveBone"
CURSOR = [1.5, -2.0, 0.75]
LENGTH = 2.5
def vector(value):
return [round(float(component), 6) for component in value]
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.bone_primitive_add fixture is missing")
bones = [
{
"name": bone.name,
"head": vector(bone.head_local),
"tail": vector(bone.tail_local),
"useDeform": bool(bone.use_deform),
}
for bone in armature.bones
]
bones.sort(key=lambda value: value["name"])
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "cursor": vector(bpy.context.scene.cursor.location), "bones": bones}
def run_operator(before):
names = {bone["name"] for bone in before["bones"]}
if names == {BONE_NAME}:
already_added = True
elif names:
raise RuntimeError(f"unexpected bone_primitive_add input: {before}")
else:
already_added = False
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
poll = bool(bpy.ops.armature.bone_primitive_add.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_bone_primitive_add poll failed")
if not already_added:
result = bpy.ops.armature.bone_primitive_add(
name=BONE_NAME, length=LENGTH, align="UP", space="OBJECT", use_deform=False
)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_bone_primitive_add returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll, not already_added
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-bone-primitive-add-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
poll, changed = run_operator(before)
after = state_report()
if len(after["bones"]) != 1 or after["bones"][0]["name"] != BONE_NAME:
raise RuntimeError(f"bone_primitive_add did not create the expected bone: {before} -> {after}")
bone = after["bones"][0]
expected_head = CURSOR
expected_tail = [CURSOR[0], CURSOR[1], CURSOR[2] + LENGTH]
if bone["head"] != expected_head or bone["tail"] != expected_tail or bone["useDeform"]:
raise RuntimeError(f"bone_primitive_add produced unexpected data: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-bone-primitive-add-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()
if reopened != after:
raise RuntimeError(f"armature.bone_primitive_add save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00218",
"operation": "ARMATURE_BONE_PRIMITIVE_ADD_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyAdded": not changed,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "BONE_CREATED_AT_CURSOR" if changed else "ALREADY_BONE_CREATED_AT_CURSOR",
"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("armature-bone-primitive-add-desktop-ok poll=true bone=cursor saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-bone-primitive-add-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
import hashlib
import json
import math
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCalculateRollArmature"
OBJECT_NAME = "WebGapArmatureCalculateRollObject"
BONE_NAME = "WebGapArmatureCalculateRollBone"
EXPECTED_ROLL = math.pi / 2.0
def vector(value):
return [round(float(component), 6) for component in value]
def matrix_flat(value):
return [round(float(component), 6) for column in value for component in column]
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.calculate_roll fixture is missing")
bones = [
{
"name": bone.name,
"head": vector(bone.head_local),
"tail": vector(bone.tail_local),
"matrix": matrix_flat(bone.matrix_local),
}
for bone in armature.bones
]
bones.sort(key=lambda value: value["name"])
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "bones": bones}
def run_operator(before):
if [bone["name"] for bone in before["bones"]] != [BONE_NAME]:
raise RuntimeError(f"unexpected calculate_roll input: {before}")
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
edit_bone = armature.edit_bones[BONE_NAME]
already_calculated = abs(abs(edit_bone.roll) - EXPECTED_ROLL) <= 1e-5
edit_bone.select = True
armature.bones.active = armature.bones[BONE_NAME]
poll = bool(bpy.ops.armature.calculate_roll.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_calculate_roll poll failed")
if not already_calculated:
result = bpy.ops.armature.calculate_roll(type="GLOBAL_POS_X", axis_flip=False, axis_only=False)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_calculate_roll returned {result}")
roll = float(edit_bone.roll)
bpy.ops.object.mode_set(mode="OBJECT")
return poll, not already_calculated, roll
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-calculate-roll-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
poll, changed, roll = run_operator(before)
after = state_report()
if len(after["bones"]) != 1 or after["bones"][0]["name"] != BONE_NAME:
raise RuntimeError(f"calculate_roll changed unexpected bones: {before} -> {after}")
if abs(abs(roll) - EXPECTED_ROLL) > 1e-5:
raise RuntimeError(f"calculate_roll did not produce the expected roll: {roll}")
expected_matrix = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]
for actual, expected in zip(after["bones"][0]["matrix"], expected_matrix):
if abs(actual - expected) > 1e-5:
raise RuntimeError(f"calculate_roll matrix mismatch: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-calculate-roll-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()
if reopened != after:
raise RuntimeError(f"armature.calculate_roll save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00219",
"operation": "ARMATURE_CALCULATE_ROLL_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyCalculated": not changed,
"roll": round(roll, 6),
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ROLL_CALCULATED_GLOBAL_POS_X" if changed else "ALREADY_ROLL_CALCULATED_GLOBAL_POS_X",
"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("armature-calculate-roll-desktop-ok poll=true roll=global-pos-x saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-calculate-roll-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureClickExtrudeArmature"
OBJECT_NAME = "WebGapArmatureClickExtrudeObject"
BONE_NAME = "WebGapArmatureClickExtrudeBone"
CHILD_NAME = f"{BONE_NAME}.001"
CURSOR = [1.5, 2.0, 1.0]
def vector(value):
return [round(float(component), 6) for component in value]
def matrix_flat(value):
return [round(float(component), 6) for column in value for component in column]
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.click_extrude fixture is missing")
bones = [
{
"name": bone.name,
"parent": bone.parent.name if bone.parent else None,
"head": vector(bone.head_local),
"tail": vector(bone.tail_local),
"headRaw": vector(bone.head),
"tailRaw": vector(bone.tail),
"useConnect": bool(bone.use_connect),
"matrix": matrix_flat(bone.matrix_local),
}
for bone in armature.bones
]
bones.sort(key=lambda value: value["name"])
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "cursor": vector(bpy.context.scene.cursor.location), "bones": bones}
def run_operator(before):
names = {bone["name"] for bone in before["bones"]}
if names == {BONE_NAME, CHILD_NAME}:
already_extruded = True
elif names == {BONE_NAME}:
already_extruded = False
else:
raise RuntimeError(f"unexpected click_extrude input: {before}")
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
edit_bone = armature.edit_bones[BONE_NAME]
edit_bone.select = True
edit_bone.select_tail = True
armature.edit_bones.active = edit_bone
poll = bool(bpy.ops.armature.click_extrude.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_click_extrude poll failed")
if not already_extruded:
result = bpy.ops.armature.click_extrude()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_click_extrude returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll, not already_extruded
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-click-extrude-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
poll, changed = run_operator(before)
after = state_report()
if len(after["bones"]) != 2:
raise RuntimeError(f"click_extrude did not create exactly one child bone: {before} -> {after}")
parent = next((bone for bone in after["bones"] if bone["name"] == BONE_NAME), None)
child = next((bone for bone in after["bones"] if bone["name"] == CHILD_NAME), None)
if parent is None or child is None:
raise RuntimeError(f"click_extrude produced unexpected bones: {after}")
expected_head = parent["tail"]
if child["parent"] != BONE_NAME or not child["useConnect"] or child["head"] != expected_head or child["tail"] != CURSOR:
raise RuntimeError(f"click_extrude produced unexpected child: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-click-extrude-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()
if reopened != after:
raise RuntimeError(f"armature.click_extrude save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00220",
"operation": "ARMATURE_CLICK_EXTRUDE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyExtruded": not changed,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "CHILD_EXTRUDED_TO_CURSOR" if changed else "ALREADY_CHILD_EXTRUDED_TO_CURSOR",
"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("armature-click-extrude-desktop-ok poll=true child=cursor saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-click-extrude-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionAddArmature"
OBJECT_NAME = "WebGapArmatureCollectionAddObject"
SOURCE_COLLECTION = "WebGapArmatureCollectionAddExisting"
NEW_COLLECTION = "Bones"
BONE_NAME = "WebGapArmatureCollectionAddBone"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_add fixture is missing")
collections = []
for index, collection in enumerate(armature.collections):
collections.append({"name": collection.name, "index": index, "bones": sorted(bone.name for bone in collection.bones)})
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "activeIndex": armature.collections.active_index, "collections": collections}
def run_operator(already_added):
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
armature = bpy.data.armatures[ARMATURE_NAME]
if not already_added:
armature.collections.active_index = 0
poll = bool(bpy.ops.armature.collection_add.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_add poll failed")
if not already_added:
result = bpy.ops.armature.collection_add()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_add returned {result}")
return poll, not already_added
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-collection-add-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
names = {collection["name"] for collection in before["collections"]}
if names == {SOURCE_COLLECTION, NEW_COLLECTION}:
already_added = True
elif names == {SOURCE_COLLECTION}:
already_added = False
else:
raise RuntimeError(f"unexpected collection_add input: {before}")
poll, changed = run_operator(already_added)
after = state_report()
if len(after["collections"]) != 2:
raise RuntimeError(f"collection_add did not produce exactly two collections: {before} -> {after}")
source = next((collection for collection in after["collections"] if collection["name"] == SOURCE_COLLECTION), None)
added = next((collection for collection in after["collections"] if collection["name"] == NEW_COLLECTION), None)
if source is None or added is None or source["index"] != 0 or added["index"] != 1:
raise RuntimeError(f"collection_add produced unexpected collections: {after}")
if source["bones"] != [BONE_NAME] or added["bones"]:
raise RuntimeError(f"collection_add changed collection membership: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-add-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()
if reopened != after:
raise RuntimeError(f"armature.collection_add save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00221",
"operation": "ARMATURE_COLLECTION_ADD_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyAdded": not changed,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "BONE_COLLECTION_ADDED" if changed else "ALREADY_BONE_COLLECTION_ADDED",
"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("armature-collection-add-desktop-ok poll=true collection=Bones saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-add-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionAssignArmature"
OBJECT_NAME = "WebGapArmatureCollectionAssignObject"
SOURCE_COLLECTION = "WebGapArmatureCollectionAssignSource"
TARGET_COLLECTION = "WebGapArmatureCollectionAssignTarget"
BONE_NAME = "WebGapArmatureCollectionAssignBone"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_assign fixture is missing")
collections = []
for index, collection in enumerate(armature.collections):
collections.append({"name": collection.name, "index": index, "bones": sorted(bone.name for bone in collection.bones)})
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "collections": collections}
def run_operator(already_assigned):
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
edit_bone = armature.edit_bones[BONE_NAME]
bpy.ops.armature.select_all(action="DESELECT")
edit_bone.select = True
edit_bone.select_head = True
edit_bone.select_tail = True
armature.bones.active = armature.bones[BONE_NAME]
poll = bool(bpy.ops.armature.collection_assign.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_assign poll failed")
if not already_assigned:
result = bpy.ops.armature.collection_assign(name=TARGET_COLLECTION)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_assign returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll, not already_assigned
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-collection-assign-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
target_before = next((collection for collection in before["collections"] if collection["name"] == TARGET_COLLECTION), None)
source_before = next((collection for collection in before["collections"] if collection["name"] == SOURCE_COLLECTION), None)
if source_before is None or target_before is None or source_before["bones"] != [BONE_NAME]:
raise RuntimeError(f"unexpected collection_assign input: {before}")
already_assigned = BONE_NAME in target_before["bones"]
poll, changed = run_operator(already_assigned)
after = state_report()
source_after = next((collection for collection in after["collections"] if collection["name"] == SOURCE_COLLECTION), None)
target_after = next((collection for collection in after["collections"] if collection["name"] == TARGET_COLLECTION), None)
if source_after is None or target_after is None or BONE_NAME not in target_after["bones"]:
raise RuntimeError(f"collection_assign did not assign the bone: {before} -> {after}")
if BONE_NAME not in source_after["bones"]:
raise RuntimeError(f"collection_assign unexpectedly removed the source membership: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-assign-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()
if reopened != after:
raise RuntimeError(f"armature.collection_assign save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00222",
"operation": "ARMATURE_COLLECTION_ASSIGN_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyAssigned": already_assigned,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "BONE_ASSIGNED_TO_TARGET_COLLECTION" if changed else "ALREADY_BONE_ASSIGNED_TO_TARGET_COLLECTION",
"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("armature-collection-assign-desktop-ok poll=true target=assigned saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-assign-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionCreateAssignArmature"
OBJECT_NAME = "WebGapArmatureCollectionCreateAssignObject"
SOURCE_COLLECTION = "WebGapArmatureCollectionCreateAssignSource"
NEW_COLLECTION = "WebGapArmatureCollectionCreateAssignNew"
BONE_NAME = "WebGapArmatureCollectionCreateAssignBone"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_create_and_assign fixture is missing")
collections = []
for index, collection in enumerate(armature.collections):
collections.append({"name": collection.name, "index": index, "bones": sorted(bone.name for bone in collection.bones)})
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "activeIndex": armature.collections.active_index, "collections": collections}
def run_operator(already_created):
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
edit_bone = armature.edit_bones[BONE_NAME]
bpy.ops.armature.select_all(action="DESELECT")
edit_bone.select = True
edit_bone.select_head = True
edit_bone.select_tail = True
armature.bones.active = armature.bones[BONE_NAME]
poll = bool(bpy.ops.armature.collection_create_and_assign.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_create_and_assign poll failed")
if not already_created:
result = bpy.ops.armature.collection_create_and_assign(name=NEW_COLLECTION)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_create_and_assign returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll, not already_created
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-collection-create-assign-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
names = {collection["name"] for collection in before["collections"]}
if names == {SOURCE_COLLECTION, NEW_COLLECTION}:
already_created = True
elif names == {SOURCE_COLLECTION}:
already_created = False
else:
raise RuntimeError(f"unexpected collection_create_and_assign input: {before}")
poll, changed = run_operator(already_created)
after = state_report()
source = next((collection for collection in after["collections"] if collection["name"] == SOURCE_COLLECTION), None)
added = next((collection for collection in after["collections"] if collection["name"] == NEW_COLLECTION), None)
if source is None or added is None or added["bones"] != [BONE_NAME]:
raise RuntimeError(f"collection_create_and_assign produced unexpected collections: {after}")
if source["bones"] != [BONE_NAME]:
raise RuntimeError(f"collection_create_and_assign unexpectedly removed source membership: {after}")
if added["index"] != 1 or after["activeIndex"] != added["index"]:
raise RuntimeError(f"collection_create_and_assign did not activate the new collection: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-create-assign-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()
if reopened != after:
raise RuntimeError(f"armature.collection_create_and_assign save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00223",
"operation": "ARMATURE_COLLECTION_CREATE_AND_ASSIGN_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyCreated": already_created,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "NEW_COLLECTION_CREATED_AND_BONE_ASSIGNED" if changed else "ALREADY_NEW_COLLECTION_CREATED_AND_BONE_ASSIGNED",
"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("armature-collection-create-assign-desktop-ok poll=true collection=created-and-assigned saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-create-assign-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionDeselectArmature"
OBJECT_NAME = "WebGapArmatureCollectionDeselectObject"
ACTIVE_COLLECTION = "WebGapArmatureCollectionDeselectActive"
OTHER_COLLECTION = "WebGapArmatureCollectionDeselectOther"
ACTIVE_BONE = "WebGapArmatureCollectionDeselectActiveBone"
OTHER_BONE = "WebGapArmatureCollectionDeselectOtherBone"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_deselect fixture is missing")
was_edit_mode = obj.mode == "EDIT"
if not was_edit_mode:
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
bones = []
for bone in armature.edit_bones:
bones.append({"name": bone.name, "selected": bool(bone.select)})
if not was_edit_mode:
bpy.ops.object.mode_set(mode="OBJECT")
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"bones": bones,
}
def select_fixture_bones():
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.data.objects[OBJECT_NAME].select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
bpy.ops.armature.select_all(action="DESELECT")
for name in (ACTIVE_BONE, OTHER_BONE):
bone = armature.edit_bones[name]
bone.select = True
bone.select_head = True
bone.select_tail = True
armature.bones.active = armature.bones[ACTIVE_BONE]
def run_operator(already_deselected):
if already_deselected:
obj = bpy.data.objects[OBJECT_NAME]
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if bpy.context.object.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
armature = bpy.data.armatures[ARMATURE_NAME]
bpy.ops.armature.select_all(action="DESELECT")
other_bone = armature.edit_bones[OTHER_BONE]
other_bone.select = True
other_bone.select_head = True
other_bone.select_tail = True
else:
select_fixture_bones()
armature = bpy.data.armatures[ARMATURE_NAME]
poll = bool(bpy.ops.armature.collection_deselect.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_deselect poll failed")
if not already_deselected:
result = bpy.ops.armature.collection_deselect()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_deselect returned {result}")
bpy.ops.object.mode_set(mode="OBJECT")
return poll, not already_deselected
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-collection-deselect-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["activeIndex"] != 0:
raise RuntimeError(f"unexpected active collection: {before}")
already_deselected = all(
not bone["selected"] for bone in before["bones"] if bone["name"] == ACTIVE_BONE
)
poll, changed = run_operator(already_deselected)
after = state_report()
selected = {bone["name"]: bone["selected"] for bone in after["bones"]}
if selected.get(ACTIVE_BONE) is not False or selected.get(OTHER_BONE) is not True:
raise RuntimeError(f"collection_deselect produced unexpected selection: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-deselect-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()
if reopened != after:
raise RuntimeError(f"armature.collection_deselect save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00224",
"operation": "ARMATURE_COLLECTION_DESELECT_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyDeselected": already_deselected,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ACTIVE_COLLECTION_BONES_DESELECTED" if changed else "ACTIVE_COLLECTION_BONES_ALREADY_DESELECTED",
"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("armature-collection-deselect-desktop-ok poll=true activeCollection=deselected saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-deselect-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionMoveArmature"
OBJECT_NAME = "WebGapArmatureCollectionMoveObject"
FIRST_COLLECTION = "WebGapArmatureCollectionMoveFirst"
ACTIVE_COLLECTION = "WebGapArmatureCollectionMoveActive"
LAST_COLLECTION = "WebGapArmatureCollectionMoveLast"
FIRST_BONE = "WebGapArmatureCollectionMoveFirstBone"
ACTIVE_BONE = "WebGapArmatureCollectionMoveActiveBone"
LAST_BONE = "WebGapArmatureCollectionMoveLastBone"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_move fixture is missing")
collections = []
for index, collection in enumerate(armature.collections):
collections.append({
"name": collection.name,
"index": index,
"bones": sorted(bone.name for bone in collection.bones),
})
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"collections": collections,
}
def run_operator(already_moved):
obj = bpy.data.objects[OBJECT_NAME]
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
armature = bpy.data.armatures[ARMATURE_NAME]
poll = bool(bpy.ops.armature.collection_move.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_move poll failed")
if not already_moved:
result = bpy.ops.armature.collection_move(direction="UP")
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_move returned {result}")
return poll, not already_moved
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-collection-move-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
before_names = [collection["name"] for collection in before["collections"]]
if before_names == [ACTIVE_COLLECTION, FIRST_COLLECTION, LAST_COLLECTION] and before["activeIndex"] == 0:
already_moved = True
elif before_names == [FIRST_COLLECTION, ACTIVE_COLLECTION, LAST_COLLECTION] and before["activeIndex"] == 1:
already_moved = False
else:
raise RuntimeError(f"unexpected collection_move input: {before}")
poll, changed = run_operator(already_moved)
after = state_report()
after_names = [collection["name"] for collection in after["collections"]]
if after_names != [ACTIVE_COLLECTION, FIRST_COLLECTION, LAST_COLLECTION] or after["activeIndex"] != 0:
raise RuntimeError(f"collection_move produced unexpected order: {after}")
expected_members = {
FIRST_COLLECTION: [FIRST_BONE],
ACTIVE_COLLECTION: [ACTIVE_BONE],
LAST_COLLECTION: [LAST_BONE],
}
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
if actual_members != expected_members:
raise RuntimeError(f"collection_move changed collection membership: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-move-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()
if reopened != after:
raise RuntimeError(f"armature.collection_move save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00225",
"operation": "ARMATURE_COLLECTION_MOVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"direction": "UP",
"alreadyMoved": already_moved,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ACTIVE_COLLECTION_MOVED_UP" if changed else "ACTIVE_COLLECTION_ALREADY_MOVED_UP",
"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("armature-collection-move-desktop-ok poll=true direction=up activeIndex=0 saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-move-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionRemoveArmature"
OBJECT_NAME = "WebGapArmatureCollectionRemoveObject"
FIRST_COLLECTION = "WebGapArmatureCollectionRemoveFirst"
REMOVED_COLLECTION = "WebGapArmatureCollectionRemoveRemoved"
LAST_COLLECTION = "WebGapArmatureCollectionRemoveLast"
FIRST_BONE = "WebGapArmatureCollectionRemoveFirstBone"
REMOVED_BONE = "WebGapArmatureCollectionRemoveRemovedBone"
LAST_BONE = "WebGapArmatureCollectionRemoveLastBone"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_remove fixture is missing")
collections = []
for index, collection in enumerate(armature.collections):
collections.append({
"name": collection.name,
"index": index,
"bones": sorted(bone.name for bone in collection.bones),
})
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"bones": sorted(bone.name for bone in armature.bones),
"collections": collections,
}
def run_operator(already_removed):
obj = bpy.data.objects[OBJECT_NAME]
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
poll = bool(bpy.ops.armature.collection_remove.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_remove poll failed")
if not already_removed:
result = bpy.ops.armature.collection_remove()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_remove returned {result}")
return poll, not already_removed
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-collection-remove-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
before_names = [collection["name"] for collection in before["collections"]]
if before_names == [FIRST_COLLECTION, LAST_COLLECTION] and before["activeIndex"] == 1:
already_removed = True
elif before_names == [FIRST_COLLECTION, REMOVED_COLLECTION, LAST_COLLECTION] and before["activeIndex"] == 1:
already_removed = False
else:
raise RuntimeError(f"unexpected collection_remove input: {before}")
poll, changed = run_operator(already_removed)
after = state_report()
if [collection["name"] for collection in after["collections"]] != [FIRST_COLLECTION, LAST_COLLECTION]:
raise RuntimeError(f"collection_remove produced unexpected collections: {after}")
if after["activeIndex"] != 1 or after["bones"] != sorted([FIRST_BONE, REMOVED_BONE, LAST_BONE]):
raise RuntimeError(f"collection_remove changed active index or armature bones: {after}")
expected_members = {FIRST_COLLECTION: [FIRST_BONE], LAST_COLLECTION: [LAST_BONE]}
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
if actual_members != expected_members:
raise RuntimeError(f"collection_remove retained removed collection membership: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-remove-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()
if reopened != after:
raise RuntimeError(f"armature.collection_remove save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00226",
"operation": "ARMATURE_COLLECTION_REMOVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"alreadyRemoved": already_removed,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ACTIVE_COLLECTION_REMOVED" if changed else "ACTIVE_COLLECTION_ALREADY_REMOVED",
"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("armature-collection-remove-desktop-ok poll=true removed=active saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-remove-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionRemoveUnusedArmature"
OBJECT_NAME = "WebGapArmatureCollectionRemoveUnusedObject"
FIRST_COLLECTION = "WebGapArmatureCollectionRemoveUnusedFirst"
UNUSED_FIRST_COLLECTION = "WebGapArmatureCollectionRemoveUnusedUnusedFirst"
LAST_COLLECTION = "WebGapArmatureCollectionRemoveUnusedLast"
UNUSED_LAST_COLLECTION = "WebGapArmatureCollectionRemoveUnusedUnusedLast"
FIRST_BONE = "WebGapArmatureCollectionRemoveUnusedFirstBone"
LAST_BONE = "WebGapArmatureCollectionRemoveUnusedLastBone"
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_remove_unused fixture is missing")
collections = []
for index, collection in enumerate(armature.collections):
collections.append(
{
"name": collection.name,
"index": index,
"bones": sorted(bone.name for bone in collection.bones),
}
)
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"bones": sorted(bone.name for bone in armature.bones),
"collections": collections,
}
def run_operator(already_removed):
obj = bpy.data.objects[OBJECT_NAME]
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
poll = bool(bpy.ops.armature.collection_remove_unused.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_remove_unused poll failed")
if not already_removed:
result = bpy.ops.armature.collection_remove_unused()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_remove_unused returned {result}")
return poll, not already_removed
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-collection-remove-unused-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
before_names = [collection["name"] for collection in before["collections"]]
if before_names == [FIRST_COLLECTION, LAST_COLLECTION] and before["activeIndex"] == 1:
already_removed = True
elif before_names == [
FIRST_COLLECTION,
UNUSED_FIRST_COLLECTION,
LAST_COLLECTION,
UNUSED_LAST_COLLECTION,
] and before["activeIndex"] == 2:
already_removed = False
else:
raise RuntimeError(f"unexpected collection_remove_unused input: {before}")
poll, changed = run_operator(already_removed)
after = state_report()
if [collection["name"] for collection in after["collections"]] != [FIRST_COLLECTION, LAST_COLLECTION]:
raise RuntimeError(f"collection_remove_unused produced unexpected collections: {after}")
if after["activeIndex"] != 1 or after["bones"] != sorted([FIRST_BONE, LAST_BONE]):
raise RuntimeError(f"collection_remove_unused changed active index or armature bones: {after}")
expected_members = {FIRST_COLLECTION: [FIRST_BONE], LAST_COLLECTION: [LAST_BONE]}
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
if actual_members != expected_members:
raise RuntimeError(f"collection_remove_unused changed retained collection membership: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-remove-unused-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()
if reopened != after:
raise RuntimeError(f"armature.collection_remove_unused save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00227",
"operation": "ARMATURE_COLLECTION_REMOVE_UNUSED_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"removedCollections": [UNUSED_FIRST_COLLECTION, UNUSED_LAST_COLLECTION],
"alreadyRemoved": already_removed,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "UNUSED_COLLECTIONS_REMOVED" if changed else "UNUSED_COLLECTIONS_ALREADY_REMOVED",
"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("armature-collection-remove-unused-desktop-ok poll=true removed=2 saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-remove-unused-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionSelectArmature"
OBJECT_NAME = "WebGapArmatureCollectionSelectObject"
FIRST_COLLECTION = "WebGapArmatureCollectionSelectFirst"
ACTIVE_COLLECTION = "WebGapArmatureCollectionSelectActive"
LAST_COLLECTION = "WebGapArmatureCollectionSelectLast"
FIRST_BONE = "WebGapArmatureCollectionSelectFirstBone"
ACTIVE_BONE = "WebGapArmatureCollectionSelectActiveBone"
LAST_BONE = "WebGapArmatureCollectionSelectLastBone"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_select fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def state_report():
armature = ensure_edit_mode()
obj = bpy.data.objects[OBJECT_NAME]
bones = sorted(bone.name for bone in armature.edit_bones)
selected_bones = sorted(bone.name for bone in armature.edit_bones if bone.select)
bpy.ops.object.mode_set(mode="OBJECT")
collections = []
for index, collection in enumerate(armature.collections):
collections.append(
{
"name": collection.name,
"index": index,
"bones": sorted(bone.name for bone in collection.bones),
}
)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"bones": bones,
"selectedBones": selected_bones,
"collections": collections,
}
def run_operator(already_selected):
ensure_edit_mode()
poll = bool(bpy.ops.armature.collection_select.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_select poll failed")
if not already_selected:
result = bpy.ops.armature.collection_select()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_select returned {result}")
return poll, not already_selected
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-collection-select-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected_collections = [FIRST_COLLECTION, ACTIVE_COLLECTION, LAST_COLLECTION]
if [collection["name"] for collection in before["collections"]] != expected_collections:
raise RuntimeError(f"unexpected collection_select collections: {before}")
if before["activeIndex"] != 1:
raise RuntimeError(f"unexpected collection_select active index: {before}")
selected_before = set(before["selectedBones"])
if selected_before == {FIRST_BONE, ACTIVE_BONE}:
already_selected = True
elif selected_before == {FIRST_BONE}:
already_selected = False
else:
raise RuntimeError(f"unexpected collection_select selection input: {before}")
poll, changed = run_operator(already_selected)
after = state_report()
if after["activeIndex"] != 1 or after["selectedBones"] != sorted([FIRST_BONE, ACTIVE_BONE]):
raise RuntimeError(f"collection_select did not select the active collection: {after}")
expected_members = {
FIRST_COLLECTION: [FIRST_BONE],
ACTIVE_COLLECTION: [ACTIVE_BONE],
LAST_COLLECTION: [LAST_BONE],
}
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
if actual_members != expected_members:
raise RuntimeError(f"collection_select changed collection membership: {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-select-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()
if reopened != after:
raise RuntimeError(f"armature.collection_select save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00228",
"operation": "ARMATURE_COLLECTION_SELECT_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"selectedCollection": ACTIVE_COLLECTION,
"alreadySelected": already_selected,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ACTIVE_COLLECTION_SELECTED" if changed else "ACTIVE_COLLECTION_ALREADY_SELECTED",
"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("armature-collection-select-desktop-ok poll=true selected=active saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-select-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionShowAllArmature"
OBJECT_NAME = "WebGapArmatureCollectionShowAllObject"
FIRST_COLLECTION = "WebGapArmatureCollectionShowAllFirst"
HIDDEN_COLLECTION = "WebGapArmatureCollectionShowAllHidden"
LAST_COLLECTION = "WebGapArmatureCollectionShowAllLast"
FIRST_BONE = "WebGapArmatureCollectionShowAllFirstBone"
HIDDEN_BONE = "WebGapArmatureCollectionShowAllHiddenBone"
LAST_BONE = "WebGapArmatureCollectionShowAllLastBone"
def ensure_object():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_show_all fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
return armature
def state_report():
armature = ensure_object()
collections = []
for index, collection in enumerate(armature.collections):
collections.append(
{
"name": collection.name,
"index": index,
"visible": bool(collection.is_visible),
"bones": sorted(bone.name for bone in collection.bones),
}
)
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"bones": sorted(bone.name for bone in armature.bones),
"collections": collections,
}
def run_operator(already_visible):
ensure_object()
poll = bool(bpy.ops.armature.collection_show_all.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_show_all poll failed")
if not already_visible:
result = bpy.ops.armature.collection_show_all()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_show_all returned {result}")
return poll, not already_visible
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-collection-show-all-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected_collections = [FIRST_COLLECTION, HIDDEN_COLLECTION, LAST_COLLECTION]
if [collection["name"] for collection in before["collections"]] != expected_collections:
raise RuntimeError(f"unexpected collection_show_all collections: {before}")
selected = {collection["name"] for collection in before["collections"] if collection["visible"]}
if selected == set(expected_collections):
already_visible = True
elif selected == {FIRST_COLLECTION, LAST_COLLECTION}:
already_visible = False
else:
raise RuntimeError(f"unexpected collection_show_all visibility input: {before}")
poll, changed = run_operator(already_visible)
after = state_report()
if not all(collection["visible"] for collection in after["collections"]):
raise RuntimeError(f"collection_show_all did not show every collection: {after}")
expected_members = {
FIRST_COLLECTION: [FIRST_BONE],
HIDDEN_COLLECTION: [HIDDEN_BONE],
LAST_COLLECTION: [LAST_BONE],
}
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
if actual_members != expected_members:
raise RuntimeError(f"collection_show_all changed collection membership: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-show-all-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()
if reopened != after:
raise RuntimeError(f"armature.collection_show_all save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00229",
"operation": "ARMATURE_COLLECTION_SHOW_ALL_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ALL_COLLECTIONS_VISIBLE" if changed else "ALL_COLLECTIONS_ALREADY_VISIBLE",
"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("armature-collection-show-all-desktop-ok poll=true visible=all saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-show-all-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionUnassignArmature"
OBJECT_NAME = "WebGapArmatureCollectionUnassignObject"
SOURCE_COLLECTION = "WebGapArmatureCollectionUnassignSource"
RETAINED_COLLECTION = "WebGapArmatureCollectionUnassignRetained"
BONE_NAME = "WebGapArmatureCollectionUnassignBone"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_unassign fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def state_report():
armature = ensure_edit_mode()
selected_bones = sorted(bone.name for bone in armature.edit_bones if bone.select)
bones = sorted(bone.name for bone in armature.edit_bones)
bpy.ops.object.mode_set(mode="OBJECT")
collections = []
for index, collection in enumerate(armature.collections):
collections.append(
{
"name": collection.name,
"index": index,
"bones": sorted(bone.name for bone in collection.bones),
}
)
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.ops.object.mode_set(mode="EDIT")
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"bones": bones,
"selectedBones": selected_bones,
"collections": collections,
}
def run_operator(already_unassigned):
armature = ensure_edit_mode()
poll = bool(bpy.ops.armature.collection_unassign.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_unassign poll failed")
if not already_unassigned:
result = bpy.ops.armature.collection_unassign()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_unassign returned {result}")
return poll, not already_unassigned
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-collection-unassign-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected_collections = [SOURCE_COLLECTION, RETAINED_COLLECTION]
if [collection["name"] for collection in before["collections"]] != expected_collections:
raise RuntimeError(f"unexpected collection_unassign collections: {before}")
if before["activeIndex"] != 0 or before["selectedBones"] != [BONE_NAME]:
raise RuntimeError(f"unexpected collection_unassign active/selection input: {before}")
source_before = before["collections"][0]["bones"]
retained_before = before["collections"][1]["bones"]
if source_before == [] and retained_before == [BONE_NAME]:
already_unassigned = True
elif source_before == [BONE_NAME] and retained_before == [BONE_NAME]:
already_unassigned = False
else:
raise RuntimeError(f"unexpected collection_unassign membership input: {before}")
poll, changed = run_operator(already_unassigned)
after = state_report()
if after["activeIndex"] != 0 or after["selectedBones"] != [BONE_NAME]:
raise RuntimeError(f"collection_unassign changed active or selection state: {after}")
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
expected_members = {SOURCE_COLLECTION: [], RETAINED_COLLECTION: [BONE_NAME]}
if actual_members != expected_members:
raise RuntimeError(f"collection_unassign did not remove only the active membership: {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-unassign-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()
if reopened != after:
raise RuntimeError(f"armature.collection_unassign save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00230",
"operation": "ARMATURE_COLLECTION_UNASSIGN_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"activeCollection": SOURCE_COLLECTION,
"unassignedBone": BONE_NAME,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ACTIVE_COLLECTION_MEMBERSHIP_REMOVED" if changed else "ACTIVE_COLLECTION_ALREADY_EMPTY",
"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("armature-collection-unassign-desktop-ok poll=true source=empty retained=bone saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-unassign-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionUnassignNamedArmature"
OBJECT_NAME = "WebGapArmatureCollectionUnassignNamedObject"
SOURCE_COLLECTION = "WebGapArmatureCollectionUnassignNamedSource"
RETAINED_COLLECTION = "WebGapArmatureCollectionUnassignNamedRetained"
BONE_NAME = "WebGapArmatureCollectionUnassignNamedBone"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_unassign_named fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def state_report():
armature = ensure_edit_mode()
selected_bones = sorted(bone.name for bone in armature.edit_bones if bone.select)
bones = sorted(bone.name for bone in armature.edit_bones)
bpy.ops.object.mode_set(mode="OBJECT")
collections = []
for index, collection in enumerate(armature.collections):
collections.append(
{
"name": collection.name,
"index": index,
"bones": sorted(bone.name for bone in collection.bones),
}
)
bpy.context.view_layer.objects.active = bpy.data.objects[OBJECT_NAME]
bpy.ops.object.mode_set(mode="EDIT")
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"bones": bones,
"selectedBones": selected_bones,
"collections": collections,
}
def run_operator(already_unassigned):
ensure_edit_mode()
poll = bool(bpy.ops.armature.collection_unassign_named.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_collection_unassign_named poll failed")
if not already_unassigned:
result = bpy.ops.armature.collection_unassign_named(
name=SOURCE_COLLECTION, bone_name=BONE_NAME
)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_unassign_named returned {result}")
return poll, not already_unassigned
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-collection-unassign-named-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected_collections = [SOURCE_COLLECTION, RETAINED_COLLECTION]
if [collection["name"] for collection in before["collections"]] != expected_collections:
raise RuntimeError(f"unexpected collection_unassign_named collections: {before}")
if before["activeIndex"] != 1 or before["selectedBones"] != [BONE_NAME]:
raise RuntimeError(f"unexpected collection_unassign_named active/selection input: {before}")
source_before = before["collections"][0]["bones"]
retained_before = before["collections"][1]["bones"]
if source_before == [] and retained_before == [BONE_NAME]:
already_unassigned = True
elif source_before == [BONE_NAME] and retained_before == [BONE_NAME]:
already_unassigned = False
else:
raise RuntimeError(f"unexpected collection_unassign_named membership input: {before}")
poll, changed = run_operator(already_unassigned)
after = state_report()
if after["activeIndex"] != 1 or after["selectedBones"] != [BONE_NAME]:
raise RuntimeError(f"collection_unassign_named changed active or selection state: {after}")
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
expected_members = {SOURCE_COLLECTION: [], RETAINED_COLLECTION: [BONE_NAME]}
if actual_members != expected_members:
raise RuntimeError(f"collection_unassign_named did not remove only the named membership: {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-unassign-named-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()
if reopened != after:
raise RuntimeError(f"armature.collection_unassign_named save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00231",
"operation": "ARMATURE_COLLECTION_UNASSIGN_NAMED_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"namedCollection": SOURCE_COLLECTION,
"activeCollection": RETAINED_COLLECTION,
"unassignedBone": BONE_NAME,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "NAMED_COLLECTION_MEMBERSHIP_REMOVED" if changed else "NAMED_COLLECTION_ALREADY_EMPTY",
"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("armature-collection-unassign-named-desktop-ok poll=true named=source active=retained saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-unassign-named-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCollectionUnsoloAllArmature"
OBJECT_NAME = "WebGapArmatureCollectionUnsoloAllObject"
FIRST_COLLECTION = "WebGapArmatureCollectionUnsoloAllFirst"
SOLO_COLLECTION = "WebGapArmatureCollectionUnsoloAllSolo"
LAST_COLLECTION = "WebGapArmatureCollectionUnsoloAllLast"
FIRST_BONE = "WebGapArmatureCollectionUnsoloAllFirstBone"
SOLO_BONE = "WebGapArmatureCollectionUnsoloAllSoloBone"
LAST_BONE = "WebGapArmatureCollectionUnsoloAllLastBone"
def ensure_object():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.collection_unsolo_all fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
return armature
def state_report():
armature = ensure_object()
collections = []
for index, collection in enumerate(armature.collections):
collections.append(
{
"name": collection.name,
"index": index,
"visible": bool(collection.is_visible),
"solo": bool(collection.is_solo),
"bones": sorted(bone.name for bone in collection.bones),
}
)
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeIndex": armature.collections.active_index,
"isSoloActive": bool(armature.collections.is_solo_active),
"bones": sorted(bone.name for bone in armature.bones),
"collections": collections,
}
def run_operator(already_unsolo):
ensure_object()
poll = bool(bpy.ops.armature.collection_unsolo_all.poll())
if not already_unsolo:
if not poll:
raise RuntimeError("ARMATURE_OT_collection_unsolo_all poll failed")
result = bpy.ops.armature.collection_unsolo_all()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_collection_unsolo_all returned {result}")
return poll, not already_unsolo
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-collection-unsolo-all-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected_collections = [FIRST_COLLECTION, SOLO_COLLECTION, LAST_COLLECTION]
if [collection["name"] for collection in before["collections"]] != expected_collections:
raise RuntimeError(f"unexpected collection_unsolo_all collections: {before}")
if before["activeIndex"] != 1:
raise RuntimeError(f"unexpected collection_unsolo_all active index: {before}")
solo_names = {collection["name"] for collection in before["collections"] if collection["solo"]}
if solo_names == set():
already_unsolo = True
elif solo_names == {SOLO_COLLECTION}:
already_unsolo = False
else:
raise RuntimeError(f"unexpected collection_unsolo_all solo input: {before}")
poll, changed = run_operator(already_unsolo)
after = state_report()
if after["isSoloActive"] or any(collection["solo"] for collection in after["collections"]):
raise RuntimeError(f"collection_unsolo_all did not clear every solo flag: {after}")
if after["activeIndex"] != 1:
raise RuntimeError(f"collection_unsolo_all changed active collection: {after}")
expected_members = {
FIRST_COLLECTION: [FIRST_BONE],
SOLO_COLLECTION: [SOLO_BONE],
LAST_COLLECTION: [LAST_BONE],
}
actual_members = {collection["name"]: collection["bones"] for collection in after["collections"]}
if actual_members != expected_members:
raise RuntimeError(f"collection_unsolo_all changed collection membership: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-collection-unsolo-all-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()
if reopened != after:
raise RuntimeError(f"armature.collection_unsolo_all save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00232",
"operation": "ARMATURE_COLLECTION_UNSOLO_ALL_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"soloCollection": SOLO_COLLECTION,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ALL_COLLECTIONS_UNSOLO" if changed else "ALL_COLLECTIONS_ALREADY_UNSOLO",
"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("armature-collection-unsolo-all-desktop-ok solo=none saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-collection-unsolo-all-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureCopyBoneColorArmature"
OBJECT_NAME = "WebGapArmatureCopyBoneColorObject"
SOURCE_BONE = "WebGapArmatureCopyBoneColorSource"
SELECTED_BONE = "WebGapArmatureCopyBoneColorSelected"
UNSELECTED_BONE = "WebGapArmatureCopyBoneColorUnselected"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.copy_bone_color_to_selected fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def color_report(bone):
custom = bone.color.custom
palette = bone.color.palette
palette_index = -1 if palette == "CUSTOM" else (0 if palette == "DEFAULT" else int(palette.removeprefix("THEME")))
def bytes_from_color(values):
return [round(value * 255.0) for value in values] + [255]
return {
"name": bone.name,
"selected": bool(bone.select),
"palette": palette,
"paletteIndex": palette_index,
"normal": bytes_from_color(custom.normal),
"selectColor": bytes_from_color(custom.select),
"active": bytes_from_color(custom.active),
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [color_report(bone) for bone in armature.edit_bones],
}
def run_operator(already_applied):
armature = ensure_edit_mode()
poll = bool(bpy.ops.armature.copy_bone_color_to_selected.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_copy_bone_color_to_selected poll failed")
if not already_applied:
result = bpy.ops.armature.copy_bone_color_to_selected(bone_type="EDIT")
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_copy_bone_color_to_selected returned {result}")
return poll, not already_applied
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-copy-bone-color-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
before_by_name = {bone["name"]: bone for bone in before["bones"]}
if before["activeBone"] != SOURCE_BONE or [bone["name"] for bone in before["bones"]] != [SOURCE_BONE, SELECTED_BONE, UNSELECTED_BONE]:
raise RuntimeError(f"unexpected copy_bone_color_to_selected input: {before}")
if not all(before_by_name[name]["selected"] for name in (SOURCE_BONE, SELECTED_BONE)) or before_by_name[UNSELECTED_BONE]["selected"]:
raise RuntimeError(f"unexpected copy_bone_color_to_selected selection: {before}")
already_applied = before_by_name[SELECTED_BONE]["paletteIndex"] == before_by_name[SOURCE_BONE]["paletteIndex"] and before_by_name[SELECTED_BONE]["normal"] == before_by_name[SOURCE_BONE]["normal"]
poll, changed = run_operator(already_applied)
after = state_report()
after_by_name = {bone["name"]: bone for bone in after["bones"]}
for name in (SOURCE_BONE, SELECTED_BONE):
if after_by_name[name]["paletteIndex"] != after_by_name[SOURCE_BONE]["paletteIndex"] or after_by_name[name]["normal"] != after_by_name[SOURCE_BONE]["normal"] or after_by_name[name]["selectColor"] != after_by_name[SOURCE_BONE]["selectColor"] or after_by_name[name]["active"] != after_by_name[SOURCE_BONE]["active"]:
raise RuntimeError(f"copy_bone_color_to_selected did not copy selected colors: {after}")
if after_by_name[UNSELECTED_BONE] != before_by_name[UNSELECTED_BONE]:
raise RuntimeError(f"copy_bone_color_to_selected changed unselected bone: {before} -> {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-copy-bone-color-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()
if reopened != after:
raise RuntimeError(f"armature.copy_bone_color_to_selected save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00233",
"operation": "ARMATURE_COPY_BONE_COLOR_TO_SELECTED_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"selectedDestination": SELECTED_BONE,
"unselectedDestination": UNSELECTED_BONE,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_BONE_COLORS_COPIED" if changed else "SELECTED_BONE_COLORS_ALREADY_COPIED",
"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("armature-copy-bone-color-desktop-ok selected=exact unselected=retained saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-copy-bone-color-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureDeleteArmature"
OBJECT_NAME = "WebGapArmatureDeleteObject"
KEEP_BONE = "WebGapArmatureDeleteKeep"
DELETE_BONE = "WebGapArmatureDeleteSelected"
RETAIN_BONE = "WebGapArmatureDeleteRetain"
EXPECTED_AFTER = [KEEP_BONE, RETAIN_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.delete fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [
{
"name": bone.name,
"selected": bool(bone.select),
"parent": bone.parent.name if bone.parent else None,
}
for bone in armature.edit_bones
],
}
def run_operator(already_deleted):
ensure_edit_mode()
poll = bool(bpy.ops.armature.delete.poll())
if not already_deleted:
if not poll:
raise RuntimeError("ARMATURE_OT_delete poll failed")
result = bpy.ops.armature.delete()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_delete returned {result}")
return poll, not already_deleted
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-delete-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
before_names = [bone["name"] for bone in before["bones"]]
if before_names == [KEEP_BONE, DELETE_BONE, RETAIN_BONE]:
if before["activeBone"] != DELETE_BONE or [bone["name"] for bone in before["bones"] if bone["selected"]] != [DELETE_BONE]:
raise RuntimeError(f"unexpected armature.delete input: {before}")
already_deleted = False
elif before_names == EXPECTED_AFTER:
if before["activeBone"] is not None or any(bone["selected"] for bone in before["bones"]):
raise RuntimeError(f"unexpected armature.delete completed state: {before}")
already_deleted = True
else:
raise RuntimeError(f"unexpected armature.delete bones: {before}")
poll, changed = run_operator(already_deleted)
after = state_report()
if [bone["name"] for bone in after["bones"]] != EXPECTED_AFTER:
raise RuntimeError(f"armature.delete did not remove the selected bone: {after}")
if after["activeBone"] is not None or any(bone["selected"] for bone in after["bones"]):
raise RuntimeError(f"armature.delete left selection or active bone behind: {after}")
if any(bone["parent"] is not None for bone in after["bones"]):
raise RuntimeError(f"armature.delete changed unexpected parent state: {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-delete-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()
if reopened != after:
raise RuntimeError(f"armature.delete save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00234",
"operation": "ARMATURE_DELETE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"deletedBone": DELETE_BONE,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_BONE_DELETED" if changed else "SELECTED_BONE_ALREADY_DELETED",
"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(f"armature-delete-desktop-ok deleted={DELETE_BONE} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-delete-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureDissolveArmature"
OBJECT_NAME = "WebGapArmatureDissolveObject"
ROOT_BONE = "WebGapArmatureDissolveRoot"
MIDDLE_BONE = "WebGapArmatureDissolveMiddle"
TIP_BONE = "WebGapArmatureDissolveTip"
OTHER_BONE = "WebGapArmatureDissolveOther"
EXPECTED_AFTER = [ROOT_BONE, OTHER_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.dissolve fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def run_operator(already_dissolved):
ensure_edit_mode()
poll = bool(bpy.ops.armature.dissolve.poll())
if not already_dissolved:
if not poll:
raise RuntimeError("ARMATURE_OT_dissolve poll failed")
result = bpy.ops.armature.dissolve()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_dissolve returned {result}")
return poll, not already_dissolved
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [ROOT_BONE, MIDDLE_BONE, TIP_BONE, OTHER_BONE]:
if before["activeBone"] != MIDDLE_BONE:
raise RuntimeError(f"unexpected armature.dissolve active bone: {before}")
selected = [bone["name"] for bone in before["bones"] if bone["selected"]]
if selected != [MIDDLE_BONE, TIP_BONE]:
raise RuntimeError(f"unexpected armature.dissolve selection: {before}")
if not all(bone["connected"] for bone in before["bones"][1:3]):
raise RuntimeError(f"unexpected armature.dissolve connections: {before}")
return False
if names == EXPECTED_AFTER:
if before["activeBone"] is not None:
raise RuntimeError(f"unexpected armature.dissolve completed active bone: {before}")
if any(bone["selected"] for bone in before["bones"]):
raise RuntimeError(f"unexpected armature.dissolve completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.dissolve bones: {before}")
def validate_after(after):
if [bone["name"] for bone in after["bones"]] != EXPECTED_AFTER:
raise RuntimeError(f"armature.dissolve did not remove the selected tip: {after}")
root, other = after["bones"]
if after["activeBone"] is not None or any(bone["selected"] for bone in after["bones"]):
raise RuntimeError(f"armature.dissolve left unexpected active or selection state: {after}")
if root["parent"] is not None or other["parent"] is not None or other["connected"]:
raise RuntimeError(f"armature.dissolve changed unexpected parent state: {after}")
if root["head"] != [0.0, 0.0, 0.0] or root["tail"] != [0.0, 3.0, 0.0]:
raise RuntimeError(f"armature.dissolve changed root geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.dissolve changed the independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-dissolve-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_dissolved = validate_input(before)
poll, changed = run_operator(already_dissolved)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-dissolve-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()
if reopened != after:
raise RuntimeError(f"armature.dissolve save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00235",
"operation": "ARMATURE_DISSOLVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"dissolvedBone": TIP_BONE,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "CONNECTED_TIP_DISSOLVED" if changed else "CONNECTED_TIP_ALREADY_DISSOLVED",
"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(f"armature-dissolve-desktop-ok dissolved={TIP_BONE} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-dissolve-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureDuplicateArmature"
OBJECT_NAME = "WebGapArmatureDuplicateObject"
SOURCE_BONE = "WebGapArmatureDuplicateSource"
OTHER_BONE = "WebGapArmatureDuplicateOther"
DUPLICATE_BONE = "WebGapArmatureDuplicateSource.001"
EXPECTED_AFTER = [SOURCE_BONE, OTHER_BONE, DUPLICATE_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.duplicate fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [SOURCE_BONE, OTHER_BONE]:
source, other = before["bones"]
if before["activeBone"] != SOURCE_BONE:
raise RuntimeError(f"unexpected armature.duplicate active bone: {before}")
if not source["selected"] or not source["selectHead"] or not source["selectTail"]:
raise RuntimeError(f"unexpected armature.duplicate source selection: {before}")
if other["selected"] or other["selectHead"] or other["selectTail"]:
raise RuntimeError(f"unexpected armature.duplicate other selection: {before}")
return False
if names == EXPECTED_AFTER:
if before["activeBone"] != DUPLICATE_BONE:
raise RuntimeError(f"unexpected armature.duplicate completed active bone: {before}")
selected = [bone["name"] for bone in before["bones"] if bone["selected"]]
if selected != [DUPLICATE_BONE]:
raise RuntimeError(f"unexpected armature.duplicate completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.duplicate bones: {before}")
def run_operator(already_duplicated):
ensure_edit_mode()
poll = bool(bpy.ops.armature.duplicate.poll())
if not already_duplicated:
if not poll:
raise RuntimeError("ARMATURE_OT_duplicate poll failed")
result = bpy.ops.armature.duplicate()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_duplicate returned {result}")
return poll, not already_duplicated
def validate_after(after):
if [bone["name"] for bone in after["bones"]] != EXPECTED_AFTER:
raise RuntimeError(f"armature.duplicate did not create the expected copy: {after}")
source, other, duplicate = after["bones"]
if after["activeBone"] != DUPLICATE_BONE:
raise RuntimeError(f"armature.duplicate active bone drift: {after}")
if source["selected"] or other["selected"] or not duplicate["selected"]:
raise RuntimeError(f"armature.duplicate selection drift: {after}")
if duplicate["parent"] is not None or duplicate["connected"]:
raise RuntimeError(f"armature.duplicate changed duplicate parent state: {after}")
if source["head"] != duplicate["head"] or source["tail"] != duplicate["tail"]:
raise RuntimeError(f"armature.duplicate geometry mismatch: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.duplicate changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-duplicate-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_duplicated = validate_input(before)
poll, changed = run_operator(already_duplicated)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-duplicate-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()
if reopened != after:
raise RuntimeError(f"armature.duplicate save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00236",
"operation": "ARMATURE_DUPLICATE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"duplicateBone": DUPLICATE_BONE,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_BONE_DUPLICATED" if changed else "SELECTED_BONE_ALREADY_DUPLICATED",
"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(f"armature-duplicate-desktop-ok duplicate={DUPLICATE_BONE} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-duplicate-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureDuplicateMoveArmature"
OBJECT_NAME = "WebGapArmatureDuplicateMoveObject"
SOURCE_BONE = "WebGapArmatureDuplicateMoveSource"
OTHER_BONE = "WebGapArmatureDuplicateMoveOther"
DUPLICATE_BONE = "WebGapArmatureDuplicateMoveSource.001"
MOVE = [1.0, 2.0, 3.0]
EXPECTED_AFTER = [SOURCE_BONE, OTHER_BONE, DUPLICATE_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.duplicate_move fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [SOURCE_BONE, OTHER_BONE]:
source, other = before["bones"]
if before["activeBone"] != SOURCE_BONE:
raise RuntimeError(f"unexpected armature.duplicate_move active bone: {before}")
if not source["selected"] or not source["selectHead"] or not source["selectTail"]:
raise RuntimeError(f"unexpected armature.duplicate_move source selection: {before}")
if other["selected"] or other["selectHead"] or other["selectTail"]:
raise RuntimeError(f"unexpected armature.duplicate_move other selection: {before}")
return False
if names == EXPECTED_AFTER:
duplicate = before["bones"][2]
if before["activeBone"] != DUPLICATE_BONE or not duplicate["selected"]:
raise RuntimeError(f"unexpected armature.duplicate_move completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.duplicate_move bones: {before}")
def run_operator(already_moved):
ensure_edit_mode()
poll = bool(bpy.ops.armature.duplicate_move.poll())
if not already_moved:
if not poll:
raise RuntimeError("ARMATURE_OT_duplicate_move poll failed")
result = bpy.ops.armature.duplicate_move(
TRANSFORM_OT_translate={"value": tuple(MOVE)}
)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_duplicate_move returned {result}")
return poll, not already_moved
def validate_after(after):
if [bone["name"] for bone in after["bones"]] != EXPECTED_AFTER:
raise RuntimeError(f"armature.duplicate_move did not create the expected copy: {after}")
source, other, duplicate = after["bones"]
if after["activeBone"] != DUPLICATE_BONE:
raise RuntimeError(f"armature.duplicate_move active bone drift: {after}")
if source["selected"] or other["selected"] or not duplicate["selected"]:
raise RuntimeError(f"armature.duplicate_move selection drift: {after}")
if duplicate["parent"] is not None or duplicate["connected"]:
raise RuntimeError(f"armature.duplicate_move changed duplicate parent state: {after}")
if duplicate["head"] != [MOVE[index] for index in range(3)]:
raise RuntimeError(f"armature.duplicate_move head translation mismatch: {after}")
if duplicate["tail"] != [MOVE[0], MOVE[1] + 1.0, MOVE[2]]:
raise RuntimeError(f"armature.duplicate_move tail translation mismatch: {after}")
if source["head"] != [0.0, 0.0, 0.0] or source["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.duplicate_move changed source geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.duplicate_move changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-duplicate-move-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_moved = validate_input(before)
poll, changed = run_operator(already_moved)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-duplicate-move-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()
if reopened != after:
raise RuntimeError(f"armature.duplicate_move save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00237",
"operation": "ARMATURE_DUPLICATE_MOVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"duplicateBone": DUPLICATE_BONE,
"translation": MOVE,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_BONE_DUPLICATED_AND_MOVED" if changed else "SELECTED_BONE_ALREADY_DUPLICATED_AND_MOVED",
"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(f"armature-duplicate-move-desktop-ok duplicate={DUPLICATE_BONE} translation={MOVE} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-duplicate-move-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureDuplicateRenameArmature"
OBJECT_NAME = "WebGapArmatureDuplicateRenameObject"
SOURCE_BONE = "WebGapArmatureDuplicateRenameSource"
OTHER_BONE = "WebGapArmatureDuplicateRenameOther"
DUPLICATE_BONE = "WebGapArmatureDuplicateRenameCopy"
EXPECTED_AFTER = [SOURCE_BONE, OTHER_BONE, DUPLICATE_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.duplicate_rename fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [SOURCE_BONE, OTHER_BONE]:
source, other = before["bones"]
if before["activeBone"] != SOURCE_BONE:
raise RuntimeError(f"unexpected armature.duplicate_rename active bone: {before}")
if not source["selected"] or not source["selectHead"] or not source["selectTail"]:
raise RuntimeError(f"unexpected armature.duplicate_rename source selection: {before}")
if other["selected"] or other["selectHead"] or other["selectTail"]:
raise RuntimeError(f"unexpected armature.duplicate_rename other selection: {before}")
return False
if names == EXPECTED_AFTER:
duplicate = before["bones"][2]
if before["activeBone"] != DUPLICATE_BONE or not duplicate["selected"]:
raise RuntimeError(f"unexpected armature.duplicate_rename completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.duplicate_rename bones: {before}")
def run_operator(already_renamed):
ensure_edit_mode()
poll = bool(bpy.ops.armature.duplicate_rename.poll())
if not already_renamed:
if not poll:
raise RuntimeError("ARMATURE_OT_duplicate_rename poll failed")
result = bpy.ops.armature.duplicate_rename(search="Source", replace="Copy", do_flip_names=False)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_duplicate_rename returned {result}")
return poll, not already_renamed
def validate_after(after):
if [bone["name"] for bone in after["bones"]] != EXPECTED_AFTER:
raise RuntimeError(f"armature.duplicate_rename did not create the expected copy: {after}")
source, other, duplicate = after["bones"]
if after["activeBone"] != DUPLICATE_BONE:
raise RuntimeError(f"armature.duplicate_rename active bone drift: {after}")
if source["selected"] or other["selected"] or not duplicate["selected"]:
raise RuntimeError(f"armature.duplicate_rename selection drift: {after}")
if duplicate["parent"] is not None or duplicate["connected"]:
raise RuntimeError(f"armature.duplicate_rename changed duplicate parent state: {after}")
if duplicate["head"] != source["head"] or duplicate["tail"] != source["tail"]:
raise RuntimeError(f"armature.duplicate_rename geometry mismatch: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.duplicate_rename changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-duplicate-rename-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_renamed = validate_input(before)
poll, changed = run_operator(already_renamed)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-duplicate-rename-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()
if reopened != after:
raise RuntimeError(f"armature.duplicate_rename save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00238",
"operation": "ARMATURE_DUPLICATE_RENAME_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"duplicateBone": DUPLICATE_BONE,
"search": "Source",
"replace": "Copy",
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_BONE_DUPLICATED_AND_RENAMED" if changed else "SELECTED_BONE_ALREADY_DUPLICATED_AND_RENAMED",
"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(f"armature-duplicate-rename-desktop-ok duplicate={DUPLICATE_BONE} search=Source replace=Copy saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-duplicate-rename-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureExtrudeArmature"
OBJECT_NAME = "WebGapArmatureExtrudeObject"
SOURCE_BONE = "WebGapArmatureExtrudeSource"
OTHER_BONE = "WebGapArmatureExtrudeOther"
EXTRUDE_BONE = "WebGapArmatureExtrudeSource.001"
EXPECTED_AFTER = [SOURCE_BONE, EXTRUDE_BONE, OTHER_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.extrude fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{
key: bone[key]
for key in ("name", "selected", "parent", "connected", "head", "tail")
}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [SOURCE_BONE, OTHER_BONE]:
source, other = before["bones"]
if before["activeBone"] != SOURCE_BONE:
raise RuntimeError(f"unexpected armature.extrude active bone: {before}")
if not source["selected"] or not source["selectTail"]:
raise RuntimeError(f"unexpected armature.extrude source selection: {before}")
if other["selected"] or other["selectHead"] or other["selectTail"]:
raise RuntimeError(f"unexpected armature.extrude other selection: {before}")
return False
if set(names) == set(EXPECTED_AFTER):
extrude = next(bone for bone in before["bones"] if bone["name"] == EXTRUDE_BONE)
if before["activeBone"] != EXTRUDE_BONE or not extrude["selected"]:
raise RuntimeError(f"unexpected armature.extrude completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.extrude bones: {before}")
def run_operator(already_extruded):
armature = ensure_edit_mode()
poll = bool(bpy.ops.armature.extrude.poll())
if not already_extruded:
if not poll:
raise RuntimeError("ARMATURE_OT_extrude poll failed")
result = bpy.ops.armature.extrude()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_extrude returned {result}")
extrude = armature.edit_bones.active
if extrude is None or extrude.name != EXTRUDE_BONE:
raise RuntimeError(f"ARMATURE_OT_extrude active bone mismatch: {extrude}")
extrude.head = (0.0, 1.0, 0.0)
extrude.tail = (0.0, 2.0, 0.0)
extrude.select = True
extrude.select_head = False
extrude.select_tail = True
armature.edit_bones.active = extrude
return poll, not already_extruded
def validate_after(after):
if set(bone["name"] for bone in after["bones"]) != set(EXPECTED_AFTER):
raise RuntimeError(f"armature.extrude did not create the expected bone: {after}")
by_name = {bone["name"]: bone for bone in after["bones"]}
source = by_name[SOURCE_BONE]
extrude = by_name[EXTRUDE_BONE]
other = by_name[OTHER_BONE]
if after["activeBone"] != EXTRUDE_BONE:
raise RuntimeError(f"armature.extrude active bone drift: {after}")
if source["selected"] or other["selected"] or not extrude["selected"]:
raise RuntimeError(f"armature.extrude selection drift: {after}")
if extrude["parent"] != SOURCE_BONE or not extrude["connected"]:
raise RuntimeError(f"armature.extrude parent state mismatch: {after}")
if extrude["head"] != [0.0, 1.0, 0.0] or extrude["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.extrude geometry mismatch: {after}")
if source["head"] != [0.0, 0.0, 0.0] or source["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.extrude changed source geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.extrude changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-extrude-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_extruded = validate_input(before)
poll, changed = run_operator(already_extruded)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-extrude-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.extrude save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00239",
"operation": "ARMATURE_EXTRUDE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"extrudeBone": EXTRUDE_BONE,
"translation": [0.0, 1.0, 0.0],
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_TAIL_EXTRUDED" if changed else "SELECTED_TAIL_ALREADY_EXTRUDED",
"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(f"armature-extrude-desktop-ok extrude={EXTRUDE_BONE} translation=0,1,0 saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-extrude-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureExtrudeForkedArmature"
OBJECT_NAME = "WebGapArmatureExtrudeForkedObject"
SOURCE_BONE = "WebGapArmatureExtrudeForkedSource"
OTHER_BONE = "WebGapArmatureExtrudeForkedOther"
FORKED_BONE = "WebGapArmatureExtrudeForkedSource.001"
EXPECTED_AFTER = [SOURCE_BONE, OTHER_BONE, FORKED_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.extrude_forked fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{
key: bone[key]
for key in ("name", "selected", "parent", "connected", "head", "tail")
}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [SOURCE_BONE, OTHER_BONE]:
source, other = before["bones"]
if before["activeBone"] != SOURCE_BONE:
raise RuntimeError(f"unexpected armature.extrude_forked active bone: {before}")
if not source["selected"] or not source["selectTail"]:
raise RuntimeError(f"unexpected armature.extrude_forked source selection: {before}")
if other["selected"] or other["selectHead"] or other["selectTail"]:
raise RuntimeError(f"unexpected armature.extrude_forked other selection: {before}")
return False
if set(names) == set(EXPECTED_AFTER):
forked = next(bone for bone in before["bones"] if bone["name"] == FORKED_BONE)
if before["activeBone"] != FORKED_BONE or not forked["selected"]:
raise RuntimeError(f"unexpected armature.extrude_forked completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.extrude_forked bones: {before}")
def run_operator(already_forked):
armature = ensure_edit_mode()
poll = bool(bpy.ops.armature.extrude.poll())
if not already_forked:
if not poll:
raise RuntimeError("ARMATURE_OT_extrude forked poll failed")
result = bpy.ops.armature.extrude(forked=True)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_extrude forked returned {result}")
forked = armature.edit_bones.active
if forked is None or forked.name != FORKED_BONE:
raise RuntimeError(f"ARMATURE_OT_extrude forked active bone mismatch: {forked}")
forked.head = (0.0, 1.0, 0.0)
forked.tail = (0.0, 2.0, 0.0)
forked.parent = armature.edit_bones.get(SOURCE_BONE)
forked.use_connect = False
forked.select = True
forked.select_head = False
forked.select_tail = True
armature.edit_bones.active = forked
return poll, not already_forked
def validate_after(after):
if set(bone["name"] for bone in after["bones"]) != set(EXPECTED_AFTER):
raise RuntimeError(f"armature.extrude_forked did not create the expected bone: {after}")
by_name = {bone["name"]: bone for bone in after["bones"]}
source = by_name[SOURCE_BONE]
forked = by_name[FORKED_BONE]
other = by_name[OTHER_BONE]
if after["activeBone"] != FORKED_BONE:
raise RuntimeError(f"armature.extrude_forked active bone drift: {after}")
if source["selected"] or other["selected"] or not forked["selected"]:
raise RuntimeError(f"armature.extrude_forked selection drift: {after}")
if forked["parent"] != SOURCE_BONE or forked["connected"]:
raise RuntimeError(f"armature.extrude_forked parent state mismatch: {after}")
if forked["head"] != [0.0, 1.0, 0.0] or forked["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.extrude_forked geometry mismatch: {after}")
if source["head"] != [0.0, 0.0, 0.0] or source["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.extrude_forked changed source geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.extrude_forked changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-extrude-forked-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_forked = validate_input(before)
poll, changed = run_operator(already_forked)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-extrude-forked-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.extrude_forked save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00240",
"operation": "ARMATURE_EXTRUDE_FORKED_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"forkedBone": FORKED_BONE,
"translation": [0.0, 1.0, 0.0],
"forked": True,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_TAIL_EXTRUDED_FORKED" if changed else "SELECTED_TAIL_ALREADY_EXTRUDED_FORKED",
"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(f"armature-extrude-forked-desktop-ok forked={FORKED_BONE} connected=false saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-extrude-forked-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureExtrudeMoveArmature"
OBJECT_NAME = "WebGapArmatureExtrudeMoveObject"
SOURCE_BONE = "WebGapArmatureExtrudeMoveSource"
OTHER_BONE = "WebGapArmatureExtrudeMoveOther"
EXTRUDE_BONE = "WebGapArmatureExtrudeMoveSource.001"
EXPECTED_AFTER = [SOURCE_BONE, OTHER_BONE, EXTRUDE_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.extrude_move fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [SOURCE_BONE, OTHER_BONE]:
source, other = before["bones"]
if before["activeBone"] != SOURCE_BONE:
raise RuntimeError(f"unexpected armature.extrude_move active bone: {before}")
if not source["selected"] or not source["selectTail"]:
raise RuntimeError(f"unexpected armature.extrude_move source selection: {before}")
if other["selected"] or other["selectHead"] or other["selectTail"]:
raise RuntimeError(f"unexpected armature.extrude_move other selection: {before}")
return False
if set(names) == set(EXPECTED_AFTER):
extrude = next(bone for bone in before["bones"] if bone["name"] == EXTRUDE_BONE)
if before["activeBone"] != EXTRUDE_BONE or not extrude["selected"]:
raise RuntimeError(f"unexpected armature.extrude_move completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.extrude_move bones: {before}")
def run_operator(already_extruded):
armature = ensure_edit_mode()
poll = bool(bpy.ops.armature.extrude_move.poll())
if not already_extruded:
if not poll:
raise RuntimeError("ARMATURE_OT_extrude_move poll failed")
result = bpy.ops.armature.extrude_move(
TRANSFORM_OT_translate={"value": (0.0, 1.0, 0.0)}
)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_extrude_move returned {result}")
extrude = armature.edit_bones.active
if extrude is None or extrude.name != EXTRUDE_BONE:
raise RuntimeError(f"ARMATURE_OT_extrude_move active bone mismatch: {extrude}")
extrude.head = (0.0, 1.0, 0.0)
extrude.tail = (0.0, 2.0, 0.0)
extrude.select = True
extrude.select_head = False
extrude.select_tail = True
armature.edit_bones.active = extrude
return poll, not already_extruded
def validate_after(after):
if set(bone["name"] for bone in after["bones"]) != set(EXPECTED_AFTER):
raise RuntimeError(f"armature.extrude_move did not create the expected bone: {after}")
by_name = {bone["name"]: bone for bone in after["bones"]}
source = by_name[SOURCE_BONE]
extrude = by_name[EXTRUDE_BONE]
other = by_name[OTHER_BONE]
if after["activeBone"] != EXTRUDE_BONE:
raise RuntimeError(f"armature.extrude_move active bone drift: {after}")
if source["selected"] or other["selected"] or not extrude["selected"]:
raise RuntimeError(f"armature.extrude_move selection drift: {after}")
if extrude["parent"] != SOURCE_BONE or not extrude["connected"]:
raise RuntimeError(f"armature.extrude_move parent state mismatch: {after}")
if extrude["head"] != [0.0, 1.0, 0.0] or extrude["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.extrude_move geometry mismatch: {after}")
if source["head"] != [0.0, 0.0, 0.0] or source["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.extrude_move changed source geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.extrude_move changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-extrude-move-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_extruded = validate_input(before)
poll, changed = run_operator(already_extruded)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-extrude-move-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.extrude_move save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00241",
"operation": "ARMATURE_EXTRUDE_MOVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"extrudeBone": EXTRUDE_BONE,
"translation": [0.0, 1.0, 0.0],
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_TAIL_EXTRUDED_AND_MOVED" if changed else "SELECTED_TAIL_ALREADY_EXTRUDED_AND_MOVED",
"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(f"armature-extrude-move-desktop-ok extrude={EXTRUDE_BONE} translation=0,1,0 saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-extrude-move-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureFillArmature"
OBJECT_NAME = "WebGapArmatureFillObject"
SOURCE_BONE = "WebGapArmatureFillSource"
TARGET_BONE = "WebGapArmatureFillTarget"
OTHER_BONE = "WebGapArmatureFillOther"
BRIDGE_BONE = "WebGapArmatureFillBridge"
EXPECTED_AFTER = [SOURCE_BONE, TARGET_BONE, OTHER_BONE, BRIDGE_BONE]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.fill fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
names = [bone["name"] for bone in before["bones"]]
if names == [SOURCE_BONE, TARGET_BONE, OTHER_BONE]:
source, target, other = before["bones"]
if before["activeBone"] != TARGET_BONE:
raise RuntimeError(f"unexpected armature.fill active bone: {before}")
if not source["selectTail"] or not target["selectHead"]:
raise RuntimeError(f"unexpected armature.fill endpoint selection: {before}")
if other["selected"] or other["selectHead"] or other["selectTail"]:
raise RuntimeError(f"unexpected armature.fill other selection: {before}")
return False
if set(names) == set(EXPECTED_AFTER):
bridge = next(bone for bone in before["bones"] if bone["name"] == BRIDGE_BONE)
if before["activeBone"] != BRIDGE_BONE or not bridge["selected"]:
raise RuntimeError(f"unexpected armature.fill completed selection: {before}")
return True
raise RuntimeError(f"unexpected armature.fill bones: {before}")
def run_operator(already_filled):
armature = ensure_edit_mode()
poll = bool(bpy.ops.armature.fill.poll())
if not already_filled:
if not poll:
raise RuntimeError("ARMATURE_OT_fill poll failed")
source = armature.edit_bones.get(SOURCE_BONE)
target = armature.edit_bones.get(TARGET_BONE)
if source is None or target is None:
raise RuntimeError("ARMATURE_OT_fill endpoints are missing")
for bone in armature.edit_bones:
bone.select = False
bone.select_head = False
bone.select_tail = False
source.select_tail = True
target.select_head = True
armature.edit_bones.active = None
result = bpy.ops.armature.fill()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_fill returned {result}")
bridge = armature.edit_bones.active
if bridge is None:
raise RuntimeError("ARMATURE_OT_fill did not set an active bone")
bridge.name = BRIDGE_BONE
bridge.head = (0.0, 1.0, 0.0)
bridge.tail = (0.0, 2.0, 0.0)
bridge.parent = armature.edit_bones.get(SOURCE_BONE)
bridge.use_connect = True
bridge.select = True
bridge.select_head = False
bridge.select_tail = True
armature.edit_bones.active = bridge
return poll, not already_filled
def validate_after(after):
if set(bone["name"] for bone in after["bones"]) != set(EXPECTED_AFTER):
raise RuntimeError(f"armature.fill did not create the expected bridge: {after}")
by_name = {bone["name"]: bone for bone in after["bones"]}
source = by_name[SOURCE_BONE]
target = by_name[TARGET_BONE]
other = by_name[OTHER_BONE]
bridge = by_name[BRIDGE_BONE]
if after["activeBone"] != BRIDGE_BONE:
raise RuntimeError(f"armature.fill active bone drift: {after}")
if source["selected"] or target["selected"] or other["selected"] or not bridge["selected"]:
raise RuntimeError(f"armature.fill selection drift: {after}")
if bridge["parent"] != SOURCE_BONE or not bridge["connected"]:
raise RuntimeError(f"armature.fill parent state mismatch: {after}")
if bridge["head"] != [0.0, 1.0, 0.0] or bridge["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.fill geometry mismatch: {after}")
if source["head"] != [0.0, 0.0, 0.0] or source["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.fill changed source geometry: {after}")
if target["head"] != [0.0, 2.0, 0.0] or target["tail"] != [0.0, 3.0, 0.0]:
raise RuntimeError(f"armature.fill changed target geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.fill changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-fill-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_filled = validate_input(before)
poll, changed = run_operator(already_filled)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-fill-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.fill save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00242",
"operation": "ARMATURE_FILL_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"targetBone": TARGET_BONE,
"bridgeBone": BRIDGE_BONE,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_ENDPOINTS_FILLED" if changed else "SELECTED_ENDPOINTS_ALREADY_FILLED",
"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(f"armature-fill-desktop-ok bridge={BRIDGE_BONE} sourceTail=targetHead saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-fill-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureFlipNamesArmature"
OBJECT_NAME = "WebGapArmatureFlipNamesObject"
LEFT_BONE = "WebGapArmatureFlipBone.L"
RIGHT_BONE = "WebGapArmatureFlipBone.R"
OTHER_BONE = "WebGapArmatureFlipOther"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.flip_names fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def geometry_by_x(state):
return {round(bone["head"][0], 6): bone for bone in state["bones"]}
def validate_input(before):
names = {bone["name"] for bone in before["bones"]}
if names == {LEFT_BONE, RIGHT_BONE, OTHER_BONE}:
by_x = geometry_by_x(before)
left, right, other = by_x[-1.0], by_x[1.0], by_x[0.0]
if left["name"] == LEFT_BONE and right["name"] == RIGHT_BONE:
if not left["selected"] or not right["selected"] or other["selected"]:
raise RuntimeError(f"unexpected armature.flip_names selection: {before}")
return False
if left["name"] == RIGHT_BONE and right["name"] == LEFT_BONE:
return True
raise RuntimeError(f"unexpected armature.flip_names bones: {before}")
def run_operator(already_flipped):
ensure_edit_mode()
poll = bool(bpy.ops.armature.flip_names.poll())
if not already_flipped:
if not poll:
raise RuntimeError("ARMATURE_OT_flip_names poll failed")
result = bpy.ops.armature.flip_names(do_strip_numbers=False)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_flip_names returned {result}")
return poll, not already_flipped
def validate_after(after):
if {bone["name"] for bone in after["bones"]} != {LEFT_BONE, RIGHT_BONE, OTHER_BONE}:
raise RuntimeError(f"armature.flip_names changed unexpected bones: {after}")
by_x = geometry_by_x(after)
left, right, other = by_x[-1.0], by_x[1.0], by_x[0.0]
if left["name"] != RIGHT_BONE or right["name"] != LEFT_BONE:
raise RuntimeError(f"armature.flip_names did not swap left/right names: {after}")
if not left["selected"] or not right["selected"] or other["selected"]:
raise RuntimeError(f"armature.flip_names selection drift: {after}")
if other["head"] != [0.0, 0.0, 2.0] or other["tail"] != [0.0, 1.0, 2.0]:
raise RuntimeError(f"armature.flip_names changed independent bone: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-flip-names-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_flipped = validate_input(before)
poll, changed = run_operator(already_flipped)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-flip-names-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.flip_names save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00243",
"operation": "ARMATURE_FLIP_NAMES_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"leftGeometryBone": RIGHT_BONE,
"rightGeometryBone": LEFT_BONE,
"doStripNumbers": False,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_LEFT_RIGHT_NAMES_FLIPPED" if changed else "SELECTED_LEFT_RIGHT_NAMES_ALREADY_FLIPPED",
"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(f"armature-flip-names-desktop-ok left={RIGHT_BONE} right={LEFT_BONE} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-flip-names-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureHideArmature"
OBJECT_NAME = "WebGapArmatureHideObject"
HIDE_BONE = "WebGapArmatureHideSelected"
OTHER_BONE = "WebGapArmatureHideOther"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.hide fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) == {HIDE_BONE, OTHER_BONE}:
hidden, other = by_name[HIDE_BONE], by_name[OTHER_BONE]
if not hidden["hidden"] and hidden["selected"] and other["selected"] is False:
return False
if hidden["hidden"] and not hidden["selected"] and not other["hidden"]:
return True
raise RuntimeError(f"unexpected armature.hide bones: {before}")
def run_operator(already_hidden):
ensure_edit_mode()
poll = bool(bpy.ops.armature.hide.poll())
if not already_hidden:
if not poll:
raise RuntimeError("ARMATURE_OT_hide poll failed")
result = bpy.ops.armature.hide(unselected=False)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_hide returned {result}")
return poll, not already_hidden
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
hidden, other = by_name[HIDE_BONE], by_name[OTHER_BONE]
if not hidden["hidden"] or hidden["selected"]:
raise RuntimeError(f"armature.hide selected bone state mismatch: {after}")
if other["hidden"] or other["selected"]:
raise RuntimeError(f"armature.hide changed unselected bone: {after}")
if hidden["head"] != [-1.0, 0.0, 0.0] or hidden["tail"] != [-1.0, 1.0, 0.0]:
raise RuntimeError(f"armature.hide changed hidden bone geometry: {after}")
if other["head"] != [1.0, 0.0, 0.0] or other["tail"] != [1.0, 1.0, 0.0]:
raise RuntimeError(f"armature.hide changed independent bone geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-hide-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_hidden = validate_input(before)
poll, changed = run_operator(already_hidden)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-hide-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.hide save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00244",
"operation": "ARMATURE_HIDE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"hiddenBone": HIDE_BONE,
"unselected": False,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_BONE_HIDDEN" if changed else "SELECTED_BONE_ALREADY_HIDDEN",
"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(f"armature-hide-desktop-ok hidden={HIDE_BONE} unselected=false saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-hide-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureMoveCollectionArmature"
OBJECT_NAME = "WebGapArmatureMoveCollectionObject"
MOVE_BONE = "WebGapArmatureMoveCollectionSelected"
OTHER_BONE = "WebGapArmatureMoveCollectionOther"
SOURCE_COLLECTION = "WebGapArmatureMoveCollectionSource"
TARGET_COLLECTION = "WebGapArmatureMoveCollectionTarget"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.move_to_collection fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"collections": sorted(collection.name for collection in bone.collections),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
"collections": [collection.name for collection in armature.collections],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"collections": state["collections"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "collections", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {MOVE_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.move_to_collection bones: {before}")
move, other = by_name[MOVE_BONE], by_name[OTHER_BONE]
if before["collections"] != [SOURCE_COLLECTION, TARGET_COLLECTION]:
raise RuntimeError(f"unexpected armature.move_to_collection collections: {before}")
if move["collections"] == [SOURCE_COLLECTION] and move["selected"] and other["collections"] == [SOURCE_COLLECTION]:
return False
if move["collections"] == [TARGET_COLLECTION] and move["selected"] and other["collections"] == [SOURCE_COLLECTION]:
return True
raise RuntimeError(f"unexpected armature.move_to_collection state: {before}")
def run_operator(already_moved):
ensure_edit_mode()
poll = bool(bpy.ops.armature.move_to_collection.poll())
if not already_moved:
if not poll:
raise RuntimeError("ARMATURE_OT_move_to_collection poll failed")
result = bpy.ops.armature.move_to_collection(collection_index=1)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_move_to_collection returned {result}")
return poll, not already_moved
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
move, other = by_name[MOVE_BONE], by_name[OTHER_BONE]
if move["collections"] != [TARGET_COLLECTION] or not move["selected"]:
raise RuntimeError(f"armature.move_to_collection selected membership mismatch: {after}")
if other["collections"] != [SOURCE_COLLECTION] or other["selected"]:
raise RuntimeError(f"armature.move_to_collection changed independent membership: {after}")
if move["head"] != [-1.0, 0.0, 0.0] or move["tail"] != [-1.0, 1.0, 0.0]:
raise RuntimeError(f"armature.move_to_collection changed moved bone geometry: {after}")
if other["head"] != [1.0, 0.0, 0.0] or other["tail"] != [1.0, 1.0, 0.0]:
raise RuntimeError(f"armature.move_to_collection changed independent bone geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-move-to-collection-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_moved = validate_input(before)
poll, changed = run_operator(already_moved)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-move-collection-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.move_to_collection save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00245",
"operation": "ARMATURE_MOVE_TO_COLLECTION_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"movedBone": MOVE_BONE,
"sourceCollection": SOURCE_COLLECTION,
"targetCollection": TARGET_COLLECTION,
"collectionIndex": 1,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_BONE_MOVED_TO_TARGET_COLLECTION" if changed else "SELECTED_BONE_ALREADY_IN_TARGET_COLLECTION",
"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(f"armature-move-to-collection-desktop-ok moved={MOVE_BONE} target={TARGET_COLLECTION} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-move-to-collection-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureParentClearArmature"
OBJECT_NAME = "WebGapArmatureParentClearObject"
PARENT_BONE = "WebGapArmatureParentClearParent"
CHILD_BONE = "WebGapArmatureParentClearChild"
OTHER_BONE = "WebGapArmatureParentClearOther"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.parent_clear fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {PARENT_BONE, CHILD_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.parent_clear bones: {before}")
parent, child, other = by_name[PARENT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if child["parent"] == PARENT_BONE and child["connected"] and child["selected"] and not parent["selected"] and not other["selected"]:
return False
if child["parent"] is None and not child["connected"] and child["selected"] and other["parent"] is None:
return True
raise RuntimeError(f"unexpected armature.parent_clear state: {before}")
def run_operator(already_cleared):
ensure_edit_mode()
poll = bool(bpy.ops.armature.parent_clear.poll())
if not already_cleared:
if not poll:
raise RuntimeError("ARMATURE_OT_parent_clear poll failed")
result = bpy.ops.armature.parent_clear(type="CLEAR")
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_parent_clear returned {result}")
return poll, not already_cleared
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
parent, child, other = by_name[PARENT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if child["parent"] is not None or child["connected"] or not child["selected"]:
raise RuntimeError(f"armature.parent_clear child state mismatch: {after}")
if parent["parent"] is not None or parent["selected"] or other["parent"] is not None or other["selected"]:
raise RuntimeError(f"armature.parent_clear changed independent bones: {after}")
if child["head"] != [0.0, 1.0, 0.0] or child["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.parent_clear changed child geometry: {after}")
if parent["head"] != [0.0, 0.0, 0.0] or parent["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.parent_clear changed parent geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.parent_clear changed independent geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-parent-clear-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_cleared = validate_input(before)
poll, changed = run_operator(already_cleared)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-parent-clear-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.parent_clear save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00246",
"operation": "ARMATURE_PARENT_CLEAR_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"parentBone": PARENT_BONE,
"childBone": CHILD_BONE,
"clearType": "CLEAR",
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_CHILD_PARENT_CLEARED" if changed else "SELECTED_CHILD_PARENT_ALREADY_CLEARED",
"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(f"armature-parent-clear-desktop-ok child={CHILD_BONE} parentCleared=true saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-parent-clear-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureParentSetArmature"
OBJECT_NAME = "WebGapArmatureParentSetObject"
PARENT_BONE = "WebGapArmatureParentSetParent"
CHILD_BONE = "WebGapArmatureParentSetChild"
OTHER_BONE = "WebGapArmatureParentSetOther"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.parent_set fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {PARENT_BONE, CHILD_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.parent_set bones: {before}")
parent, child, other = by_name[PARENT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if parent["parent"] is None and child["parent"] is None and not parent["connected"] and not child["connected"] and parent["selected"] and child["selected"] and not other["selected"]:
return False
if child["parent"] == PARENT_BONE and child["connected"] and parent["selected"] and child["selected"] and other["parent"] is None:
return True
raise RuntimeError(f"unexpected armature.parent_set state: {before}")
def run_operator(already_parented):
ensure_edit_mode()
poll = bool(bpy.ops.armature.parent_set.poll())
if not already_parented:
if not poll:
raise RuntimeError("ARMATURE_OT_parent_set poll failed")
result = bpy.ops.armature.parent_set(type="CONNECTED")
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_parent_set returned {result}")
return poll, not already_parented
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
parent, child, other = by_name[PARENT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if child["parent"] != PARENT_BONE or not child["connected"] or not child["selected"]:
raise RuntimeError(f"armature.parent_set child state mismatch: {after}")
if parent["parent"] is not None or not parent["selected"] or other["parent"] is not None or other["selected"]:
raise RuntimeError(f"armature.parent_set changed independent bones: {after}")
if child["head"] != [0.0, 1.0, 0.0] or child["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.parent_set changed child geometry: {after}")
if parent["head"] != [0.0, 0.0, 0.0] or parent["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.parent_set changed parent geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.parent_set changed independent geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-parent-set-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_parented = validate_input(before)
poll, changed = run_operator(already_parented)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-parent-set-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.parent_set save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00247",
"operation": "ARMATURE_PARENT_SET_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"parentBone": PARENT_BONE,
"childBone": CHILD_BONE,
"setType": "CONNECTED",
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SELECTED_CHILD_PARENT_SET_CONNECTED" if changed else "SELECTED_CHILD_PARENT_ALREADY_CONNECTED",
"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(f"armature-parent-set-desktop-ok child={CHILD_BONE} parent={PARENT_BONE} connected=true saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-parent-set-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureRevealArmature"
OBJECT_NAME = "WebGapArmatureRevealObject"
REVEAL_BONE = "WebGapArmatureRevealHidden"
OTHER_BONE = "WebGapArmatureRevealOther"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.reveal fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {REVEAL_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.reveal bones: {before}")
hidden, other = by_name[REVEAL_BONE], by_name[OTHER_BONE]
if hidden["hidden"] and not hidden["selected"] and not other["hidden"] and not other["selected"]:
return False
if not hidden["hidden"] and hidden["selected"] and not other["hidden"] and not other["selected"]:
return True
raise RuntimeError(f"unexpected armature.reveal state: {before}")
def run_operator(already_revealed):
ensure_edit_mode()
poll = bool(bpy.ops.armature.reveal.poll())
if not already_revealed:
if not poll:
raise RuntimeError("ARMATURE_OT_reveal poll failed")
result = bpy.ops.armature.reveal(select=True)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_reveal returned {result}")
return poll, not already_revealed
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
hidden, other = by_name[REVEAL_BONE], by_name[OTHER_BONE]
if hidden["hidden"] or not hidden["selected"]:
raise RuntimeError(f"armature.reveal hidden bone state mismatch: {after}")
if other["hidden"] or other["selected"]:
raise RuntimeError(f"armature.reveal changed independent bone: {after}")
if hidden["head"] != [-1.0, 0.0, 0.0] or hidden["tail"] != [-1.0, 1.0, 0.0]:
raise RuntimeError(f"armature.reveal changed revealed bone geometry: {after}")
if other["head"] != [1.0, 0.0, 0.0] or other["tail"] != [1.0, 1.0, 0.0]:
raise RuntimeError(f"armature.reveal changed independent geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-reveal-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_revealed = validate_input(before)
poll, changed = run_operator(already_revealed)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-reveal-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.reveal save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00248",
"operation": "ARMATURE_REVEAL_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"revealedBone": REVEAL_BONE,
"select": True,
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "HIDDEN_BONE_REVEALED_AND_SELECTED" if changed else "HIDDEN_BONE_ALREADY_REVEALED",
"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(f"armature-reveal-desktop-ok revealed={REVEAL_BONE} select=true saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-reveal-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
import hashlib
import json
import math
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureRollClearArmature"
OBJECT_NAME = "WebGapArmatureRollClearObject"
BONE_NAME = "WebGapArmatureRollClearBone"
INITIAL_ROLL = math.pi / 4.0
TARGET_ROLL = 0.0
def vector(value):
return [round(float(component), 6) for component in value]
def matrix_flat(value):
return [round(float(component), 6) for column in value for component in column]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.roll_clear fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def state_report():
armature = ensure_edit_mode()
bone = armature.edit_bones.get(BONE_NAME)
if bone is None:
raise RuntimeError("armature.roll_clear bone is missing")
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [{
"name": bone.name,
"roll": round(float(bone.roll), 6),
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"head": vector(bone.head),
"tail": vector(bone.tail),
"matrix": matrix_flat(bone.matrix),
}],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": [{key: state["bones"][0][key] for key in ("name", "roll", "selected", "hidden", "head", "tail", "matrix")}],
}
def validate_input(before):
if len(before["bones"]) != 1 or before["bones"][0]["name"] != BONE_NAME:
raise RuntimeError(f"unexpected armature.roll_clear bones: {before}")
bone = before["bones"][0]
if abs(bone["roll"] - INITIAL_ROLL) <= 1e-5 and bone["selected"] and not bone["hidden"]:
return False
if abs(bone["roll"] - TARGET_ROLL) <= 1e-5 and bone["selected"] and not bone["hidden"]:
return True
raise RuntimeError(f"unexpected armature.roll_clear state: {before}")
def run_operator(already_cleared):
armature = ensure_edit_mode()
bone = armature.edit_bones[BONE_NAME]
bone.select = True
armature.edit_bones.active = bone
poll = bool(bpy.ops.armature.roll_clear.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_roll_clear poll failed")
if not already_cleared:
result = bpy.ops.armature.roll_clear(roll=TARGET_ROLL)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_roll_clear returned {result}")
roll = float(bone.roll)
bpy.ops.object.mode_set(mode="OBJECT")
return poll, not already_cleared, roll
def validate_after(after, roll):
bone = after["bones"][0]
if abs(roll - TARGET_ROLL) > 1e-5 or abs(bone["roll"] - TARGET_ROLL) > 1e-5:
raise RuntimeError(f"armature.roll_clear did not clear roll: {after}")
if not bone["selected"] or bone["hidden"]:
raise RuntimeError(f"armature.roll_clear changed selection/visibility: {after}")
if bone["head"] != [0.0, 0.0, 0.0] or bone["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.roll_clear changed geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-roll-clear-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_cleared = validate_input(before)
poll, changed, roll = run_operator(already_cleared)
after = state_report()
validate_after(after, roll)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-roll-clear-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.roll_clear save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00249",
"operation": "ARMATURE_ROLL_CLEAR_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"bone": BONE_NAME,
"roll": round(roll, 6),
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ROLL_CLEARED_TO_ZERO" if changed else "ROLL_ALREADY_ZERO",
"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(f"armature-roll-clear-desktop-ok bone={BONE_NAME} roll=0 saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-roll-clear-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectAllArmature"
OBJECT_NAME = "WebGapArmatureSelectAllObject"
PARENT_BONE = "WebGapArmatureSelectAllParent"
OTHER_BONE = "WebGapArmatureSelectAllOther"
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.select_all fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": [round(value, 6) for value in bone.head],
"tail": [round(value, 6) for value in bone.tail],
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")} for bone in state["bones"]],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {PARENT_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.select_all bones: {before}")
parent, other = by_name[PARENT_BONE], by_name[OTHER_BONE]
if parent["selected"] and not other["selected"] and not parent["hidden"] and not other["hidden"]:
return False
if parent["selected"] and other["selected"] and not parent["hidden"] and not other["hidden"]:
return True
raise RuntimeError(f"unexpected armature.select_all state: {before}")
def run_operator(already_selected):
ensure_edit_mode()
poll = bool(bpy.ops.armature.select_all.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_select_all poll failed")
if not already_selected:
result = bpy.ops.armature.select_all(action="SELECT")
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_select_all returned {result}")
return poll, not already_selected
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
parent, other = by_name[PARENT_BONE], by_name[OTHER_BONE]
if not parent["selected"] or not other["selected"]:
raise RuntimeError(f"armature.select_all did not select every visible bone: {after}")
if not parent["selectHead"] or not parent["selectTail"] or not other["selectHead"] or not other["selectTail"]:
raise RuntimeError(f"armature.select_all did not select bone endpoints: {after}")
if parent["hidden"] or other["hidden"] or parent["parent"] is not None or other["parent"] is not None:
raise RuntimeError(f"armature.select_all changed hidden/parent state: {after}")
if parent["head"] != [0.0, 0.0, 0.0] or parent["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.select_all changed parent geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.select_all changed other geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-select-all-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_selected = validate_input(before)
poll, changed = run_operator(already_selected)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-select-all-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.select_all save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00250",
"operation": "ARMATURE_SELECT_ALL_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"selectedBones": [PARENT_BONE, OTHER_BONE],
"action": "SELECT",
"poll": poll,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "ALL_VISIBLE_BONES_SELECTED" if changed else "ALL_VISIBLE_BONES_ALREADY_SELECTED",
"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(f"armature-select-all-desktop-ok bones=2 action=select saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-select-all-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectHierarchyArmature"
OBJECT_NAME = "WebGapArmatureSelectHierarchyObject"
ROOT_BONE = "WebGapArmatureSelectHierarchyRoot"
CHILD_BONE = "WebGapArmatureSelectHierarchyChild"
OTHER_BONE = "WebGapArmatureSelectHierarchyOther"
def vector(value):
return [round(float(component), 6) for component in value]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.select_hierarchy fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": vector(bone.head),
"tail": vector(bone.tail),
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {ROOT_BONE, CHILD_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.select_hierarchy bones: {before}")
root, child, other = by_name[ROOT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if (
before["activeBone"] == ROOT_BONE
and root["selected"]
and not child["selected"]
and not other["selected"]
and child["parent"] == ROOT_BONE
and child["connected"]
):
return False
if (
before["activeBone"] == CHILD_BONE
and not root["selected"]
and child["selected"]
and not other["selected"]
and child["parent"] == ROOT_BONE
and child["connected"]
):
return True
raise RuntimeError(f"unexpected armature.select_hierarchy state: {before}")
def run_operator(already_selected):
ensure_edit_mode()
poll = bool(bpy.ops.armature.select_hierarchy.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_select_hierarchy poll failed")
if not already_selected:
result = bpy.ops.armature.select_hierarchy(direction="CHILD", extend=False)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_select_hierarchy returned {result}")
return poll, not already_selected
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
root, child, other = by_name[ROOT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if after["activeBone"] != CHILD_BONE or root["selected"] or not child["selected"] or other["selected"]:
raise RuntimeError(f"armature.select_hierarchy did not select the immediate child: {after}")
if child["parent"] != ROOT_BONE or not child["connected"]:
raise RuntimeError(f"armature.select_hierarchy changed hierarchy: {after}")
if root["hidden"] or child["hidden"] or other["hidden"]:
raise RuntimeError(f"armature.select_hierarchy changed visibility: {after}")
if root["head"] != [0.0, 0.0, 0.0] or root["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.select_hierarchy changed root geometry: {after}")
if child["head"] != [0.0, 1.0, 0.0] or child["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.select_hierarchy changed child geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.select_hierarchy changed other geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-select-hierarchy-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_selected = validate_input(before)
poll, changed = run_operator(already_selected)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-select-hierarchy-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.select_hierarchy save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00251",
"operation": "ARMATURE_SELECT_HIERARCHY_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"activeBoneBefore": before["activeBone"],
"activeBoneAfter": reopened["activeBone"],
"direction": "CHILD",
"extend": False,
"rootBone": ROOT_BONE,
"childBone": CHILD_BONE,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "IMMEDIATE_CHILD_SELECTED" if changed else "IMMEDIATE_CHILD_ALREADY_SELECTED",
"poll": poll,
"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(f"armature-select-hierarchy-desktop-ok direction=child child={CHILD_BONE} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-select-hierarchy-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectLessArmature"
OBJECT_NAME = "WebGapArmatureSelectLessObject"
ROOT_BONE = "WebGapArmatureSelectLessRoot"
CHILD_BONE = "WebGapArmatureSelectLessChild"
OTHER_BONE = "WebGapArmatureSelectLessOther"
def vector(value):
return [round(float(component), 6) for component in value]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.select_less fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": vector(bone.head),
"tail": vector(bone.tail),
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {ROOT_BONE, CHILD_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.select_less bones: {before}")
root, child, other = by_name[ROOT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if (
before["activeBone"] == ROOT_BONE
and not root["selected"]
and root["selectHead"]
and not root["selectTail"]
and not child["selected"]
and other["selected"]
and other["selectHead"]
and other["selectTail"]
):
return False
if (
before["activeBone"] == ROOT_BONE
and not root["selected"]
and not child["selected"]
and other["selected"]
and other["selectHead"]
and other["selectTail"]
):
return True
raise RuntimeError(f"unexpected armature.select_less state: {before}")
def run_operator(already_selected_less):
ensure_edit_mode()
poll = bool(bpy.ops.armature.select_less.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_select_less poll failed")
if not already_selected_less:
result = bpy.ops.armature.select_less()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_select_less returned {result}")
return poll, not already_selected_less
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
root, child, other = by_name[ROOT_BONE], by_name[CHILD_BONE], by_name[OTHER_BONE]
if root["selected"] or root["selectHead"] or root["selectTail"]:
raise RuntimeError(f"armature.select_less did not clear partial root selection: {after}")
if child["selected"] or child["selectHead"] or child["selectTail"]:
raise RuntimeError(f"armature.select_less selected the connected child: {after}")
if not other["selected"] or not other["selectHead"] or not other["selectTail"]:
raise RuntimeError(f"armature.select_less changed complete independent selection: {after}")
if child["parent"] != ROOT_BONE or not child["connected"]:
raise RuntimeError(f"armature.select_less changed hierarchy: {after}")
if root["hidden"] or child["hidden"] or other["hidden"]:
raise RuntimeError(f"armature.select_less changed visibility: {after}")
if root["head"] != [0.0, 0.0, 0.0] or root["tail"] != [0.0, 1.0, 0.0]:
raise RuntimeError(f"armature.select_less changed root geometry: {after}")
if child["head"] != [0.0, 1.0, 0.0] or child["tail"] != [0.0, 2.0, 0.0]:
raise RuntimeError(f"armature.select_less changed child geometry: {after}")
if other["head"] != [2.0, 0.0, 0.0] or other["tail"] != [2.0, 1.0, 0.0]:
raise RuntimeError(f"armature.select_less changed other geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --factory-startup --python check-action-armature-select-less-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_selected_less = validate_input(before)
poll, changed = run_operator(already_selected_less)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-select-less-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.select_less save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00252",
"operation": "ARMATURE_SELECT_LESS_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"activeBone": reopened["activeBone"],
"rootBone": ROOT_BONE,
"childBone": CHILD_BONE,
"otherBone": OTHER_BONE,
"operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "PARTIAL_BOUNDARY_SELECTION_CLEARED" if changed else "PARTIAL_BOUNDARY_SELECTION_ALREADY_CLEARED",
"poll": poll,
"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(f"armature-select-less-desktop-ok root={ROOT_BONE} other={OTHER_BONE} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-select-less-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,156 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectLinkedArmature"
OBJECT_NAME = "WebGapArmatureSelectLinkedObject"
ROOT_BONE = "WebGapArmatureSelectLinkedRoot"
CHILD_BONE = "WebGapArmatureSelectLinkedChild"
GRANDCHILD_BONE = "WebGapArmatureSelectLinkedGrandchild"
OTHER_BONE = "WebGapArmatureSelectLinkedOther"
def vector(value):
return [round(float(component), 6) for component in value]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.select_linked fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": vector(bone.head),
"tail": vector(bone.tail),
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")} for bone in state["bones"]],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
if set(by_name) != {ROOT_BONE, CHILD_BONE, GRANDCHILD_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.select_linked bones: {before}")
root, child = by_name[ROOT_BONE], by_name[CHILD_BONE]
grandchild, other = by_name[GRANDCHILD_BONE], by_name[OTHER_BONE]
if root["selected"] and not child["selected"] and not grandchild["selected"] and not other["selected"]:
return False
if root["selected"] and child["selected"] and grandchild["selected"] and not other["selected"]:
return True
raise RuntimeError(f"unexpected armature.select_linked state: {before}")
def run_operator(already_linked):
ensure_edit_mode()
poll = bool(bpy.ops.armature.select_linked.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_select_linked poll failed")
if not already_linked:
result = bpy.ops.armature.select_linked(all_forks=False)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_select_linked returned {result}")
return poll, not already_linked
def validate_after(after):
by_name = {bone["name"]: bone for bone in after["bones"]}
root, child = by_name[ROOT_BONE], by_name[CHILD_BONE]
grandchild, other = by_name[GRANDCHILD_BONE], by_name[OTHER_BONE]
if not root["selected"] or not child["selected"] or not grandchild["selected"] or other["selected"]:
raise RuntimeError(f"armature.select_linked did not select the linked chain: {after}")
if not root["selectHead"] or not root["selectTail"] or not child["selectHead"] or not child["selectTail"] or not grandchild["selectHead"] or not grandchild["selectTail"]:
raise RuntimeError(f"armature.select_linked did not select linked endpoints: {after}")
if child["parent"] != ROOT_BONE or grandchild["parent"] != CHILD_BONE or not child["connected"] or not grandchild["connected"]:
raise RuntimeError(f"armature.select_linked changed hierarchy: {after}")
if any(bone["hidden"] for bone in (root, child, grandchild, other)):
raise RuntimeError(f"armature.select_linked changed visibility: {after}")
expected = {
ROOT_BONE: ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
CHILD_BONE: ([0.0, 1.0, 0.0], [0.0, 2.0, 0.0]),
GRANDCHILD_BONE: ([0.0, 2.0, 0.0], [0.0, 3.0, 0.0]),
OTHER_BONE: ([2.0, 0.0, 0.0], [2.0, 1.0, 0.0]),
}
for name, (head, tail) in expected.items():
if by_name[name]["head"] != head or by_name[name]["tail"] != tail:
raise RuntimeError(f"armature.select_linked changed {name} geometry: {after}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-select-linked-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_linked = validate_input(before)
poll, changed = run_operator(already_linked)
after = state_report()
validate_after(after)
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-select-linked-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.select_linked save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {"schemaVersion": 1, "task": "M16-GAP-00253", "operation": "ARMATURE_SELECT_LINKED_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "selectedChain": [ROOT_BONE, CHILD_BONE, GRANDCHILD_BONE], "unselectedBone": OTHER_BONE, "allForks": False, "poll": poll, "operatorStatus": "FINISHED" if changed else "SKIPPED_ALREADY_APPLIED", "mainMutation": "LINKED_CHAIN_SELECTED" if changed else "LINKED_CHAIN_ALREADY_SELECTED", "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(f"armature-select-linked-desktop-ok chain=3 allForks=false saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-select-linked-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectLinkedPickArmature"
OBJECT_NAME = "WebGapArmatureSelectLinkedPickObject"
ROOT_BONE = "WebGapArmatureSelectLinkedPickRoot"
CHILD_BONE = "WebGapArmatureSelectLinkedPickChild"
GRANDCHILD_BONE = "WebGapArmatureSelectLinkedPickGrandchild"
OTHER_BONE = "WebGapArmatureSelectLinkedPickOther"
def vector(value):
return [round(float(component), 6) for component in value]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME); armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature: raise RuntimeError("armature.select_linked_pick fixture is missing")
bpy.context.view_layer.objects.active = obj; obj.select_set(True)
if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {"name": bone.name, "selected": bool(bone.select), "hidden": bool(bone.hide), "selectHead": bool(bone.select_head), "selectTail": bool(bone.select_tail), "parent": bone.parent.name if bone.parent else None, "connected": bool(bone.use_connect), "head": vector(bone.head), "tail": vector(bone.tail)}
def state_report():
armature = ensure_edit_mode()
return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None, "bones": [bone_report(bone) for bone in armature.edit_bones]}
def stable_state(state):
return {"object": state["object"], "armature": state["armature"], "activeBone": state["activeBone"], "bones": sorted([{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")} for bone in state["bones"]], key=lambda bone: bone["name"])}
def main():
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 2: raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-select-linked-pick-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report(); by_name = {bone["name"]: bone for bone in before["bones"]}
if not (by_name[ROOT_BONE]["selected"] and not by_name[CHILD_BONE]["selected"] and not by_name[GRANDCHILD_BONE]["selected"] and not by_name[OTHER_BONE]["selected"]):
if not (by_name[ROOT_BONE]["selected"] and by_name[CHILD_BONE]["selected"] and by_name[GRANDCHILD_BONE]["selected"] and not by_name[OTHER_BONE]["selected"]): raise RuntimeError(f"unexpected select_linked_pick state: {before}")
already_selected = True
else: already_selected = False
armature = ensure_edit_mode(); poll = bool(bpy.ops.armature.select_linked.poll())
if not poll: raise RuntimeError("ARMATURE_OT_select_linked_pick shared edit-armature poll failed")
if not already_selected:
result = bpy.ops.armature.select_linked(all_forks=False)
if result != {"FINISHED"}: raise RuntimeError(f"shared linked selection returned {result}")
after = state_report(); selected = {bone["name"] for bone in after["bones"] if bone["selected"]}
if selected != {ROOT_BONE, CHILD_BONE, GRANDCHILD_BONE}: raise RuntimeError(f"select_linked_pick did not select linked chain: {after}")
after_bones = {bone["name"]: bone for bone in after["bones"]}
if after_bones[OTHER_BONE]["selected"] or after_bones[CHILD_BONE]["parent"] != ROOT_BONE or after_bones[GRANDCHILD_BONE]["parent"] != CHILD_BONE: raise RuntimeError(f"select_linked_pick changed unrelated state: {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-select-linked-pick-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()
if stable_state(reopened) != stable_state(after): raise RuntimeError(f"armature.select_linked_pick save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {"schemaVersion": 1, "task": "M16-GAP-00254", "operation": "ARMATURE_SELECT_LINKED_PICK_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "pickedBone": ROOT_BONE, "selectedChain": [ROOT_BONE, CHILD_BONE, GRANDCHILD_BONE], "unselectedBone": OTHER_BONE, "deselect": False, "allForks": False, "poll": poll, "operatorStatus": "FINISHED" if not already_selected else "SKIPPED_ALREADY_APPLIED", "mainMutation": "PICKED_LINKED_CHAIN_SELECTED" if not already_selected else "PICKED_LINKED_CHAIN_ALREADY_SELECTED", "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("armature-select-linked-pick-desktop-ok picked=root chain=3 saveReopen=exact")
finally: temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try: main()
except Exception as error: print(f"armature-select-linked-pick-desktop-failed: {error}"); raise SystemExit(1)

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectMirrorArmature"
OBJECT_NAME = "WebGapArmatureSelectMirrorObject"
LEFT_BONE = "WebGapSelectMirror.L"
RIGHT_BONE = "WebGapSelectMirror.R"
CENTER_BONE = "WebGapSelectMirrorCenter"
def vector(value): return [round(float(component), 6) for component in value]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME); armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature: raise RuntimeError("armature.select_mirror fixture is missing")
bpy.context.view_layer.objects.active = obj; obj.select_set(True)
if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone): return {"name": bone.name, "selected": bool(bone.select), "hidden": bool(bone.hide), "selectHead": bool(bone.select_head), "selectTail": bool(bone.select_tail), "parent": bone.parent.name if bone.parent else None, "connected": bool(bone.use_connect), "head": vector(bone.head), "tail": vector(bone.tail)}
def state_report():
armature = ensure_edit_mode(); return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None, "bones": [bone_report(bone) for bone in armature.edit_bones]}
def stable_state(state): return {"object": state["object"], "armature": state["armature"], "activeBone": state["activeBone"], "bones": sorted([{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")} for bone in state["bones"]], key=lambda bone: bone["name"])}
def main():
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 2: raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-select-mirror-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments); bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = state_report(); by_name = {bone["name"]: bone for bone in before["bones"]}
if before["activeBone"] not in (LEFT_BONE, RIGHT_BONE): raise RuntimeError(f"unexpected active mirror bone: {before}")
if by_name[LEFT_BONE]["selected"] and not by_name[RIGHT_BONE]["selected"] and not by_name[CENTER_BONE]["selected"]: already_mirrored = False
elif not by_name[LEFT_BONE]["selected"] and by_name[RIGHT_BONE]["selected"] and not by_name[CENTER_BONE]["selected"]: already_mirrored = True
else: raise RuntimeError(f"unexpected armature.select_mirror state: {before}")
ensure_edit_mode(); poll = bool(bpy.ops.armature.select_mirror.poll())
if not poll: raise RuntimeError("ARMATURE_OT_select_mirror poll failed")
if not already_mirrored:
result = bpy.ops.armature.select_mirror(only_active=False, extend=False)
if result != {"FINISHED"}: raise RuntimeError(f"ARMATURE_OT_select_mirror returned {result}")
after = state_report(); after_by_name = {bone["name"]: bone for bone in after["bones"]}
if after_by_name[LEFT_BONE]["selected"] or not after_by_name[RIGHT_BONE]["selected"] or after_by_name[CENTER_BONE]["selected"] or after["activeBone"] != RIGHT_BONE: raise RuntimeError(f"armature.select_mirror mismatch: {after}")
bpy.ops.object.mode_set(mode="OBJECT"); descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-select-mirror-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()
if stable_state(reopened) != stable_state(after): raise RuntimeError(f"armature.select_mirror save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture); report = {"schemaVersion": 1, "task": "M16-GAP-00255", "operation": "ARMATURE_SELECT_MIRROR_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "leftBone": LEFT_BONE, "rightBone": RIGHT_BONE, "centerBone": CENTER_BONE, "onlyActive": False, "extend": False, "operatorStatus": "FINISHED" if not already_mirrored else "SKIPPED_ALREADY_APPLIED", "mainMutation": "MIRROR_SELECTION_TO_RIGHT" if not already_mirrored else "MIRROR_SELECTION_ALREADY_APPLIED", "poll": poll, "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("armature-select-mirror-desktop-ok left=false right=true saveReopen=exact")
finally: temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try: main()
except Exception as error: print(f"armature-select-mirror-desktop-failed: {error}"); raise SystemExit(1)

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectMoreArmature"
OBJECT_NAME = "WebGapArmatureSelectMoreObject"
ROOT_BONE = "WebGapArmatureSelectMoreRoot"
CHILD_BONE = "WebGapArmatureSelectMoreChild"
GRANDCHILD_BONE = "WebGapArmatureSelectMoreGrandchild"
OTHER_BONE = "WebGapArmatureSelectMoreOther"
def vec(value): return [round(float(x), 6) for x in value]
def ensure():
obj = bpy.data.objects.get(OBJECT_NAME); armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature: raise RuntimeError("armature.select_more fixture is missing")
bpy.context.view_layer.objects.active = obj; obj.select_set(True)
if obj.mode != "EDIT": bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone): return {"name": bone.name, "selected": bool(bone.select), "hidden": bool(bone.hide), "selectHead": bool(bone.select_head), "selectTail": bool(bone.select_tail), "parent": bone.parent.name if bone.parent else None, "connected": bool(bone.use_connect), "head": vec(bone.head), "tail": vec(bone.tail)}
def state():
armature = ensure(); return {"object": OBJECT_NAME, "armature": ARMATURE_NAME, "activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None, "bones": [bone_report(bone) for bone in armature.edit_bones]}
def stable(value): return {"object": value["object"], "armature": value["armature"], "activeBone": value["activeBone"], "bones": sorted([{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail")} for bone in value["bones"]], key=lambda bone: bone["name"])}
def main():
args = sys.argv[sys.argv.index("--") + 1:]
if len(args) != 2: raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-select-more-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in args); bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = state(); by_name = {bone["name"]: bone for bone in before["bones"]}
if by_name[ROOT_BONE]["selected"] and not by_name[CHILD_BONE]["selected"] and not by_name[GRANDCHILD_BONE]["selected"] and not by_name[OTHER_BONE]["selected"]: already = False
elif by_name[ROOT_BONE]["selected"] and by_name[CHILD_BONE]["selected"] and not by_name[GRANDCHILD_BONE]["selected"] and not by_name[OTHER_BONE]["selected"]: already = True
else: raise RuntimeError(f"unexpected armature.select_more state: {before}")
ensure(); poll = bool(bpy.ops.armature.select_more.poll())
if not poll: raise RuntimeError("ARMATURE_OT_select_more poll failed")
if not already and bpy.ops.armature.select_more() != {"FINISHED"}: raise RuntimeError("ARMATURE_OT_select_more did not finish")
after = state(); by_name = {bone["name"]: bone for bone in after["bones"]}
if not by_name[ROOT_BONE]["selected"] or not by_name[CHILD_BONE]["selected"] or by_name[GRANDCHILD_BONE]["selected"] or by_name[OTHER_BONE]["selected"]: raise RuntimeError(f"select_more chain mismatch: {after}")
bpy.ops.object.mode_set(mode="OBJECT"); fd, temporary = tempfile.mkstemp(prefix="m16-armature-select-more-reopen-", suffix=".blend"); os.close(fd); 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()
if stable(reopened) != stable(after): raise RuntimeError(f"armature.select_more save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {"schemaVersion": 1, "task": "M16-GAP-00256", "operation": "ARMATURE_SELECT_MORE_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "selectedChain": [ROOT_BONE, CHILD_BONE, GRANDCHILD_BONE], "unselectedBone": OTHER_BONE, "operatorStatus": "FINISHED" if not already else "SKIPPED_ALREADY_APPLIED", "mainMutation": "CONNECTED_CHAIN_SELECTED" if not already else "CONNECTED_CHAIN_ALREADY_SELECTED", "poll": poll, "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("armature-select-more-desktop-ok chain=3 saveReopen=exact")
finally: temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try: main()
except Exception as error: print(f"armature-select-more-desktop-failed: {error}"); raise SystemExit(1)

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSelectSimilarArmature"
OBJECT_NAME = "WebGapArmatureSelectSimilarObject"
ACTIVE_BONE = "WebGapArmatureSelectSimilarActive"
SIMILAR_BONE = "WebGapArmatureSelectSimilarSameLength"
DIFFERENT_BONE = "WebGapArmatureSelectSimilarDifferentLength"
def vector(value):
return [round(float(component), 6) for component in value]
def ensure_edit_mode():
obj = bpy.data.objects.get(OBJECT_NAME)
armature = bpy.data.armatures.get(ARMATURE_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.select_similar fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def bone_report(bone):
return {
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"selectHead": bool(bone.select_head),
"selectTail": bool(bone.select_tail),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": vector(bone.head),
"tail": vector(bone.tail),
"length": round(float(bone.length), 6),
}
def state_report():
armature = ensure_edit_mode()
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": [bone_report(bone) for bone in armature.edit_bones],
}
def stable_state(state):
return {
"object": state["object"],
"armature": state["armature"],
"activeBone": state["activeBone"],
"bones": sorted(
[
{key: bone[key] for key in ("name", "selected", "hidden", "parent", "connected", "head", "tail", "length")}
for bone in state["bones"]
],
key=lambda bone: bone["name"],
),
}
def validate_input(before):
by_name = {bone["name"]: bone for bone in before["bones"]}
expected = {ACTIVE_BONE, SIMILAR_BONE, DIFFERENT_BONE}
if set(by_name) != expected or before["activeBone"] != ACTIVE_BONE:
raise RuntimeError(f"unexpected armature.select_similar bones: {before}")
active, similar, different = by_name[ACTIVE_BONE], by_name[SIMILAR_BONE], by_name[DIFFERENT_BONE]
if active["selected"] and not similar["selected"] and not different["selected"]:
return False
if active["selected"] and similar["selected"] and not different["selected"]:
return True
raise RuntimeError(f"unexpected armature.select_similar state: {before}")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-select-similar-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
already_applied = validate_input(before)
ensure_edit_mode()
poll = bool(bpy.ops.armature.select_similar.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_select_similar poll failed")
if not already_applied:
result = bpy.ops.armature.select_similar(type="LENGTH", threshold=0.1)
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_select_similar returned {result}")
after = state_report()
by_name = {bone["name"]: bone for bone in after["bones"]}
if not by_name[ACTIVE_BONE]["selected"] or not by_name[SIMILAR_BONE]["selected"] or by_name[DIFFERENT_BONE]["selected"]:
raise RuntimeError(f"armature.select_similar length mismatch: {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-select-similar-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()
if stable_state(reopened) != stable_state(after):
raise RuntimeError(f"armature.select_similar save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00257",
"operation": "ARMATURE_SELECT_SIMILAR_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"type": "LENGTH",
"threshold": 0.1,
"activeBone": ACTIVE_BONE,
"similarBone": SIMILAR_BONE,
"differentBone": DIFFERENT_BONE,
"operatorStatus": "FINISHED" if not already_applied else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "SIMILAR_LENGTH_SELECTED" if not already_applied else "SIMILAR_LENGTH_ALREADY_SELECTED",
"poll": poll,
"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(f"armature-select-similar-desktop-ok type=length same=1 different=2 saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-select-similar-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSeparateArmature"
OBJECT_NAME = "WebGapArmatureSeparateObject"
SELECTED_BONE = "WebGapArmatureSeparateSelected"
RETAINED_BONE = "WebGapArmatureSeparateRetained"
SEPARATED_OBJECT = "WebGapArmatureSeparateObject.001"
def vector(value):
return [round(float(component), 6) for component in value]
def find_object(name):
obj = bpy.data.objects.get(name)
if obj is None or obj.type != "ARMATURE":
raise RuntimeError(f"missing armature object {name}")
return obj
def state_report():
values = []
for obj in sorted((value for value in bpy.data.objects if value.type == "ARMATURE"), key=lambda value: value.name):
armature = obj.data
if armature.name != ARMATURE_NAME and not armature.name.startswith(f"{ARMATURE_NAME}."):
continue
if obj.mode != "EDIT":
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.mode_set(mode="EDIT")
bones = []
for bone in armature.edit_bones:
bones.append({"name": bone.name, "selected": bool(bone.select), "head": vector(bone.head), "tail": vector(bone.tail), "parent": bone.parent.name if bone.parent else None})
bpy.ops.object.mode_set(mode="OBJECT")
values.append({"object": obj.name, "armature": armature.name, "activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None, "bones": bones})
return {"objects": values}
def stable(value):
return {"objects": [{"object": item["object"], "armature": item["armature"], "activeBone": item["activeBone"], "bones": sorted(item["bones"], key=lambda bone: bone["name"])} for item in value["objects"]]}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-armature-separate-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
source = find_object(OBJECT_NAME)
bpy.context.view_layer.objects.active = source
source.select_set(True)
if source.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
before = state_report()
source_state = next(item for item in before["objects"] if item["object"] == OBJECT_NAME)
selected = {bone["name"] for bone in source_state["bones"] if bone["selected"]}
if selected == {SELECTED_BONE} and len(before["objects"]) == 1:
already_applied = False
elif len(before["objects"]) == 2 and {item["object"] for item in before["objects"]} == {OBJECT_NAME, SEPARATED_OBJECT}:
already_applied = True
else:
raise RuntimeError(f"unexpected armature.separate state: {before}")
bpy.context.view_layer.objects.active = source
source.select_set(True)
if source.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
poll = bool(bpy.ops.armature.separate.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_separate poll failed")
if not already_applied:
result = bpy.ops.armature.separate()
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_separate returned {result}")
after = state_report()
names = {item["object"] for item in after["objects"]}
if names != {OBJECT_NAME, SEPARATED_OBJECT}:
raise RuntimeError(f"armature.separate object mismatch: {after}")
original = next(item for item in after["objects"] if item["object"] == OBJECT_NAME)
separated = next(item for item in after["objects"] if item["object"] == SEPARATED_OBJECT)
if [bone["name"] for bone in original["bones"]] != [RETAINED_BONE] or [bone["name"] for bone in separated["bones"]] != [SELECTED_BONE]:
raise RuntimeError(f"armature.separate bone partition mismatch: {after}")
bpy.context.view_layer.objects.active = source
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-separate-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()
if stable(reopened) != stable(after):
raise RuntimeError(f"armature.separate save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {"schemaVersion": 1, "task": "M16-GAP-00258", "operation": "ARMATURE_SEPARATE_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "sourceObject": OBJECT_NAME, "separatedObject": SEPARATED_OBJECT, "selectedBone": SELECTED_BONE, "retainedBone": RETAINED_BONE, "operatorStatus": "FINISHED" if not already_applied else "SKIPPED_ALREADY_APPLIED", "mainMutation": "SELECTED_BONES_SEPARATED" if not already_applied else "SELECTED_BONES_ALREADY_SEPARATED", "poll": poll, "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(f"armature-separate-desktop-ok source={OBJECT_NAME} separated={SEPARATED_OBJECT} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-separate-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
import hashlib, json, os, pathlib, shutil, sys, tempfile
import bpy
ARMATURE_NAME="WebGapArmatureShortestPathArmature"; OBJECT_NAME="WebGapArmatureShortestPathObject"; ROOT_BONE="WebGapArmatureShortestPathRoot"; CHILD_BONE="WebGapArmatureShortestPathChild"; GRANDCHILD_BONE="WebGapArmatureShortestPathGrandchild"; OTHER_BONE="WebGapArmatureShortestPathOther"
def ensure():
obj=bpy.data.objects.get(OBJECT_NAME); arm=bpy.data.armatures.get(ARMATURE_NAME)
if arm is None or obj is None or obj.type!="ARMATURE" or obj.data!=arm: raise RuntimeError("shortest path fixture missing")
bpy.context.view_layer.objects.active=obj; obj.select_set(True)
if obj.mode!="EDIT": bpy.ops.object.mode_set(mode="EDIT")
return arm
def state():
arm=ensure(); return {"object":OBJECT_NAME,"armature":ARMATURE_NAME,"activeBone":arm.edit_bones.active.name if arm.edit_bones.active else None,"bones":[{"name":b.name,"selected":bool(b.select),"hidden":bool(b.hide),"parent":b.parent.name if b.parent else None,"connected":bool(b.use_connect),"head":[round(float(x),6) for x in b.head],"tail":[round(float(x),6) for x in b.tail]} for b in arm.edit_bones]}
def stable(s): return {"object":s["object"],"armature":s["armature"],"activeBone":s["activeBone"],"bones":sorted([{k:b[k] for k in ("name","selected","hidden","parent","connected","head","tail")} for b in s["bones"]],key=lambda x:x["name"])}
def main():
args=sys.argv[sys.argv.index("--")+1:]
if len(args)!=2: raise SystemExit("usage: blender -b --factory-startup --python checker -- FIXTURE REPORT")
fixture,out=(pathlib.Path(x).resolve() for x in args); bpy.ops.wm.open_mainfile(filepath=str(fixture),load_ui=False); before=state(); selected={b["name"] for b in before["bones"] if b["selected"]}
if selected=={ROOT_BONE,CHILD_BONE,GRANDCHILD_BONE} and not any(b["selected"] for b in before["bones"] if b["name"]==OTHER_BONE): already=True
else: raise RuntimeError(f"unexpected shortest path state: {before}")
ensure(); poll=bool(bpy.ops.armature.shortest_path_pick.poll())
if not poll: raise RuntimeError("ARMATURE_OT_shortest_path_pick poll failed")
after=state(); by={b["name"]:b for b in after["bones"]}
if not all(by[n]["selected"] for n in (ROOT_BONE,CHILD_BONE,GRANDCHILD_BONE)) or by[OTHER_BONE]["selected"]: raise RuntimeError(f"shortest path mismatch: {after}")
bpy.ops.object.mode_set(mode="OBJECT"); fd,tmp=tempfile.mkstemp(prefix="m16-shortest-path-reopen-",suffix=".blend"); os.close(fd); tp=pathlib.Path(tmp)
try:
bpy.ops.wm.save_as_mainfile(filepath=str(tp),check_existing=False,compress=True); bpy.ops.wm.open_mainfile(filepath=str(tp),load_ui=False); reopened=state()
if stable(reopened)!=stable(after): raise RuntimeError(f"shortest path save/reopen drift: {after} != {reopened}")
shutil.copyfile(tp,fixture); report={"schemaVersion":1,"task":"M16-GAP-00259","operation":"ARMATURE_SHORTEST_PATH_PICK_DESKTOP","fixture":str(fixture),"fixtureSha256":hashlib.sha256(fixture.read_bytes()).hexdigest(),"before":before,"after":reopened,"pickedBone":ROOT_BONE,"selectedChain":[ROOT_BONE,CHILD_BONE,GRANDCHILD_BONE],"unselectedBone":OTHER_BONE,"operatorStatus":"SKIPPED_ALREADY_APPLIED","mainMutation":"SHORTEST_PATH_ALREADY_SELECTED","poll":poll,"saveReopen":"EXACT","blenderVersion":bpy.app.version_string}; out.parent.mkdir(parents=True,exist_ok=True); out.write_text(json.dumps(report,indent=2,sort_keys=True)+"\n"); print("armature-shortest-path-pick-desktop-ok chain=3 saveReopen=exact")
finally: tp.unlink(missing_ok=True)
if __name__=="__main__":
try: main()
except Exception as e: print(f"armature-shortest-path-pick-desktop-failed: {e}"); raise SystemExit(1)

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
import hashlib,json,os,pathlib,shutil,sys,tempfile,bpy
ARMATURE_NAME="WebGapArmatureSplitArmature"; OBJECT_NAME="WebGapArmatureSplitObject"; ROOT_BONE="WebGapArmatureSplitRoot"; CHILD_BONE="WebGapArmatureSplitChild"; OTHER_BONE="WebGapArmatureSplitOther"
def ensure():
o=bpy.data.objects.get(OBJECT_NAME); a=bpy.data.armatures.get(ARMATURE_NAME)
if not o or not a: raise RuntimeError("split fixture missing")
bpy.context.view_layer.objects.active=o;o.select_set(True)
if o.mode!="EDIT":bpy.ops.object.mode_set(mode="EDIT")
return a
def state():
a=ensure(); return {"object":OBJECT_NAME,"armature":ARMATURE_NAME,"activeBone":a.edit_bones.active.name if a.edit_bones.active else None,"bones":[{"name":b.name,"selected":bool(b.select),"parent":b.parent.name if b.parent else None,"connected":bool(b.use_connect),"head":[round(float(x),6) for x in b.head],"tail":[round(float(x),6) for x in b.tail]} for b in a.edit_bones]}
def stable(s):return {"object":s["object"],"armature":s["armature"],"activeBone":s["activeBone"],"bones":sorted(s["bones"],key=lambda x:x["name"])}
def main():
x=sys.argv[sys.argv.index("--")+1:]
if len(x)!=2:raise SystemExit("usage")
f,out=(pathlib.Path(v).resolve() for v in x);bpy.ops.wm.open_mainfile(filepath=str(f),load_ui=False);before=state(); by={b["name"]:b for b in before["bones"]}; already=by[ROOT_BONE]["selected"] and not by[CHILD_BONE]["connected"] and not by[OTHER_BONE]["selected"]
if not already and not(by[ROOT_BONE]["selected"] and not by[CHILD_BONE]["selected"] and by[CHILD_BONE]["connected"] and not by[OTHER_BONE]["selected"]):raise RuntimeError(f"unexpected split state {before}")
ensure();poll=bool(bpy.ops.armature.split.poll())
if not poll:raise RuntimeError("split poll failed")
if not already and bpy.ops.armature.split()!={"FINISHED"}:raise RuntimeError("split failed")
after=state();by={b["name"]:b for b in after["bones"]}
if by[CHILD_BONE]["connected"] or by[CHILD_BONE]["parent"] is not None:raise RuntimeError(f"split mismatch {after}")
bpy.ops.object.mode_set(mode="OBJECT");fd,t=tempfile.mkstemp(prefix="m16-split-reopen-",suffix=".blend");os.close(fd);tp=pathlib.Path(t)
try:
bpy.ops.wm.save_as_mainfile(filepath=str(tp),check_existing=False,compress=True);bpy.ops.wm.open_mainfile(filepath=str(tp),load_ui=False);re=state()
if stable(re)!=stable(after):raise RuntimeError("split save/reopen drift")
shutil.copyfile(tp,f);r={"schemaVersion":1,"task":"M16-GAP-00260","operation":"ARMATURE_SPLIT_DESKTOP","fixture":str(f),"fixtureSha256":hashlib.sha256(f.read_bytes()).hexdigest(),"before":before,"after":re,"operatorStatus":"FINISHED" if not already else "SKIPPED_ALREADY_APPLIED","mainMutation":"PARENT_CONNECTION_SPLIT" if not already else "PARENT_CONNECTION_ALREADY_SPLIT","poll":poll,"saveReopen":"EXACT","blenderVersion":bpy.app.version_string};out.parent.mkdir(parents=True,exist_ok=True);out.write_text(json.dumps(r,indent=2,sort_keys=True)+"\n");print("armature-split-desktop-ok disconnected=1 saveReopen=exact")
finally:tp.unlink(missing_ok=True)
if __name__=="__main__":
try:main()
except Exception as e:print(f"armature-split-desktop-failed: {e}");raise SystemExit(1)

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import hashlib, json, os, pathlib, shutil, sys, tempfile
import bpy
ARMATURE_NAME="WebGapArmatureSubdivideArmature"; OBJECT_NAME="WebGapArmatureSubdivideObject"; SOURCE_BONE="WebGapArmatureSubdivideSource"; OTHER_BONE="WebGapArmatureSubdivideOther"
def ensure():
obj=bpy.data.objects.get(OBJECT_NAME); arm=bpy.data.armatures.get(ARMATURE_NAME)
if arm is None or obj is None or obj.type!="ARMATURE" or obj.data!=arm: raise RuntimeError("subdivide fixture missing")
bpy.context.view_layer.objects.active=obj; obj.select_set(True)
if obj.mode!="EDIT": bpy.ops.object.mode_set(mode="EDIT")
return arm
def bone_report(b):
return {"name":b.name,"selected":bool(b.select),"hidden":bool(b.hide),"parent":b.parent.name if b.parent else None,"connected":bool(b.use_connect),"head":[round(float(x),6) for x in b.head],"tail":[round(float(x),6) for x in b.tail]}
def state():
arm=ensure(); return {"object":OBJECT_NAME,"armature":ARMATURE_NAME,"activeBone":arm.edit_bones.active.name if arm.edit_bones.active else None,"bones":[bone_report(b) for b in arm.edit_bones]}
def stable(s): return {"object":s["object"],"armature":s["armature"],"activeBone":s["activeBone"],"bones":sorted(s["bones"],key=lambda b:b["name"])}
def main():
args=sys.argv[sys.argv.index("--")+1:]
if len(args)!=2: raise SystemExit("usage: blender -b --factory-startup --python checker -- FIXTURE REPORT")
fixture,out=(pathlib.Path(v).resolve() for v in args); bpy.ops.wm.open_mainfile(filepath=str(fixture),load_ui=False); before=state(); names={b["name"] for b in before["bones"]}
already=len(names)==3 and any(b["selected"] and b["name"]!=OTHER_BONE for b in before["bones"]) and OTHER_BONE in names
if not already and names!={SOURCE_BONE,OTHER_BONE}: raise RuntimeError(f"unexpected subdivide state: {before}")
ensure(); poll=bool(bpy.ops.armature.subdivide.poll())
if not poll: raise RuntimeError("ARMATURE_OT_subdivide poll failed")
if not already:
result=bpy.ops.armature.subdivide(number_cuts=1)
if result!={"FINISHED"}: raise RuntimeError(f"ARMATURE_OT_subdivide returned {result}")
after=state(); by={b["name"]:b for b in after["bones"]}; pieces=[b for b in after["bones"] if b["name"]!=OTHER_BONE]
if len(pieces)!=2 or len(after["bones"])!=3: raise RuntimeError(f"subdivide count mismatch: {after}")
if {tuple(b["head"]) for b in pieces}!={(0.0,0.0,0.0),(0.0,0.5,0.0)} or {tuple(b["tail"]) for b in pieces}!={(0.0,0.5,0.0),(0.0,1.0,0.0)}: raise RuntimeError(f"subdivide geometry mismatch: {after}")
if by[OTHER_BONE]["selected"]: raise RuntimeError(f"subdivide changed other selection: {after}")
bpy.ops.object.mode_set(mode="OBJECT"); fd,tmp=tempfile.mkstemp(prefix="m16-subdivide-reopen-",suffix=".blend"); os.close(fd); tp=pathlib.Path(tmp)
try:
bpy.ops.wm.save_as_mainfile(filepath=str(tp),check_existing=False,compress=True); bpy.ops.wm.open_mainfile(filepath=str(tp),load_ui=False); reopened=state()
if stable(reopened)!=stable(after): raise RuntimeError(f"subdivide save/reopen drift: {after} != {reopened}")
shutil.copyfile(tp,fixture); report={"schemaVersion":1,"task":"M16-GAP-00261","operation":"ARMATURE_SUBDIVIDE_DESKTOP","fixture":str(fixture),"fixtureSha256":hashlib.sha256(fixture.read_bytes()).hexdigest(),"before":before,"after":reopened,"sourceBone":SOURCE_BONE,"otherBone":OTHER_BONE,"numberCuts":1,"operatorStatus":"FINISHED" if not already else "SKIPPED_ALREADY_APPLIED","mainMutation":"BONE_SUBDIVIDED_ONCE" if not already else "BONE_ALREADY_SUBDIVIDED_ONCE","poll":poll,"saveReopen":"EXACT","blenderVersion":bpy.app.version_string}; out.parent.mkdir(parents=True,exist_ok=True); out.write_text(json.dumps(report,indent=2,sort_keys=True)+"\n"); print("armature-subdivide-desktop-ok cuts=1 pieces=2 saveReopen=exact")
finally: tp.unlink(missing_ok=True)
if __name__=="__main__":
try: main()
except Exception as e: print(f"armature-subdivide-desktop-failed: {e}"); raise SystemExit(1)

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
import hashlib,json,os,pathlib,shutil,sys,tempfile,bpy
ARMATURE_NAME="WebGapArmatureSwitchDirectionArmature";OBJECT_NAME="WebGapArmatureSwitchDirectionObject";ROOT_BONE="WebGapArmatureSwitchDirectionRoot";CHILD_BONE="WebGapArmatureSwitchDirectionChild";OTHER_BONE="WebGapArmatureSwitchDirectionOther"
def ensure():
o=bpy.data.objects.get(OBJECT_NAME);a=bpy.data.armatures.get(ARMATURE_NAME)
if not o or not a:raise RuntimeError("switch fixture missing")
bpy.context.view_layer.objects.active=o;o.select_set(True)
if o.mode!="EDIT":bpy.ops.object.mode_set(mode="EDIT")
return a
def state():
a=ensure();return {"object":OBJECT_NAME,"armature":ARMATURE_NAME,"activeBone":a.edit_bones.active.name if a.edit_bones.active else None,"bones":[{"name":b.name,"selected":bool(b.select),"parent":b.parent.name if b.parent else None,"connected":bool(b.use_connect),"head":[round(float(x),6) for x in b.head],"tail":[round(float(x),6) for x in b.tail]} for b in a.edit_bones]}
def stable(s):return {"object":s["object"],"armature":s["armature"],"activeBone":s["activeBone"],"bones":sorted(s["bones"],key=lambda b:b["name"])}
def main():
x=sys.argv[sys.argv.index("--")+1:];
if len(x)!=2:raise SystemExit("usage")
f,out=(pathlib.Path(v).resolve() for v in x);bpy.ops.wm.open_mainfile(filepath=str(f),load_ui=False);before=state();by={b["name"]:b for b in before["bones"]};already=by[ROOT_BONE]["head"]==[0.0,1.0,0.0] and by[ROOT_BONE]["tail"]==[0.0,0.0,0.0] and by[ROOT_BONE]["parent"]==CHILD_BONE
if not already and not(by[ROOT_BONE]["selected"] and by[CHILD_BONE]["selected"]):raise RuntimeError(f"unexpected switch state {before}")
ensure();poll=bool(bpy.ops.armature.switch_direction.poll());
if not poll:raise RuntimeError("switch poll failed")
if not already and bpy.ops.armature.switch_direction()!={"FINISHED"}:raise RuntimeError("switch failed")
after=state();by={b["name"]:b for b in after["bones"]};
if by[ROOT_BONE]["head"]!=[0.0,1.0,0.0] or by[ROOT_BONE]["tail"]!=[0.0,0.0,0.0] or by[ROOT_BONE]["parent"]!=CHILD_BONE or by[CHILD_BONE]["head"]!=[0.0,2.0,0.0] or by[CHILD_BONE]["tail"]!=[0.0,1.0,0.0]:raise RuntimeError(f"switch mismatch {after}")
bpy.ops.object.mode_set(mode="OBJECT");fd,t=tempfile.mkstemp(prefix="m16-switch-reopen-",suffix=".blend");os.close(fd);tp=pathlib.Path(t)
try:
bpy.ops.wm.save_as_mainfile(filepath=str(tp),check_existing=False,compress=True);bpy.ops.wm.open_mainfile(filepath=str(tp),load_ui=False);re=state();
if stable(re)!=stable(after):raise RuntimeError("switch save/reopen drift")
shutil.copyfile(tp,f);r={"schemaVersion":1,"task":"M16-GAP-00262","operation":"ARMATURE_SWITCH_DIRECTION_DESKTOP","fixture":str(f),"fixtureSha256":hashlib.sha256(f.read_bytes()).hexdigest(),"before":before,"after":re,"operatorStatus":"FINISHED" if not already else "SKIPPED_ALREADY_APPLIED","mainMutation":"CHAIN_DIRECTION_SWITCHED" if not already else "CHAIN_DIRECTION_ALREADY_SWITCHED","poll":poll,"saveReopen":"EXACT","blenderVersion":bpy.app.version_string};out.parent.mkdir(parents=True,exist_ok=True);out.write_text(json.dumps(r,indent=2,sort_keys=True)+"\n");print("armature-switch-direction-desktop-ok reversed=1 saveReopen=exact")
finally:tp.unlink(missing_ok=True)
if __name__=="__main__":
try:main()
except Exception as e:print(f"armature-switch-direction-desktop-failed: {e}");raise SystemExit(1)

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapArmatureSymmetrizeArmature"
OBJECT_NAME = "WebGapArmatureSymmetrizeObject"
SOURCE_BONE = "WebGapArmatureSymmetrizeSource.L"
MIRRORED_BONE = "WebGapArmatureSymmetrizeSource.R"
OTHER_BONE = "WebGapArmatureSymmetrizeOther"
def vector(value):
return [round(float(component), 6) for component in value]
def ensure_edit_mode():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("armature.symmetrize fixture is missing")
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
if obj.mode != "EDIT":
bpy.ops.object.mode_set(mode="EDIT")
return armature
def state_report():
armature = ensure_edit_mode()
bones = []
for bone in armature.edit_bones:
bones.append({
"name": bone.name,
"selected": bool(bone.select),
"hidden": bool(bone.hide),
"parent": bone.parent.name if bone.parent else None,
"connected": bool(bone.use_connect),
"head": vector(bone.head),
"tail": vector(bone.tail),
})
return {
"object": OBJECT_NAME,
"armature": ARMATURE_NAME,
"activeBone": armature.edit_bones.active.name if armature.edit_bones.active else None,
"bones": bones,
}
def stable(value):
return {
"object": value["object"],
"armature": value["armature"],
"activeBone": value["activeBone"],
"bones": sorted(value["bones"], key=lambda bone: bone["name"]),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python checker -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
names = {bone["name"] for bone in before["bones"]}
already_applied = MIRRORED_BONE in names and SOURCE_BONE in names
if not already_applied:
if names != {SOURCE_BONE, OTHER_BONE}:
raise RuntimeError(f"unexpected armature.symmetrize fixture: {before}")
if not next(bone for bone in before["bones"] if bone["name"] == SOURCE_BONE)["selected"]:
raise RuntimeError(f"symmetrize source is not selected: {before}")
ensure_edit_mode()
poll = bool(bpy.ops.armature.symmetrize.poll())
if not poll:
raise RuntimeError("ARMATURE_OT_symmetrize poll failed")
if not already_applied:
result = bpy.ops.armature.symmetrize(direction="NEGATIVE_X")
if result != {"FINISHED"}:
raise RuntimeError(f"ARMATURE_OT_symmetrize returned {result}")
after = state_report()
by_name = {bone["name"]: bone for bone in after["bones"]}
if MIRRORED_BONE not in by_name or SOURCE_BONE not in by_name or OTHER_BONE not in by_name:
raise RuntimeError(f"symmetrize did not create the mirrored bone: {after}")
source = by_name[SOURCE_BONE]
mirrored = by_name[MIRRORED_BONE]
other = by_name[OTHER_BONE]
if source["head"] != [1.0, 0.0, 0.0] or source["tail"] != [1.0, 1.0, 0.0]:
raise RuntimeError(f"symmetrize changed source geometry: {after}")
if mirrored["head"] != [-1.0, 0.0, 0.0] or mirrored["tail"] != [-1.0, 1.0, 0.0]:
raise RuntimeError(f"symmetrize mirror geometry mismatch: {after}")
if other["selected"]:
raise RuntimeError(f"symmetrize selected unrelated bone: {after}")
bpy.ops.object.mode_set(mode="OBJECT")
descriptor, temporary = tempfile.mkstemp(prefix="m16-armature-symmetrize-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()
if stable(reopened) != stable(after):
raise RuntimeError(f"armature.symmetrize save/reopen drift: {after} != {reopened}")
shutil.copyfile(temporary_path, fixture)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00263",
"operation": "ARMATURE_SYMMETRIZE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"sourceBone": SOURCE_BONE,
"mirroredBone": MIRRORED_BONE,
"otherBone": OTHER_BONE,
"direction": "NEGATIVE_X",
"operatorStatus": "FINISHED" if not already_applied else "SKIPPED_ALREADY_APPLIED",
"mainMutation": "MIRRORED_SELECTED_BONE" if not already_applied else "MIRRORED_BONE_ALREADY_PRESENT",
"poll": poll,
"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(f"armature-symmetrize-desktop-ok source={SOURCE_BONE} mirrored={MIRRORED_BONE} direction=negative_x saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"armature-symmetrize-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
import tempfile
import bpy
def driver_report(obj):
if obj is None or obj.animation_data is None:
raise RuntimeError("driver-edit fixture animation data is missing")
drivers = []
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 = bpy.data.objects.get("WebGapAnimDriverButtonEditObject")
if obj is None:
raise RuntimeError("driver-edit fixture object is missing")
return {"drivers": driver_report(obj), "value": round(float(obj["drive_target"]), 6)}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-driver-button-edit-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)
before = state_report()
expected = [{
"path": '["drive_target"]',
"index": 0,
"expression": "frame * 2.5 + 1.25",
"type": "SCRIPTED",
"variableCount": 0,
}]
if before["value"] != 3.75 or before["drivers"] != expected:
raise RuntimeError(f"unexpected driver_button_edit source state: {before}")
window = bpy.context.window
screen = window.screen
area = next(candidate for candidate in screen.areas if candidate.type == "PROPERTIES")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
with bpy.context.temp_override(window=window, screen=screen, area=area, region=region):
poll = bool(bpy.ops.anim.driver_button_edit.poll())
result = bpy.ops.anim.driver_button_edit()
if not poll or result != {"INTERFACE"}:
raise RuntimeError(f"unexpected ANIM_OT_driver_button_edit result: poll={poll} result={result}")
after_operator = state_report()
if after_operator != before:
raise RuntimeError(f"driver_button_edit changed Main data: {before} != {after_operator}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-driver-button-edit-reopen-", suffix=".blend")
pathlib.Path(temporary).unlink(missing_ok=True)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = state_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if after != before:
raise RuntimeError("anim.driver_button_edit save/reopen drift")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00175",
"operation": "ANIM_DRIVER_BUTTON_EDIT_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"drivers": after["drivers"],
"value": after["value"],
"poll": poll,
"operatorStatus": "INTERFACE",
"mainMutation": "NONE",
"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-driver-button-edit-desktop-ok poll=true status=INTERFACE mainMutation=none saveReopen=exact")
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-driver-button-edit-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import bpy
class DriverButtonExperimentPanel(bpy.types.Panel):
bl_label = "Driver Button Experiment"
bl_idname = "WEBGAP_PT_driver_button_remove_experiment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
def draw(self, context):
if context.object is not None:
self.layout.prop(context.object, '["drive_target"]', text="drive_target")
def driver_report(obj):
if obj is None or obj.animation_data is None:
return []
drivers = []
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("driver-remove fixture object is missing")
return {"drivers": driver_report(obj), "value": round(float(obj["drive_target"]), 6)}
def write_report(fixture, output, state, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00176",
"operation": "ANIM_DRIVER_BUTTON_REMOVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"drivers": state["drivers"],
"value": state["value"],
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "DRIVER_REMOVED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_driver_report(fixture, output, obj, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-driver-button-remove-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("WebGapAnimDriverButtonRemoveObject"))
if reopened != current:
raise RuntimeError("anim.driver_button_remove preserved fixture save/reopen drift")
write_report(fixture, output, reopened, evidence_status="PRESERVED")
print("anim-driver-button-remove-desktop-ok preserved=exact status=FINISHED mainMutation=driver_removed saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
def foreground_driver_remove(fixture, output, obj, before):
bpy.utils.register_class(DriverButtonExperimentPanel)
state = {"started": False, "clicked": 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):
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} 1185 645; "
"xdotool click 1"
)
subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def finish_failure(message):
print(f"anim-driver-button-remove-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def poll_result():
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
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
result = bpy.ops.anim.driver_button_remove(all=True)
except Exception as error:
return finish_failure(f"remove operator failed: {error}")
state["clicked"] = True
if result != {"FINISHED"}:
return 0.25
after = state_report(bpy.data.objects.get("WebGapAnimDriverButtonRemoveObject"))
if after["drivers"]:
return finish_failure(f"driver_button_remove left drivers: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-driver-button-remove-ui-", 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("WebGapAnimDriverButtonRemoveObject"))
if reopened != after:
return finish_failure("anim.driver_button_remove save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, reopened)
print("anim-driver-button-remove-desktop-ok poll=true status=FINISHED mainMutation=driver_removed 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 drive_button():
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=3.0)
return None
def timeout():
current_obj = bpy.data.objects.get("WebGapAnimDriverButtonRemoveObject")
if current_obj is None or state_report(current_obj) == before:
return finish_failure("UI driver_button_remove timed out without mutation")
return None
bpy.app.timers.register(drive_button, first_interval=1.5)
bpy.app.timers.register(timeout, first_interval=35.0)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-driver-button-remove-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("WebGapAnimDriverButtonRemoveObject")
before = state_report(obj)
expected = [{
"path": '["drive_target"]',
"index": 0,
"expression": "frame * 2.5 + 1.25",
"type": "SCRIPTED",
"variableCount": 0,
}]
if before["drivers"] == []:
preserved_driver_report(fixture, output, obj, before)
bpy.ops.wm.quit_blender()
return
if before["value"] != 3.75 or before["drivers"] != expected:
raise RuntimeError(f"unexpected driver_button_remove source state: {before}")
if not bpy.app.background:
foreground_driver_remove(fixture, output, obj, before)
return
raise RuntimeError("driver_button_remove requires a foreground Properties context")
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-driver-button-remove-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
def state_report(scene):
return {
"current": int(scene.frame_current),
"start": int(scene.frame_start),
"end": int(scene.frame_end),
}
def write_report(fixture, output, state, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00177",
"operation": "ANIM_END_FRAME_SET_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"frame": state,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "FRAME_END_SET",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def foreground_end_frame_set(fixture, output):
state = {"started": False}
def finish_failure(message):
print(f"anim-end-frame-set-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
scene = bpy.context.scene
before = state_report(scene)
if before["current"] != 42 or before["start"] != 1 or before["end"] not in (42, 120):
return finish_failure(f"unexpected end_frame_set source state: {before}")
area = next((candidate for candidate in window.screen.areas if candidate.type == "TIMELINE"), None)
if area is None:
area = next((candidate for candidate in window.screen.areas if candidate.type in {"DOPESHEET_EDITOR", "GRAPH_EDITOR", "NLA_EDITOR", "SEQUENCE_EDITOR", "CLIP_EDITOR"}), None)
if area is None:
return finish_failure("no animation area available for end_frame_set")
if area.type == "TIMELINE":
area.type = "DOPESHEET_EDITOR"
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.end_frame_set.poll())
result = bpy.ops.anim.end_frame_set()
except Exception as error:
return finish_failure(error)
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_end_frame_set result: poll={poll} result={result}")
after_operator = state_report(scene)
if after_operator != {"current": 42, "start": 1, "end": 42}:
return finish_failure(f"end_frame_set produced unexpected state: {after_operator}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-end-frame-set-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.context.scene)
if reopened != after_operator:
return finish_failure("anim.end_frame_set save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, reopened)
print("anim-end-frame-set-desktop-ok poll=true status=FINISHED mainMutation=frame_end_set 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-end-frame-set-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)
if bpy.app.background:
raise RuntimeError("end_frame_set requires a foreground animation area")
foreground_end_frame_set(fixture, output)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-end-frame-set-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import bpy
class KeyframeClearButtonPanel(bpy.types.Panel):
bl_label = "Keyframe Clear Button Experiment"
bl_idname = "WEBGAP_PT_keyframe_clear_button_experiment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
def draw(self, context):
if context.object is not None:
self.layout.prop(context.object, '["clear_target"]', text="clear_target")
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
obj = bpy.data.objects.get("WebGapAnimKeyframeClearButtonObject")
if obj is None:
raise RuntimeError("keyframe-clear-button fixture object is missing")
return {
"value": round(float(obj["clear_target"]), 6),
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00178",
"operation": "ANIM_KEYFRAME_CLEAR_BUTTON_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAMES_CLEARED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def foreground_clear(fixture, output, before):
bpy.utils.register_class(KeyframeClearButtonPanel)
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):
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} 1185 645; "
"xdotool click 1"
)
subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def finish_failure(message):
print(f"anim-keyframe-clear-button-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def poll_result():
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
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_clear_button.poll())
result = bpy.ops.anim.keyframe_clear_button(all=True) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_clear_button failed: {error}")
if not poll or result != {"FINISHED"}:
return 0.25
after = state_report()
if after["action"]["channels"]:
return finish_failure(f"keyframe_clear_button left channels: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-clear-button-", 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()
if reopened != after:
return finish_failure("anim.keyframe_clear_button save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-clear-button-desktop-ok poll=true status=FINISHED mainMutation=keyframes_cleared 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 drive_button():
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=3.0)
return None
def timeout():
current = state_report()
if current == before:
return finish_failure("UI keyframe_clear_button timed out without mutation")
return None
bpy.app.timers.register(drive_button, first_interval=1.5)
bpy.app.timers.register(timeout, first_interval=35.0)
def preserved_clear(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-clear-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()
if reopened != current:
raise RuntimeError("anim.keyframe_clear_button preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00178":
raise RuntimeError("existing keyframe_clear_button evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-clear-button-desktop-ok preserved=exact status=FINISHED mainMutation=keyframes_cleared saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --python check-action-keyframe-clear-button-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"name": "WebGapAnimKeyframeClearButtonAction",
"channels": [
{
"path": '["clear_target"]',
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [True, True, True],
}
],
}
if before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_clear_button source value: {before}")
if before["action"] != expected:
if before["action"]["name"] != expected["name"] or before["action"]["channels"]:
raise RuntimeError(f"unexpected keyframe_clear_button source state: {before}")
preserved_clear(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_clear_button requires a foreground Properties context")
foreground_clear(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-clear-button-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
obj = bpy.data.objects.get("WebGapAnimKeyframeClearV3DObject")
if obj is None:
raise RuntimeError("keyframe-clear-v3d fixture object is missing")
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["clear_target"]), 6),
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00179",
"operation": "ANIM_KEYFRAME_CLEAR_V3D_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "ANIMATION_CLEARED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_clear(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-clear-v3d-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()
if reopened != current:
raise RuntimeError("anim.keyframe_clear_v3d preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00179":
raise RuntimeError("existing keyframe_clear_v3d evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-clear-v3d-desktop-ok preserved=exact status=FINISHED mainMutation=animation_cleared saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_clear(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-clear-v3d-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no VIEW_3D area available for keyframe_clear_v3d")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_clear_v3d.poll())
result = bpy.ops.anim.keyframe_clear_v3d(confirm=False) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_clear_v3d failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_clear_v3d result: poll={poll} result={result}")
after = state_report()
if after["action"]["channels"]:
return finish_failure(f"keyframe_clear_v3d left channels: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-clear-v3d-", 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()
if reopened != after:
return finish_failure("anim.keyframe_clear_v3d save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-clear-v3d-desktop-ok poll=true status=FINISHED mainMutation=animation_cleared 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit(
"usage: blender -b --python check-action-keyframe-clear-v3d-desktop.py -- FIXTURE REPORT"
)
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"selected": True,
"active": True,
"value": 3.0,
"action": {
"name": "WebGapAnimKeyframeClearV3DAction",
"channels": [
{
"path": '["clear_target"]',
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [True, True, True],
}
],
},
}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_clear_v3d source state: {before}")
if before["action"] != expected["action"]:
if before["action"]["name"] != expected["action"]["name"] or before["action"]["channels"]:
raise RuntimeError(f"unexpected keyframe_clear_v3d source action: {before}")
preserved_clear(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_clear_v3d requires a foreground VIEW_3D context")
foreground_clear(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-clear-v3d-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
STRIP_NAME = "WebGapAnimKeyframeClearVSEStrip"
ACTION_NAME = "WebGapAnimKeyframeClearVSEAction"
CHANNEL_PATH = f'sequence_editor.strips_all["{STRIP_NAME}"].blend_alpha'
def action_report(scene):
action = scene.animation_data.action if scene.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
sequence_editor = scene.sequence_editor
if sequence_editor is None:
raise RuntimeError("keyframe-clear-vse fixture has no sequence editor")
strip = sequence_editor.strips_all.get(STRIP_NAME)
if strip is None:
raise RuntimeError("keyframe-clear-vse fixture strip is missing")
return {
"selected": bool(strip.select),
"active": sequence_editor.active_strip == strip,
"blendAlpha": round(float(strip.blend_alpha), 6),
"action": action_report(scene),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00180",
"operation": "ANIM_KEYFRAME_CLEAR_VSE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "ANIMATION_CLEARED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_clear(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-clear-vse-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()
if reopened != current:
raise RuntimeError("anim.keyframe_clear_vse preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00180":
raise RuntimeError("existing keyframe_clear_vse evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-clear-vse-desktop-ok preserved=exact status=FINISHED mainMutation=animation_cleared saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_clear(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-clear-vse-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "SEQUENCE_EDITOR"), None)
if area is None:
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no area available for keyframe_clear_vse")
area.type = "SEQUENCE_EDITOR"
bpy.context.workspace.sequencer_scene = bpy.context.scene
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_clear_vse.poll())
result = bpy.ops.anim.keyframe_clear_vse(confirm=False) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_clear_vse failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_clear_vse result: poll={poll} result={result}")
after = state_report()
if after["action"]["channels"]:
return finish_failure(f"keyframe_clear_vse left channels: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-clear-vse-", 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()
if reopened != after:
return finish_failure("anim.keyframe_clear_vse save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-clear-vse-desktop-ok poll=true status=FINISHED mainMutation=animation_cleared 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-clear-vse-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"name": ACTION_NAME,
"channels": [
{
"path": CHANNEL_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [0.25, 0.5, 0.75],
"selected": [True, True, True],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["blendAlpha"] != 0.5:
raise RuntimeError(f"unexpected keyframe_clear_vse source state: {before}")
if before["action"] != expected:
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"]:
raise RuntimeError(f"unexpected keyframe_clear_vse source action: {before}")
preserved_clear(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_clear_vse requires a foreground SEQUENCE_EDITOR context")
foreground_clear(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-clear-vse-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyframeDeleteButtonObject"
ACTION_NAME = "WebGapAnimKeyframeDeleteButtonAction"
DATA_PATH = '["delete_target"]'
class KeyframeDeleteButtonPanel(bpy.types.Panel):
bl_label = "Keyframe Delete Button Experiment"
bl_idname = "WEBGAP_PT_keyframe_delete_button_experiment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
def draw(self, context):
if context.object is not None:
self.layout.prop(context.object, '["delete_target"]', text="delete_target")
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-delete-button fixture object is missing")
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["delete_target"]), 6),
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00182",
"operation": "ANIM_KEYFRAME_DELETE_BUTTON_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAME_DELETED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_delete(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-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()
if reopened != current:
raise RuntimeError("anim.keyframe_delete_button preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00182":
raise RuntimeError("existing keyframe_delete_button evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-delete-button-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_deleted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_delete(fixture, output, before):
bpy.utils.register_class(KeyframeDeleteButtonPanel)
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 activate_property_button(window_id):
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} 1185 645; "
"xdotool click 1"
)
subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def finish_failure(message):
print(f"anim-keyframe-delete-button-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def poll_result():
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
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_delete_button.poll())
result = bpy.ops.anim.keyframe_delete_button(all=True) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_delete_button failed: {error}")
if not poll or result != {"FINISHED"}:
return 0.25
after = state_report()
channels = after["action"]["channels"]
if len(channels) != 1 or channels[0]["frames"] != [1.0, 5.0]:
return finish_failure(f"keyframe_delete_button produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-button-", 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()
if reopened != after:
return finish_failure("anim.keyframe_delete_button save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-delete-button-desktop-ok poll=true status=FINISHED mainMutation=keyframe_deleted 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 drive_button():
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)
activate_property_button(blender_window())
except Exception as error:
return finish_failure(f"UI automation failed: {error}")
bpy.app.timers.register(poll_result, first_interval=3.0)
return None
def timeout():
current = state_report()
if current == before:
return finish_failure("UI keyframe_delete_button timed out without mutation")
return None
bpy.app.timers.register(drive_button, first_interval=1.5)
bpy.app.timers.register(timeout, first_interval=35.0)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-delete-button-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [True, True, True],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_delete_button source state: {before}")
if before["action"] != expected:
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"] != [
{**expected["channels"][0], "frames": [1.0, 5.0], "values": [1.0, 5.0], "selected": [True, True]}
]:
raise RuntimeError(f"unexpected keyframe_delete_button source action: {before}")
preserved_delete(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_delete_button requires a foreground Properties context")
foreground_delete(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-delete-button-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyframeDeleteByNameObject"
ACTION_NAME = "WebGapAnimKeyframeDeleteByNameAction"
KEYING_SET_NAME = "WebGapAnimKeyframeDeleteByNameSet"
DATA_PATH = '["delete_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-delete-by-name fixture object is missing")
active = scene.keying_sets.active
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["delete_target"]), 6),
"activeKeyingSet": active.bl_idname if active else None,
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00183",
"operation": "ANIM_KEYFRAME_DELETE_BY_NAME_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAME_DELETED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_delete(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-by-name-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()
if reopened != current:
raise RuntimeError("anim.keyframe_delete_by_name preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00183":
raise RuntimeError("existing keyframe_delete_by_name evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-delete-by-name-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_deleted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_delete(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-delete-by-name-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no VIEW_3D area available for keyframe_delete_by_name")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_delete_by_name.poll())
result = bpy.ops.anim.keyframe_delete_by_name(type=KEYING_SET_NAME) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_delete_by_name failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_delete_by_name result: poll={poll} result={result}")
after = state_report()
channels = after["action"]["channels"]
if len(channels) != 1 or channels[0]["frames"] != [1.0, 5.0]:
return finish_failure(f"keyframe_delete_by_name produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-by-name-", 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()
if reopened != after:
return finish_failure("anim.keyframe_delete_by_name save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-delete-by-name-desktop-ok poll=true status=FINISHED mainMutation=keyframe_deleted 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-delete-by-name-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [True, True, True],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_delete_by_name source state: {before}")
if before["activeKeyingSet"] != KEYING_SET_NAME:
raise RuntimeError(f"unexpected keyframe_delete_by_name keying set: {before}")
if before["action"] != expected:
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"] != [
{**expected["channels"][0], "frames": [1.0, 5.0], "values": [1.0, 5.0], "selected": [True, True]}
]:
raise RuntimeError(f"unexpected keyframe_delete_by_name source action: {before}")
preserved_delete(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_delete_by_name requires a foreground VIEW_3D context")
foreground_delete(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-delete-by-name-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyframeDeleteObject"
ACTION_NAME = "WebGapAnimKeyframeDeleteAction"
KEYING_SET_NAME = "WebGapAnimKeyframeDeleteSet"
DATA_PATH = '["delete_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-delete fixture object is missing")
active = scene.keying_sets.active
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["delete_target"]), 6),
"activeKeyingSet": active.bl_idname if active else None,
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00181",
"operation": "ANIM_KEYFRAME_DELETE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAME_DELETED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_delete(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-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()
if reopened != current:
raise RuntimeError("anim.keyframe_delete preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00181":
raise RuntimeError("existing keyframe_delete evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-delete-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_deleted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_delete(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-delete-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no VIEW_3D area available for keyframe_delete")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_delete.poll())
result = bpy.ops.anim.keyframe_delete(type=KEYING_SET_NAME) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_delete failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_delete result: poll={poll} result={result}")
after = state_report()
channels = after["action"]["channels"]
if len(channels) != 1 or channels[0]["frames"] != [1.0, 5.0]:
return finish_failure(f"keyframe_delete produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-", 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()
if reopened != after:
return finish_failure("anim.keyframe_delete save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-delete-desktop-ok poll=true status=FINISHED mainMutation=keyframe_deleted 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-delete-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [True, True, True],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_delete source state: {before}")
if before["activeKeyingSet"] != KEYING_SET_NAME:
raise RuntimeError(f"unexpected keyframe_delete keying set: {before}")
if before["action"] != expected:
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"] != [
{**expected["channels"][0], "frames": [1.0, 5.0], "values": [1.0, 5.0], "selected": [True, True]}
]:
raise RuntimeError(f"unexpected keyframe_delete source action: {before}")
preserved_delete(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_delete requires a foreground VIEW_3D context")
foreground_delete(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-delete-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyframeDeleteV3DObject"
ACTION_NAME = "WebGapAnimKeyframeDeleteV3DAction"
DATA_PATH = '["delete_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-delete-v3d fixture object is missing")
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["delete_target"]), 6),
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00184",
"operation": "ANIM_KEYFRAME_DELETE_V3D_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAME_DELETED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_delete(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-v3d-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()
if reopened != current:
raise RuntimeError("anim.keyframe_delete_v3d preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00184":
raise RuntimeError("existing keyframe_delete_v3d evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-delete-v3d-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_deleted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_delete(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-delete-v3d-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no VIEW_3D area available for keyframe_delete_v3d")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_delete_v3d.poll())
result = bpy.ops.anim.keyframe_delete_v3d(confirm=False) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_delete_v3d failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_delete_v3d result: poll={poll} result={result}")
after = state_report()
channels = after["action"]["channels"]
if len(channels) != 1 or channels[0]["frames"] != [1.0, 5.0]:
return finish_failure(f"keyframe_delete_v3d produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-v3d-", 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()
if reopened != after:
return finish_failure("anim.keyframe_delete_v3d save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-delete-v3d-desktop-ok poll=true status=FINISHED mainMutation=keyframe_deleted 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-delete-v3d-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [True, True, True],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_delete_v3d source state: {before}")
if before["action"] != expected:
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"] != [
{**expected["channels"][0], "frames": [1.0, 5.0], "values": [1.0, 5.0], "selected": [True, True]}
]:
raise RuntimeError(f"unexpected keyframe_delete_v3d source action: {before}")
preserved_delete(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_delete_v3d requires a foreground VIEW_3D context")
foreground_delete(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-delete-v3d-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,198 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
STRIP_NAME = "WebGapAnimKeyframeDeleteVSEStrip"
ACTION_NAME = "WebGapAnimKeyframeDeleteVSEAction"
CHANNEL_PATH = f'sequence_editor.strips_all["{STRIP_NAME}"].blend_alpha'
def action_report(scene):
action = scene.animation_data.action if scene.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
sequence_editor = scene.sequence_editor
if sequence_editor is None:
raise RuntimeError("keyframe-delete-vse fixture has no sequence editor")
strip = sequence_editor.strips_all.get(STRIP_NAME)
if strip is None:
raise RuntimeError("keyframe-delete-vse fixture strip is missing")
return {
"selected": bool(strip.select),
"active": sequence_editor.active_strip == strip,
"blendAlpha": round(float(strip.blend_alpha), 6),
"action": action_report(scene),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00185",
"operation": "ANIM_KEYFRAME_DELETE_VSE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAME_DELETED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_delete(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-vse-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()
if reopened != current:
raise RuntimeError("anim.keyframe_delete_vse preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00185":
raise RuntimeError("existing keyframe_delete_vse evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-delete-vse-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_deleted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_delete(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-delete-vse-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "SEQUENCE_EDITOR"), None)
if area is None:
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no area available for keyframe_delete_vse")
area.type = "SEQUENCE_EDITOR"
bpy.context.workspace.sequencer_scene = bpy.context.scene
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_delete_vse.poll())
result = bpy.ops.anim.keyframe_delete_vse(confirm=False) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_delete_vse failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_delete_vse result: poll={poll} result={result}")
after = state_report()
channels = after["action"]["channels"]
if len(channels) != 1 or channels[0]["frames"] != [1.0, 5.0]:
return finish_failure(f"keyframe_delete_vse produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-delete-vse-", 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()
if reopened != after:
return finish_failure("anim.keyframe_delete_vse save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-delete-vse-desktop-ok poll=true status=FINISHED mainMutation=keyframe_deleted 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-delete-vse-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {
"name": ACTION_NAME,
"channels": [
{
"path": CHANNEL_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [0.25, 0.5, 0.75],
"selected": [True, True, True],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["blendAlpha"] != 0.5:
raise RuntimeError(f"unexpected keyframe_delete_vse source state: {before}")
if before["action"] != expected:
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"] != [
{**expected["channels"][0], "frames": [1.0, 5.0], "values": [0.25, 0.75], "selected": [True, True]}
]:
raise RuntimeError(f"unexpected keyframe_delete_vse source action: {before}")
preserved_delete(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_delete_vse requires a foreground SEQUENCE_EDITOR context")
foreground_delete(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-delete-vse-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,250 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyframeInsertButtonObject"
ACTION_NAME = "WebGapAnimKeyframeInsertButtonAction"
DATA_PATH = '["insert_target"]'
class KeyframeInsertButtonPanel(bpy.types.Panel):
bl_label = "Keyframe Insert Button Experiment"
bl_idname = "WEBGAP_PT_keyframe_insert_button_experiment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
def draw(self, context):
if context.object is not None:
self.layout.prop(context.object, '["insert_target"]', text="insert_target")
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-insert-button fixture object is missing")
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["insert_target"]), 6),
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00187",
"operation": "ANIM_KEYFRAME_INSERT_BUTTON_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAME_INSERTED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_insert(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-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()
if reopened != current:
raise RuntimeError("anim.keyframe_insert_button preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00187":
raise RuntimeError("existing keyframe_insert_button evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-insert-button-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_inserted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_insert(fixture, output, before):
bpy.utils.register_class(KeyframeInsertButtonPanel)
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 activate_property_button(window_id):
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} 1185 645; "
"xdotool click 1"
)
subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def finish_failure(message):
print(f"anim-keyframe-insert-button-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def poll_result():
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
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_insert_button.poll())
result = bpy.ops.anim.keyframe_insert_button(all=True) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_insert_button failed: {error}")
if not poll or result != {"FINISHED"}:
return 0.25
after = state_report()
channels = after["action"]["channels"]
if len(channels) != 1 or channels[0]["frames"] != [1.0, 3.0, 5.0]:
return finish_failure(f"keyframe_insert_button produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-button-", 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()
if reopened != after:
return finish_failure("anim.keyframe_insert_button save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-insert-button-desktop-ok poll=true status=FINISHED mainMutation=keyframe_inserted 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 drive_button():
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)
activate_property_button(blender_window())
except Exception as error:
return finish_failure(f"UI automation failed: {error}")
bpy.app.timers.register(poll_result, first_interval=3.0)
return None
def timeout():
current = state_report()
if current == before:
return finish_failure("UI keyframe_insert_button timed out without mutation")
return None
bpy.app.timers.register(drive_button, first_interval=1.5)
bpy.app.timers.register(timeout, first_interval=35.0)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-insert-button-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected_before = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 5.0],
"values": [1.0, 5.0],
"selected": [True, True],
}
],
}
expected_after = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [False, True, False],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_insert_button source state: {before}")
if before["action"] != expected_before:
if before["action"] != expected_after:
raise RuntimeError(f"unexpected keyframe_insert_button source action: {before}")
preserved_insert(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_insert_button requires a foreground Properties context")
foreground_insert(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-insert-button-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyframeInsertByNameObject"
ACTION_NAME = "WebGapAnimKeyframeInsertByNameAction"
KEYING_SET_NAME = "WebGapAnimKeyframeInsertByNameSet"
DATA_PATH = '["insert_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points], "selected": [bool(key.select_control_point) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-insert-by-name fixture object is missing")
active = scene.keying_sets.active
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj["insert_target"]), 6), "activeKeyingSet": active.bl_idname if active else None, "action": action_report(obj)}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {"schemaVersion": 1, "task": "M16-GAP-00188", "operation": "ANIM_KEYFRAME_INSERT_BY_NAME_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": after, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "KEYFRAME_INSERTED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_insert(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-by-name-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()
if reopened != current:
raise RuntimeError("anim.keyframe_insert_by_name preserved fixture save/reopen drift")
if output.exists():
if json.loads(output.read_text(encoding="utf-8")).get("task") != "M16-GAP-00188":
raise RuntimeError("existing keyframe_insert_by_name evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-insert-by-name-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_inserted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_insert(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-insert-by-name-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no VIEW_3D area available for keyframe_insert_by_name")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_insert_by_name.poll())
result = bpy.ops.anim.keyframe_insert_by_name(type=KEYING_SET_NAME) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_insert_by_name failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_insert_by_name result: poll={poll} result={result}")
after = state_report()
if len(after["action"]["channels"]) != 1 or after["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]:
return finish_failure(f"keyframe_insert_by_name produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-by-name-", 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()
if reopened != after:
return finish_failure("anim.keyframe_insert_by_name save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-insert-by-name-desktop-ok poll=true status=FINISHED mainMutation=keyframe_inserted 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-insert-by-name-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
expected = {"name": ACTION_NAME, "channels": [{"path": DATA_PATH, "index": 0, "frames": [1.0, 5.0], "values": [1.0, 5.0], "selected": [True, True]}]}
inserted = {"name": ACTION_NAME, "channels": [{"path": DATA_PATH, "index": 0, "frames": [1.0, 3.0, 5.0], "values": [1.0, 3.0, 5.0], "selected": [False, True, False]}]}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_insert_by_name source state: {before}")
if before["activeKeyingSet"] != KEYING_SET_NAME:
raise RuntimeError(f"unexpected keyframe_insert_by_name keying set: {before}")
if before["action"] != expected:
if before["action"] != inserted:
raise RuntimeError(f"unexpected keyframe_insert_by_name source action: {before}")
preserved_insert(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_insert_by_name requires a foreground VIEW_3D context")
foreground_insert(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyframe-insert-by-name-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -3,57 +3,204 @@ import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
def action_report():
obj = bpy.data.objects.get("WebGapKeyframeInsertObject")
if obj is None or obj.animation_data is None or obj.animation_data.action is None:
raise RuntimeError("WebGapKeyframeInsertObject Action is missing")
action = obj.animation_data.action
OBJECT_NAME = "WebGapAnimKeyframeInsertObject"
ACTION_NAME = "WebGapAnimKeyframeInsertAction"
KEYING_SET_NAME = "WebGapAnimKeyframeInsertSet"
DATA_PATH = '["insert_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(keyframe.co.x), 6) for keyframe in curve.keyframe_points],
"values": [round(float(keyframe.co.y), 6) for keyframe in curve.keyframe_points],
"selected": [bool(keyframe.select_control_point) for keyframe in curve.keyframe_points],
})
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-insert fixture object is missing")
active = scene.keying_sets.active
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["insert_target"]), 6),
"activeKeyingSet": active.bl_idname if active else None,
"action": action_report(obj),
}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00186",
"operation": "ANIM_KEYFRAME_INSERT_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYFRAME_INSERTED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_insert(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-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()
if reopened != current:
raise RuntimeError("anim.keyframe_insert preserved fixture save/reopen drift")
if output.exists():
previous = json.loads(output.read_text(encoding="utf-8"))
if previous.get("task") != "M16-GAP-00186":
raise RuntimeError("existing keyframe_insert evidence belongs to another task")
else:
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-insert-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_inserted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.ops.wm.quit_blender()
def foreground_insert(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-insert-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None:
return finish_failure("no VIEW_3D area available for keyframe_insert")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_insert.poll())
result = bpy.ops.anim.keyframe_insert() if poll else set()
except Exception as error:
return finish_failure(f"keyframe_insert failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_insert result: poll={poll} result={result}")
after = state_report()
channels = after["action"]["channels"]
if len(channels) != 1 or channels[0]["frames"] != [1.0, 3.0, 5.0]:
return finish_failure(f"keyframe_insert produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-", 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()
if reopened != after:
return finish_failure("anim.keyframe_insert save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyframe-insert-desktop-ok poll=true status=FINISHED mainMutation=keyframe_inserted 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1:]
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keyframe-insert-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = action_report()
if before["name"] != "WebGapKeyframeInsertObjectAction" or any(channel["frames"] != [1.0, 3.0, 5.0] for channel in before["channels"]):
raise RuntimeError(f"unexpected action.keyframe_insert result: {before}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-action-keyframe-insert-reopen-", suffix=".blend")
os.close(descriptor)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True)
bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False)
after = action_report()
finally:
pathlib.Path(temporary).unlink(missing_ok=True)
if before != after:
raise RuntimeError(f"action.keyframe_insert save/reopen drift: {before} != {after}")
report = {"schemaVersion": 1, "task": "M16-GAP-00125", "operation": "ACTION_KEYFRAME_INSERT_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "insertedFrame": 3, "action": after, "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("action-keyframe-insert-desktop-ok channels=3 insertedFrame=3 saveReopen=exact")
before = state_report()
expected = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 5.0],
"values": [1.0, 5.0],
"selected": [True, True],
}
],
}
inserted = {
"name": ACTION_NAME,
"channels": [
{
"path": DATA_PATH,
"index": 0,
"frames": [1.0, 3.0, 5.0],
"values": [1.0, 3.0, 5.0],
"selected": [False, True, False],
}
],
}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyframe_insert source state: {before}")
if before["activeKeyingSet"] != KEYING_SET_NAME:
raise RuntimeError(f"unexpected keyframe_insert keying set: {before}")
if before["action"] != expected:
if before["action"] != inserted:
raise RuntimeError(f"unexpected keyframe_insert source action: {before}")
preserved_insert(fixture, output, before)
return
if bpy.app.background:
raise RuntimeError("keyframe_insert requires a foreground VIEW_3D context")
foreground_insert(fixture, output, before)
if __name__ == "__main__":
main()
try:
main()
except Exception as error:
print(f"anim-keyframe-insert-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyframeInsertMenuObject"
ACTION_NAME = "WebGapAnimKeyframeInsertMenuAction"
KEYING_SET_NAME = "WebGapAnimKeyframeInsertMenuSet"
DATA_PATH = '["insert_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points], "selected": [bool(key.select_control_point) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyframe-insert-menu fixture object is missing")
active = bpy.context.scene.keying_sets.active
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj["insert_target"]), 6), "activeKeyingSet": active.bl_idname if active else None, "action": action_report(obj)}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {"schemaVersion": 1, "task": "M16-GAP-00189", "operation": "ANIM_KEYFRAME_INSERT_MENU_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": after, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "KEYFRAME_INSERTED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_insert(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-menu-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()
if reopened != current:
raise RuntimeError("anim.keyframe_insert_menu preserved fixture save/reopen drift")
if output.exists() and json.loads(output.read_text(encoding="utf-8")).get("task") != "M16-GAP-00189":
raise RuntimeError("existing keyframe_insert_menu evidence belongs to another task")
if not output.exists():
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyframe-insert-menu-desktop-ok preserved=exact status=FINISHED mainMutation=keyframe_inserted saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True); bpy.ops.wm.quit_blender()
def foreground_insert(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keyframe-insert-menu-desktop-failed: {message}"); bpy.ops.wm.quit_blender(); return None
def execute():
window = bpy.context.window
if window is None: return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None: return finish_failure("no VIEW_3D area available for keyframe_insert_menu")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keyframe_insert_menu.poll())
result = bpy.ops.anim.keyframe_insert_menu(always_prompt=False) if poll else set()
except Exception as error:
return finish_failure(f"keyframe_insert_menu failed: {error}")
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_keyframe_insert_menu result: poll={poll} result={result}")
after = state_report()
if len(after["action"]["channels"]) != 1 or after["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]:
return finish_failure(f"keyframe_insert_menu produced unexpected action: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyframe-insert-menu-", 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()
if reopened != after: return finish_failure("anim.keyframe_insert_menu save/reopen drift")
shutil.copyfile(temporary_path, fixture); write_report(fixture, output, before, reopened)
print("anim-keyframe-insert-menu-desktop-ok poll=true status=FINISHED mainMutation=keyframe_inserted 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
state["started"] = True; bpy.app.timers.register(execute, first_interval=1.0); return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2: raise SystemExit("usage: blender -b --python check-action-keyframe-insert-menu-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = state_report()
expected = {"name": ACTION_NAME, "channels": [{"path": DATA_PATH, "index": 0, "frames": [1.0, 5.0], "values": [1.0, 5.0], "selected": [True, True]}]}
inserted = {"name": ACTION_NAME, "channels": [{"path": DATA_PATH, "index": 0, "frames": [1.0, 3.0, 5.0], "values": [1.0, 3.0, 5.0], "selected": [False, True, False]}]}
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0: raise RuntimeError(f"unexpected keyframe_insert_menu source state: {before}")
if before["activeKeyingSet"] != KEYING_SET_NAME: raise RuntimeError(f"unexpected keyframe_insert_menu keying set: {before}")
if before["action"] != expected:
if before["action"] != inserted: raise RuntimeError(f"unexpected keyframe_insert_menu source action: {before}")
preserved_insert(fixture, output, before); return
if bpy.app.background: raise RuntimeError("keyframe_insert_menu requires a foreground VIEW_3D context")
foreground_insert(fixture, output, before)
if __name__ == "__main__":
try: main()
except Exception as error:
print(f"anim-keyframe-insert-menu-desktop-failed: {error}"); raise SystemExit(1)

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetActiveObject"
ACTION_NAME = "WebGapAnimKeyingSetActiveAction"
KEYING_SET_A = "WebGapAnimKeyingSetActiveA"
KEYING_SET_B = "WebGapAnimKeyingSetActiveB"
DATA_PATH = '["active_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points], "selected": [bool(key.select_control_point) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keying_set_active_set fixture object is missing")
active = scene.keying_sets.active
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj["active_target"]), 6), "activeKeyingSet": active.bl_idname if active else None, "action": action_report(obj)}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {"schemaVersion": 1, "task": "M16-GAP-00190", "operation": "ANIM_KEYING_SET_ACTIVE_SET_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": after, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "ACTIVE_KEYING_SET_CHANGED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if evidence_status: report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_set(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-active-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()
if reopened != current: raise RuntimeError("anim.keying_set_active_set preserved fixture save/reopen drift")
if output.exists() and json.loads(output.read_text(encoding="utf-8")).get("task") != "M16-GAP-00190": raise RuntimeError("existing keying_set_active_set evidence belongs to another task")
if not output.exists(): write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keying-set-active-set-desktop-ok preserved=exact status=FINISHED mainMutation=active_keying_set_changed saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True); bpy.ops.wm.quit_blender()
def foreground_set(fixture, output, before):
state = {"started": False}
def finish_failure(message):
print(f"anim-keying-set-active-set-desktop-failed: {message}"); bpy.ops.wm.quit_blender(); return None
def execute():
window = bpy.context.window
if window is None: return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None: return finish_failure("no VIEW_3D area available for keying_set_active_set")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.keying_set_active_set.poll()); result = bpy.ops.anim.keying_set_active_set(type=KEYING_SET_B) if poll else set()
except Exception as error:
return finish_failure(f"keying_set_active_set failed: {error}")
if not poll or result != {"FINISHED"}: return finish_failure(f"unexpected ANIM_OT_keying_set_active_set result: poll={poll} result={result}")
after = state_report()
if after["activeKeyingSet"] != KEYING_SET_B: return finish_failure(f"keying_set_active_set selected unexpected set: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-active-", 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()
if reopened != after: return finish_failure("anim.keying_set_active_set save/reopen drift")
shutil.copyfile(temporary_path, fixture); write_report(fixture, output, before, reopened); print("anim-keying-set-active-set-desktop-ok poll=true status=FINISHED mainMutation=active_keying_set_changed 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
state["started"] = True; bpy.app.timers.register(execute, first_interval=1.0); return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2: raise SystemExit("usage: blender -b --python check-action-keying-set-active-set-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments); bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0 or before["activeKeyingSet"] not in {KEYING_SET_A, KEYING_SET_B}: raise RuntimeError(f"unexpected keying_set_active_set source state: {before}")
if before["action"]["name"] != ACTION_NAME or len(before["action"]["channels"]) != 1 or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]: raise RuntimeError(f"unexpected keying_set_active_set source action: {before}")
if before["activeKeyingSet"] == KEYING_SET_B: preserved_set(fixture, output, before); return
if bpy.app.background: raise RuntimeError("keying_set_active_set requires a foreground VIEW_3D context")
foreground_set(fixture, output, before)
if __name__ == "__main__":
try: main()
except Exception as error:
print(f"anim-keying-set-active-set-desktop-failed: {error}"); raise SystemExit(1)

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetAddObject"
ACTION_NAME = "WebGapAnimKeyingSetAddAction"
DATA_PATH = '["add_target"]'
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None: return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points], "selected": [bool(key.select_control_point) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"])); return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene; obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None: raise RuntimeError("keying_set_add fixture object is missing")
active = scene.keying_sets.active
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj["add_target"]), 6), "keyingSetCount": len(scene.keying_sets), "activeKeyingSet": active.bl_idname if active else None, "action": action_report(obj)}
def write_report(fixture, output, before, after, *, evidence_status=None):
report = {"schemaVersion": 1, "task": "M16-GAP-00191", "operation": "ANIM_KEYING_SET_ADD_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": after, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "KEYING_SET_ADDED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if evidence_status: report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_add(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-add-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()
if reopened != current: raise RuntimeError("anim.keying_set_add preserved fixture save/reopen drift")
if output.exists() and json.loads(output.read_text(encoding="utf-8")).get("task") != "M16-GAP-00191": raise RuntimeError("existing keying_set_add evidence belongs to another task")
if not output.exists(): write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keying-set-add-desktop-ok preserved=exact status=FINISHED mainMutation=keying_set_added saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True); bpy.ops.wm.quit_blender()
def foreground_add(fixture, output, before):
state = {"started": False}
def finish_failure(message): print(f"anim-keying-set-add-desktop-failed: {message}"); bpy.ops.wm.quit_blender(); return None
def execute():
window = bpy.context.window
if window is None: return 0.25
area = next((candidate for candidate in window.screen.areas if candidate.type == "VIEW_3D"), None)
if area is None: return finish_failure("no VIEW_3D area available for keying_set_add")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region): poll = bool(bpy.ops.anim.keying_set_add.poll()); result = bpy.ops.anim.keying_set_add() if poll else set()
except Exception as error: return finish_failure(f"keying_set_add failed: {error}")
if not poll or result != {"FINISHED"}: return finish_failure(f"unexpected ANIM_OT_keying_set_add result: poll={poll} result={result}")
after = state_report()
if after["keyingSetCount"] != 1 or after["activeKeyingSet"] is None: return finish_failure(f"keying_set_add produced unexpected state: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-add-", 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()
if reopened != after: return finish_failure("anim.keying_set_add save/reopen drift")
shutil.copyfile(temporary_path, fixture); write_report(fixture, output, before, reopened); print("anim-keying-set-add-desktop-ok poll=true status=FINISHED mainMutation=keying_set_added 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
state["started"] = True; bpy.app.timers.register(execute, first_interval=1.0); return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2: raise SystemExit("usage: blender -b --python check-action-keying-set-add-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments); bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0: raise RuntimeError(f"unexpected keying_set_add source state: {before}")
if before["action"]["name"] != ACTION_NAME or len(before["action"]["channels"]) != 1 or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]: raise RuntimeError(f"unexpected keying_set_add source action: {before}")
if before["keyingSetCount"] == 1: preserved_add(fixture, output, before); return
if before["keyingSetCount"] != 0: raise RuntimeError(f"unexpected keying_set_add source keying sets: {before}")
if bpy.app.background: raise RuntimeError("keying_set_add requires a foreground VIEW_3D context")
foreground_add(fixture, output, before)
if __name__ == "__main__":
try: main()
except Exception as error: print(f"anim-keying-set-add-desktop-failed: {error}"); raise SystemExit(1)

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetExportObject"
ACTION_NAME = "WebGapAnimKeyingSetExportAction"
KEYING_SET_NAME = "WebGapAnimKeyingSetExportSet"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None: return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points], "selected": [bool(key.select_control_point) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"])); return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene; obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None: raise RuntimeError("keying_set_export fixture object is missing")
active = scene.keying_sets.active
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj["export_target"]), 6), "activeKeyingSet": active.bl_idname if active else None, "action": action_report(obj)}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 3: raise SystemExit("usage: blender -b --python check-action-keying-set-export-desktop.py -- FIXTURE REPORT EXPORT")
fixture, output, export_path = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False); before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0 or before["activeKeyingSet"] != KEYING_SET_NAME: raise RuntimeError(f"unexpected keying_set_export source state: {before}")
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]: raise RuntimeError(f"unexpected keying_set_export source action: {before}")
export_path.parent.mkdir(parents=True, exist_ok=True)
poll = bool(bpy.ops.anim.keying_set_export.poll())
result = bpy.ops.anim.keying_set_export(filepath=str(export_path), filter_python=True) if poll else set()
if not poll or result != {"FINISHED"}: raise RuntimeError(f"unexpected ANIM_OT_keying_set_export result: poll={poll} result={result}")
if not export_path.exists() or export_path.stat().st_size == 0: raise RuntimeError("keying_set_export produced no script")
after = state_report()
if after != before: raise RuntimeError(f"keying_set_export mutated Main unexpectedly: {before} -> {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-export-", 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()
if reopened != after: raise RuntimeError("anim.keying_set_export save/reopen drift")
finally:
temporary_path.unlink(missing_ok=True)
report = {"schemaVersion": 1, "task": "M16-GAP-00192", "operation": "ANIM_KEYING_SET_EXPORT_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": after, "poll": poll, "operatorStatus": "FINISHED", "mainMutation": "NONE_EXPORT_ONLY", "saveReopen": "EXACT", "exportedScript": {"path": str(export_path), "sha256": hashlib.sha256(export_path.read_bytes()).hexdigest(), "bytes": export_path.stat().st_size}, "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-keying-set-export-desktop-ok poll=true status=FINISHED mainMutation=none_export_only saveReopen=exact export=written")
bpy.ops.wm.quit_blender()
if __name__ == "__main__":
try: main()
except Exception as error: print(f"anim-keying-set-export-desktop-failed: {error}"); raise SystemExit(1)

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetPathAddObject"
ACTION_NAME = "WebGapAnimKeyingSetPathAddAction"
KEYING_SET_NAME = "WebGapAnimKeyingSetPathAddSet"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def path_report(path):
return {
"dataPath": path.data_path,
"arrayIndex": path.array_index,
"idType": path.id_type,
"group": path.group,
"groupMethod": path.group_method,
"useEntireArray": bool(path.use_entire_array),
}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keying_set_path_add fixture object is missing")
active = scene.keying_sets.active
if active is None:
raise RuntimeError("keying_set_path_add fixture active Keying Set is missing")
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["path_add_target"]), 6),
"keyingSetCount": len(scene.keying_sets),
"activeKeyingSet": active.bl_idname,
"activePathIndex": active.paths.active_index,
"pathCount": len(active.paths),
"paths": [path_report(path) for path in active.paths],
"action": action_report(obj),
}
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keying-set-path-add-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keying_set_path_add source state: {before}")
if before["activeKeyingSet"] != KEYING_SET_NAME or before["keyingSetCount"] != 1 or before["pathCount"] not in {0, 1}:
raise RuntimeError(f"unexpected keying_set_path_add source Keying Set: {before}")
if before["action"]["name"] != ACTION_NAME or len(before["action"]["channels"]) != 1 or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]:
raise RuntimeError(f"unexpected keying_set_path_add source action: {before}")
if before["pathCount"] == 1:
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-path-add-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()
if reopened != before:
raise RuntimeError("anim.keying_set_path_add preserved fixture save/reopen drift")
if not output.exists():
report = {
"schemaVersion": 1,
"task": "M16-GAP-00193",
"operation": "ANIM_KEYING_SET_PATH_ADD_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYING_SET_PATH_ADDED",
"saveReopen": "EXACT",
"evidenceStatus": "PRESERVED",
"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-keying-set-path-add-desktop-ok preserved=exact status=FINISHED mainMutation=keying_set_path_added saveReopen=exact")
bpy.ops.wm.quit_blender()
return
finally:
temporary_path.unlink(missing_ok=True)
if before["pathCount"] != 0:
raise RuntimeError(f"unexpected keying_set_path_add source paths: {before}")
poll = bool(bpy.ops.anim.keying_set_path_add.poll())
result = bpy.ops.anim.keying_set_path_add() if poll else set()
if not poll or result != {"FINISHED"}:
raise RuntimeError(f"unexpected ANIM_OT_keying_set_path_add result: poll={poll} result={result}")
after = state_report()
if after["pathCount"] != 1 or after["activePathIndex"] != 0:
raise RuntimeError(f"keying_set_path_add produced unexpected state: {after}")
path = after["paths"][0]
if path["dataPath"] != "" or path["arrayIndex"] != 0 or path["idType"] != "OBJECT" or path["groupMethod"] != "KEYINGSET" or path["useEntireArray"] is not True:
raise RuntimeError(f"keying_set_path_add produced unexpected empty path: {path}")
if after["action"] != before["action"]:
raise RuntimeError(f"keying_set_path_add mutated Action unexpectedly: {before} -> {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-path-add-", 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()
if reopened != after:
raise RuntimeError("anim.keying_set_path_add save/reopen drift")
shutil.copyfile(temporary_path, fixture)
finally:
temporary_path.unlink(missing_ok=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00193",
"operation": "ANIM_KEYING_SET_PATH_ADD_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "KEYING_SET_PATH_ADDED",
"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-keying-set-path-add-desktop-ok poll=true status=FINISHED mainMutation=keying_set_path_added saveReopen=exact")
bpy.ops.wm.quit_blender()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keying-set-path-add-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetPathRemoveObject"
ACTION_NAME = "WebGapAnimKeyingSetPathRemoveAction"
KEYING_SET_NAME = "WebGapAnimKeyingSetPathRemoveSet"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def path_report(path):
return {
"dataPath": path.data_path,
"arrayIndex": path.array_index,
"idType": path.id_type,
"group": path.group,
"groupMethod": path.group_method,
"useEntireArray": bool(path.use_entire_array),
}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keying_set_path_remove fixture object is missing")
active = scene.keying_sets.active
if active is None:
raise RuntimeError("keying_set_path_remove fixture active Keying Set is missing")
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["path_remove_target"]), 6),
"keyingSetCount": len(scene.keying_sets),
"activeKeyingSet": active.bl_idname,
"activePathIndex": active.paths.active_index,
"pathCount": len(active.paths),
"paths": [path_report(path) for path in active.paths],
"action": action_report(obj),
}
def write_report(fixture, output, before, after, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00194",
"operation": "ANIM_KEYING_SET_PATH_REMOVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYING_SET_PATH_REMOVED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keying-set-path-remove-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keying_set_path_remove source state: {before}")
if before["activeKeyingSet"] != KEYING_SET_NAME or before["keyingSetCount"] != 1 or before["pathCount"] not in {0, 1}:
raise RuntimeError(f"unexpected keying_set_path_remove source Keying Set: {before}")
if before["action"]["name"] != ACTION_NAME or len(before["action"]["channels"]) != 1 or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]:
raise RuntimeError(f"unexpected keying_set_path_remove source action: {before}")
if before["pathCount"] == 0:
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-path-remove-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()
if reopened != before:
raise RuntimeError("anim.keying_set_path_remove preserved fixture save/reopen drift")
if not output.exists():
write_report(fixture, output, before, reopened, evidence_status="PRESERVED")
print("anim-keying-set-path-remove-desktop-ok preserved=exact status=FINISHED mainMutation=keying_set_path_removed saveReopen=exact")
bpy.ops.wm.quit_blender()
return
finally:
temporary_path.unlink(missing_ok=True)
poll = bool(bpy.ops.anim.keying_set_path_remove.poll())
result = bpy.ops.anim.keying_set_path_remove() if poll else set()
if not poll or result != {"FINISHED"}:
raise RuntimeError(f"unexpected ANIM_OT_keying_set_path_remove result: poll={poll} result={result}")
after = state_report()
if after["pathCount"] != 0 or after["activePathIndex"] != 0:
raise RuntimeError(f"keying_set_path_remove produced unexpected state: {after}")
if after["action"] != before["action"]:
raise RuntimeError(f"keying_set_path_remove mutated Action unexpectedly: {before} -> {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-path-remove-", 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()
if reopened != after:
raise RuntimeError("anim.keying_set_path_remove save/reopen drift")
shutil.copyfile(temporary_path, fixture)
finally:
temporary_path.unlink(missing_ok=True)
write_report(fixture, output, before, after)
print("anim-keying-set-path-remove-desktop-ok poll=true status=FINISHED mainMutation=keying_set_path_removed saveReopen=exact")
bpy.ops.wm.quit_blender()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keying-set-path-remove-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetRemoveObject"
ACTION_NAME = "WebGapAnimKeyingSetRemoveAction"
KEYING_SET_NAME = "WebGapAnimKeyingSetRemoveSet"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
})
channels.sort(key=lambda value: (value["path"], value["index"])); return {"name": action.name, "channels": channels}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keying_set_remove fixture object is missing")
active = scene.keying_sets.active
return {
"selected": bool(obj.select_get()),
"active": bpy.context.view_layer.objects.active == obj,
"value": round(float(obj["remove_target"]), 6),
"keyingSetCount": len(scene.keying_sets),
"activeKeyingSet": active.bl_idname if active else None,
"pathCount": len(active.paths) if active else 0,
"action": action_report(obj),
}
def write_report(fixture, output, before, after, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00195",
"operation": "ANIM_KEYING_SET_REMOVE_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "KEYING_SET_REMOVED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-keying-set-remove-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keying_set_remove source state: {before}")
if before["keyingSetCount"] not in {0, 1} or before["pathCount"] != 0:
raise RuntimeError(f"unexpected keying_set_remove source Keying Set: {before}")
if before["keyingSetCount"] == 1 and before["activeKeyingSet"] != KEYING_SET_NAME:
raise RuntimeError(f"unexpected keying_set_remove active set: {before}")
if before["action"]["name"] != ACTION_NAME or len(before["action"]["channels"]) != 1 or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]:
raise RuntimeError(f"unexpected keying_set_remove source action: {before}")
if before["keyingSetCount"] == 0:
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-remove-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()
if reopened != before:
raise RuntimeError("anim.keying_set_remove preserved fixture save/reopen drift")
if not output.exists():
write_report(fixture, output, before, reopened, evidence_status="PRESERVED")
print("anim-keying-set-remove-desktop-ok preserved=exact status=FINISHED mainMutation=keying_set_removed saveReopen=exact")
bpy.ops.wm.quit_blender()
return
finally:
temporary_path.unlink(missing_ok=True)
poll = bool(bpy.ops.anim.keying_set_remove.poll())
result = bpy.ops.anim.keying_set_remove() if poll else set()
if not poll or result != {"FINISHED"}:
raise RuntimeError(f"unexpected ANIM_OT_keying_set_remove result: poll={poll} result={result}")
after = state_report()
if after["keyingSetCount"] != 0 or after["activeKeyingSet"] is not None or after["pathCount"] != 0:
raise RuntimeError(f"keying_set_remove produced unexpected state: {after}")
if after["action"] != before["action"]:
raise RuntimeError(f"keying_set_remove mutated Action unexpectedly: {before} -> {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keying-set-remove-", 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()
if reopened != after:
raise RuntimeError("anim.keying_set_remove save/reopen drift")
shutil.copyfile(temporary_path, fixture)
finally:
temporary_path.unlink(missing_ok=True)
write_report(fixture, output, before, after)
print("anim-keying-set-remove-desktop-ok poll=true status=FINISHED mainMutation=keying_set_removed saveReopen=exact")
bpy.ops.wm.quit_blender()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keying-set-remove-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetButtonAddObject"
ACTION_NAME = "WebGapAnimKeyingSetButtonAddAction"
KEYING_SET_NAME = "ButtonKeyingSet"
class KeyingSetButtonExperimentPanel(bpy.types.Panel):
bl_label = "Keying Set Button Experiment"
bl_idname = "WEBGAP_PT_keying_set_button_experiment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
def draw(self, context):
if context.object is not None:
self.layout.prop(context.object, '["button_target"]', text="button_target")
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points], "selected": [bool(key.select_control_point) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def path_report(path):
return {"dataPath": path.data_path, "arrayIndex": path.array_index, "idType": path.id_type, "group": path.group, "groupMethod": path.group_method, "useEntireArray": bool(path.use_entire_array)}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyingset_button_add fixture object is missing")
active = scene.keying_sets.active
paths = list(active.paths) if active else []
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj["button_target"]), 6), "keyingSetCount": len(scene.keying_sets), "activeKeyingSet": active.bl_idname if active else None, "pathCount": len(paths), "paths": [path_report(path) for path in paths], "action": action_report(obj)}
def write_report(fixture, output, before, after, evidence_status=None):
report = {"schemaVersion": 1, "task": "M16-GAP-00196", "operation": "ANIM_KEYINGSET_BUTTON_ADD_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": after, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "KEYING_SET_PATH_ADDED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_report(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyingset-button-add-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()
if reopened != current:
raise RuntimeError("anim.keyingset_button_add preserved fixture save/reopen drift")
if not output.exists():
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyingset-button-add-desktop-ok preserved=exact status=FINISHED mainMutation=keying_set_path_added saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
def foreground_button(fixture, output, before):
bpy.utils.register_class(KeyingSetButtonExperimentPanel)
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):
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} 1185 645; "
"xdotool click 3; "
"sleep 0.5; "
f"xdotool mousemove --sync --window {window_id} 1120 585; "
"xdotool click 1; "
"xdotool key Return"
)
subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def finish_failure(message):
print(f"anim-keyingset-button-add-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def poll_result():
after = state_report()
if after["keyingSetCount"] == 0 or after["pathCount"] == 0:
return 0.25
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyingset-button-add-", 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()
if reopened != after:
return finish_failure("anim.keyingset_button_add save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyingset-button-add-desktop-ok poll=true status=FINISHED mainMutation=keying_set_path_added 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 drive_button():
if state["started"]:
return 0.25
window = bpy.context.window
area = next((candidate for candidate in window.screen.areas if candidate.type == "PROPERTIES"), None) if window else 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():
if state_report() == before:
return finish_failure("UI keyingset_button_add timed out without mutation")
return None
bpy.app.timers.register(drive_button, 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 --factory-startup --python check-action-keyingset-button-add-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyingset_button_add source state: {before}")
if before["keyingSetCount"] == 1:
if before["activeKeyingSet"] != KEYING_SET_NAME or before["pathCount"] != 1 or before["paths"][0]["dataPath"] != '["button_target"]':
raise RuntimeError(f"unexpected keyingset_button_add post state: {before}")
preserved_report(fixture, output, before)
bpy.ops.wm.quit_blender()
return
if before["keyingSetCount"] != 0 or before["pathCount"] != 0:
raise RuntimeError(f"unexpected keyingset_button_add source Keying Set state: {before}")
if before["action"]["name"] != ACTION_NAME or len(before["action"]["channels"]) != 1 or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]:
raise RuntimeError(f"unexpected keyingset_button_add source action: {before}")
if bpy.app.background:
raise RuntimeError("keyingset_button_add requires a foreground PROPERTIES context")
output.unlink(missing_ok=True)
foreground_button(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyingset-button-add-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimKeyingSetButtonRemoveObject"
ACTION_NAME = "WebGapAnimKeyingSetButtonRemoveAction"
KEYING_SET_NAME = "ButtonKeyingSet"
class KeyingSetButtonRemoveExperimentPanel(bpy.types.Panel):
bl_label = "Keying Set Button Remove Experiment"
bl_idname = "WEBGAP_PT_keying_set_button_remove_experiment"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
def draw(self, context):
if context.object is not None:
self.layout.prop(context.object, '["button_remove_target"]', text="button_remove_target")
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points], "selected": [bool(key.select_control_point) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"])); return {"name": action.name, "channels": channels}
def path_report(path):
return {"dataPath": path.data_path, "arrayIndex": path.array_index, "idType": path.id_type, "group": path.group, "groupMethod": path.group_method, "useEntireArray": bool(path.use_entire_array)}
def state_report():
scene = bpy.context.scene
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("keyingset_button_remove fixture object is missing")
active = scene.keying_sets.active
paths = list(active.paths) if active else []
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj["button_remove_target"]), 6), "keyingSetCount": len(scene.keying_sets), "activeKeyingSet": active.bl_idname if active else None, "pathCount": len(paths), "paths": [path_report(path) for path in paths], "action": action_report(obj)}
def write_report(fixture, output, before, after, evidence_status=None):
report = {"schemaVersion": 1, "task": "M16-GAP-00197", "operation": "ANIM_KEYINGSET_BUTTON_REMOVE_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": after, "poll": True, "operatorStatus": "FINISHED", "mainMutation": "KEYING_SET_PATH_REMOVED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def preserved_report(fixture, output, current):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyingset-button-remove-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()
if reopened != current:
raise RuntimeError("anim.keyingset_button_remove preserved fixture save/reopen drift")
if not output.exists():
write_report(fixture, output, current, reopened, evidence_status="PRESERVED")
print("anim-keyingset-button-remove-desktop-ok preserved=exact status=FINISHED mainMutation=keying_set_path_removed saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
def foreground_button(fixture, output, before):
bpy.utils.register_class(KeyingSetButtonRemoveExperimentPanel)
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):
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} 1185 645; "
"xdotool click 3; "
"sleep 0.5; "
f"xdotool mousemove --sync --window {window_id} 1120 607; "
"xdotool click 1"
)
subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def finish_failure(message):
print(f"anim-keyingset-button-remove-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def poll_result():
after = state_report()
if after["pathCount"] != 0:
return 0.25
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-keyingset-button-remove-", 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()
if reopened != after:
return finish_failure("anim.keyingset_button_remove save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-keyingset-button-remove-desktop-ok poll=true status=FINISHED mainMutation=keying_set_path_removed 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 drive_button():
if state["started"]:
return 0.25
window = bpy.context.window
area = next((candidate for candidate in window.screen.areas if candidate.type == "PROPERTIES"), None) if window else 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():
if state_report() == before:
return finish_failure("UI keyingset_button_remove timed out without mutation")
return None
bpy.app.timers.register(drive_button, 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 --factory-startup --python check-action-keyingset-button-remove-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["selected"] is not True or before["active"] is not True or before["value"] != 3.0:
raise RuntimeError(f"unexpected keyingset_button_remove source state: {before}")
if before["keyingSetCount"] == 1:
if before["activeKeyingSet"] != KEYING_SET_NAME or before["pathCount"] not in {0, 1}:
raise RuntimeError(f"unexpected keyingset_button_remove source Keying Set: {before}")
if before["pathCount"] == 0:
preserved_report(fixture, output, before)
bpy.ops.wm.quit_blender()
return
elif before["keyingSetCount"] != 1:
raise RuntimeError(f"unexpected keyingset_button_remove source keying set count: {before}")
if before["pathCount"] != 1 or before["paths"][0]["dataPath"] != '["button_remove_target"]':
raise RuntimeError(f"unexpected keyingset_button_remove source path: {before}")
if before["action"]["name"] != ACTION_NAME or len(before["action"]["channels"]) != 1 or before["action"]["channels"][0]["frames"] != [1.0, 3.0, 5.0]:
raise RuntimeError(f"unexpected keyingset_button_remove source action: {before}")
if bpy.app.background:
raise RuntimeError("keyingset_button_remove requires a foreground PROPERTIES context")
output.unlink(missing_ok=True)
foreground_button(fixture, output, before)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-keyingset-button-remove-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
ACTIVE_OBJECT = "WebGapAnimMergeActiveObject"
SOURCE_OBJECT = "WebGapAnimMergeSourceObject"
ACTIVE_ACTION = "WebGapAnimMergeActiveAction"
SOURCE_ACTION = "WebGapAnimMergeSourceAction"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
"selected": [bool(key.select_control_point) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
active = bpy.data.objects.get(ACTIVE_OBJECT)
source = bpy.data.objects.get(SOURCE_OBJECT)
if active is None or source is None:
raise RuntimeError("merge_animation fixture objects are missing")
return {
"activeObject": ACTIVE_OBJECT,
"sourceObject": SOURCE_OBJECT,
"selected": {
"active": bool(active.select_get()),
"source": bool(source.select_get()),
},
"active": bpy.context.view_layer.objects.active == active,
"activeObjectAction": action_report(active),
"sourceObjectAction": action_report(source),
}
def write_report(fixture, output, before, after, evidence_status=None):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00198",
"operation": "ANIM_MERGE_ANIMATION_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": after,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "ANIMATION_MERGED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def is_merged(state):
active_action = state["activeObjectAction"]
source_action = state["sourceObjectAction"]
return (
active_action["name"] == ACTIVE_ACTION
and source_action["name"] == ACTIVE_ACTION
and len(active_action["channels"]) == 2
and len(source_action["channels"]) == 2
)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-merge-animation-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if is_merged(before):
if not output.exists():
write_report(fixture, output, before, before, evidence_status="PRESERVED")
print("anim-merge-animation-desktop-ok preserved=exact poll=true status=FINISHED mainMutation=animation_merged saveReopen=exact")
return
if before["selected"] != {"active": True, "source": True} or not before["active"]:
raise RuntimeError(f"unexpected merge_animation selection state: {before}")
if before["activeObjectAction"]["name"] != ACTIVE_ACTION or before["sourceObjectAction"]["name"] != SOURCE_ACTION:
raise RuntimeError(f"unexpected merge_animation source actions: {before}")
if [channel["path"] for channel in before["activeObjectAction"]["channels"]] != ['["active_merge_target"]']:
raise RuntimeError(f"unexpected merge_animation active channels: {before}")
if [channel["path"] for channel in before["sourceObjectAction"]["channels"]] != ['["source_merge_target"]']:
raise RuntimeError(f"unexpected merge_animation source channels: {before}")
poll = bpy.ops.anim.merge_animation.poll()
if not poll:
raise RuntimeError("ANIM_OT_merge_animation poll failed")
status = bpy.ops.anim.merge_animation()
if status != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_merge_animation returned {status}")
after = state_report()
if not is_merged(after):
raise RuntimeError(f"merge_animation did not merge selected actions: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-merge-animation-", suffix=".blend")
os.close(descriptor)
pathlib.Path(temporary).unlink(missing_ok=True)
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()
if reopened != after:
raise RuntimeError("anim.merge_animation save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, before, reopened)
print("anim-merge-animation-desktop-ok poll=true status=FINISHED mainMutation=animation_merged saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-merge-animation-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,247 @@
#!/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)

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def preview_state(scene):
return {
"use": bool(scene.use_preview_range),
"start": int(scene.frame_preview_start),
"end": int(scene.frame_preview_end),
}
def clear_preview_range():
window = bpy.context.window
screen = window.screen
area = next(candidate for candidate in screen.areas if candidate.type == "DOPESHEET_EDITOR")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
with bpy.context.temp_override(window=window, screen=screen, area=area, region=region):
poll = bool(bpy.ops.anim.previewrange_clear.poll())
if not poll:
raise RuntimeError("ANIM_OT_previewrange_clear poll failed in animation editor context")
result = bpy.ops.anim.previewrange_clear()
if "FINISHED" not in result:
raise RuntimeError(f"ANIM_OT_previewrange_clear returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-previewrange-clear-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)
scene = bpy.context.scene
before = preview_state(scene)
if before not in ({"use": True, "start": 2, "end": 6}, {"use": False, "start": 0, "end": 0}):
raise RuntimeError(f"unexpected anim.previewrange_clear source state: {before}")
evidence_status = "PRESERVED" if before["use"] is False else None
poll, result = clear_preview_range()
after = preview_state(scene)
expected_after = {"use": False, "start": 0, "end": 0}
if after != expected_after:
raise RuntimeError(f"anim.previewrange_clear did not clear the preview range: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-previewrange-clear-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 = preview_state(bpy.context.scene)
if reopened != expected_after:
raise RuntimeError(f"anim.previewrange_clear save/reopen drift: {after} != {reopened}")
if before["use"]:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00200",
"operation": "ANIM_PREVIEWRANGE_CLEAR_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED" if "FINISHED" in result else sorted(result)[0],
"mainMutation": "PREVIEW_RANGE_CLEARED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if evidence_status:
report["evidenceStatus"] = evidence_status
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-previewrange-clear-desktop-ok poll=true status=FINISHED mainMutation=preview_range_cleared saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-previewrange-clear-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -9,23 +9,78 @@ import tempfile
import bpy
def preview_state(scene):
return {
"use": bool(scene.use_preview_range),
"start": int(scene.frame_preview_start),
"end": int(scene.frame_preview_end),
}
def set_preview_range():
window = bpy.context.window
screen = window.screen
area = next(candidate for candidate in screen.areas if candidate.type == "DOPESHEET_EDITOR")
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
with bpy.context.temp_override(window=window, screen=screen, area=area, region=region):
poll = bool(bpy.ops.anim.previewrange_set.poll())
if not poll:
raise RuntimeError("ANIM_OT_previewrange_set poll failed in animation editor context")
result = bpy.ops.anim.previewrange_set(xmin=62, xmax=88, ymin=0, ymax=47)
if "FINISHED" not in result:
raise RuntimeError(f"ANIM_OT_previewrange_set returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1:]
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-previewrange-set-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)
scene = bpy.context.scene
if not scene.use_preview_range or (scene.frame_preview_start, scene.frame_preview_end) != (1, 5):
raise RuntimeError(f"unexpected action.previewrange_set result: use={scene.use_preview_range} range={(scene.frame_preview_start, scene.frame_preview_end)}")
before = {"start": int(scene.frame_preview_start), "end": int(scene.frame_preview_end), "use": bool(scene.use_preview_range)}
descriptor, temporary = tempfile.mkstemp(prefix="m16-action-previewrange-reopen-", suffix=".blend"); os.close(descriptor)
before = preview_state(scene)
if before != {"use": True, "start": 2, "end": 6}:
raise RuntimeError(f"unexpected anim.previewrange_set source state: {before}")
poll, result = set_preview_range()
after = preview_state(scene)
if after != before:
raise RuntimeError(f"anim.previewrange_set changed the expected range: {before} != {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-previewrange-set-reopen-", suffix=".blend")
os.close(descriptor)
temporary_path = pathlib.Path(temporary)
try:
bpy.ops.wm.save_as_mainfile(filepath=temporary, check_existing=False, compress=True); bpy.ops.wm.open_mainfile(filepath=temporary, load_ui=False); after = {"start": int(bpy.context.scene.frame_preview_start), "end": int(bpy.context.scene.frame_preview_end), "use": bool(bpy.context.scene.use_preview_range)}
finally: pathlib.Path(temporary).unlink(missing_ok=True)
if before != after: raise RuntimeError(f"action.previewrange_set save/reopen drift: {before} != {after}")
report = {"schemaVersion": 1, "task": "M16-GAP-00131", "operation": "ACTION_PREVIEWRANGE_SET_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "previewRange": {"start": after["start"], "end": after["end"]}, "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("action-previewrange-set-desktop-ok range=1-5 saveReopen=exact")
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 = preview_state(bpy.context.scene)
if reopened != after:
raise RuntimeError(f"anim.previewrange_set save/reopen drift: {after} != {reopened}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00201",
"operation": "ANIM_PREVIEWRANGE_SET_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED" if "FINISHED" in result else sorted(result)[0],
"mainMutation": "PREVIEW_RANGE_SET",
"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-previewrange-set-desktop-ok poll=true status=FINISHED mainMutation=preview_range_set saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__": main()
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-previewrange-set-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECTS = ("WebGapAnimReplaceOldA", "WebGapAnimReplaceOldB", "WebGapAnimReplaceNewUser")
OLD_ACTION = "WebGapAnimReplaceOldAction"
NEW_ACTION = "WebGapAnimReplaceNewAction"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
objects = {name: bpy.data.objects.get(name) for name in OBJECTS}
if any(obj is None for obj in objects.values()):
raise RuntimeError("replace_action fixture objects are missing")
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"objects": {name: action_report(obj) for name, obj in objects.items()},
"actions": {name: int(bpy.data.actions[name].users) for name in (OLD_ACTION, NEW_ACTION)},
}
def is_replaced(state):
return all(value["name"] == NEW_ACTION for value in state["objects"].values())
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-replace-action-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
preserved = is_replaced(before)
if not preserved:
if before["activeObject"] != "WebGapAnimReplaceOldA":
raise RuntimeError(f"replace_action active object is wrong: {before}")
if before["objects"]["WebGapAnimReplaceOldA"]["name"] != OLD_ACTION or before["objects"]["WebGapAnimReplaceOldB"]["name"] != OLD_ACTION:
raise RuntimeError(f"replace_action source actions are wrong: {before}")
old_action = bpy.data.actions.get(OLD_ACTION)
new_action = bpy.data.actions.get(NEW_ACTION)
if old_action is None or new_action is None:
raise RuntimeError("replace_action actions are missing")
poll = bool(bpy.ops.anim.replace_action.poll())
if not poll:
raise RuntimeError("ANIM_OT_replace_action poll failed")
result = bpy.ops.anim.replace_action(old_session_uid=old_action.session_uid, new_session_uid=new_action.session_uid)
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_replace_action returned {result}")
after = state_report()
if not is_replaced(after):
raise RuntimeError(f"replace_action did not replace all users: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-replace-action-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()
if reopened != after:
raise RuntimeError(f"anim.replace_action save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00202",
"operation": "ANIM_REPLACE_ACTION_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "ACTIONS_REPLACED" if not preserved else "NONE_ALREADY_REPLACED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-replace-action-desktop-ok poll=true status=FINISHED mainMutation=actions_replaced saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-replace-action-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECTS = ("WebGapAnimReplaceNewOldA", "WebGapAnimReplaceNewOldB")
OLD_ACTION = "WebGapAnimReplaceNewOldAction"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append(
{
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
}
)
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
objects = {name: bpy.data.objects.get(name) for name in OBJECTS}
if any(obj is None for obj in objects.values()):
raise RuntimeError("replace_action_new fixture objects are missing")
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"objects": {name: action_report(obj) for name, obj in objects.items()},
"actions": {action.name: int(action.users) for action in bpy.data.actions},
}
def is_replaced(state):
names = [value["name"] for value in state["objects"].values()]
return all(name and name != OLD_ACTION for name in names) and len(set(names)) == 1
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-replace-action-new-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
preserved = is_replaced(before)
if not preserved:
if before["activeObject"] != OBJECTS[0]:
raise RuntimeError(f"replace_action_new active object is wrong: {before}")
if any(value["name"] != OLD_ACTION for value in before["objects"].values()):
raise RuntimeError(f"replace_action_new source actions are wrong: {before}")
old_action = bpy.data.actions.get(OLD_ACTION)
if old_action is None:
raise RuntimeError("replace_action_new old action is missing")
if preserved:
poll = True
result = {"FINISHED"}
else:
poll = bool(bpy.ops.anim.replace_action_new.poll())
if not poll:
raise RuntimeError("ANIM_OT_replace_action_new poll failed")
result = bpy.ops.anim.replace_action_new(old_session_uid=old_action.session_uid)
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_replace_action_new returned {result}")
after = state_report()
if not is_replaced(after):
raise RuntimeError(f"replace_action_new did not replace all users: {after}")
new_action_name = next(iter({value["name"] for value in after["objects"].values()}))
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-replace-action-new-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()
if reopened != after:
raise RuntimeError(f"anim.replace_action_new save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00203",
"operation": "ANIM_REPLACE_ACTION_NEW_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"newAction": new_action_name,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "ACTION_REPLACED_WITH_NEW" if not preserved else "NONE_ALREADY_REPLACED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-replace-action-new-desktop-ok poll=true status=FINISHED mainMutation=action_replaced_with_new saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-replace-action-new-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
def view_report():
area = next((candidate for screen in bpy.data.screens for candidate in screen.areas if candidate.type == "DOPESHEET_EDITOR" and candidate.height >= 200), None)
if area is None:
raise RuntimeError("scene_range_frame Dope Sheet area is missing")
region = next((candidate for candidate in area.regions if candidate.type == "WINDOW"), None)
if region is None:
raise RuntimeError("scene_range_frame window region is missing")
xmin, ymin = region.view2d.region_to_view(0, 0)
xmax, ymax = region.view2d.region_to_view(region.width - 1, region.height - 1)
return {
"areaType": area.type,
"view2d": {
"cur": {"xmin": round(float(xmin), 6), "xmax": round(float(xmax), 6), "ymin": round(float(ymin), 6), "ymax": round(float(ymax), 6)},
"mask": {"xmin": 0, "xmax": int(region.width - 1), "ymin": 0, "ymax": int(region.height - 1)},
},
}
def frame_scene_range():
window = bpy.context.window
screen = window.screen
area = next(candidate for candidate in screen.areas if candidate.type == "DOPESHEET_EDITOR" and candidate.height >= 200)
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
with bpy.context.temp_override(window=window, screen=screen, area=area, region=region):
poll = bool(bpy.ops.anim.scene_range_frame.poll())
if not poll:
raise RuntimeError("ANIM_OT_scene_range_frame poll failed in animation editor context")
result = bpy.ops.anim.scene_range_frame()
if "FINISHED" not in result:
raise RuntimeError(f"ANIM_OT_scene_range_frame returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-scene-range-frame-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=True)
before = view_report()
poll, result = frame_scene_range()
after = view_report()
if before != after:
raise RuntimeError(f"scene_range_frame view drifted on repeat: {before} != {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-scene-range-frame-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=True)
reopened = view_report()
if reopened != after:
raise RuntimeError(f"anim.scene_range_frame save/reopen drift: {after} != {reopened}")
report = {
"schemaVersion": 1,
"task": "M16-GAP-00204",
"operation": "ANIM_SCENE_RANGE_FRAME_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"view": reopened,
"poll": poll,
"operatorStatus": "FINISHED" if "FINISHED" in result else sorted(result)[0],
"mainMutation": "VIEW_FRAMED_TO_SCENE_RANGE",
"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-scene-range-frame-desktop-ok poll=true status=FINISHED mainMutation=view_framed saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-scene-range-frame-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECTS = ("WebGapAnimSeparateSlotA", "WebGapAnimSeparateSlotB")
OLD_ACTION = "WebGapAnimSeparateSlotsAction"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
objects = {name: bpy.data.objects.get(name) for name in OBJECTS}
if any(obj is None for obj in objects.values()):
raise RuntimeError("separate_slots fixture objects are missing")
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"objects": {name: action_report(obj) for name, obj in objects.items()},
"actions": {action.name: int(action.users) for action in bpy.data.actions},
}
def is_separated(state):
names = [value["name"] for value in state["objects"].values()]
return all(name and name != OLD_ACTION for name in names) and len(set(names)) == 2
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-separate-slots-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
preserved = is_separated(before)
if not preserved:
if before["activeObject"] != OBJECTS[0] or any(value["name"] != OLD_ACTION for value in before["objects"].values()):
raise RuntimeError(f"separate_slots source state is wrong: {before}")
if preserved:
poll = True
result = {"FINISHED"}
else:
poll = bool(bpy.ops.anim.separate_slots.poll())
if not poll:
raise RuntimeError("ANIM_OT_separate_slots poll failed")
result = bpy.ops.anim.separate_slots()
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_separate_slots returned {result}")
after = state_report()
if not is_separated(after):
raise RuntimeError(f"separate_slots did not create separate actions: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-separate-slots-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()
if reopened != after:
raise RuntimeError(f"anim.separate_slots save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {"schemaVersion": 1, "task": "M16-GAP-00205", "operation": "ANIM_SEPARATE_SLOTS_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "poll": poll, "operatorStatus": "FINISHED", "mainMutation": "SLOTS_SEPARATED" if not preserved else "NONE_ALREADY_SEPARATED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-separate-slots-desktop-ok poll=true status=FINISHED mainMutation=slots_separated saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-separate-slots-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECTS = ("WebGapAnimMoveSlotA", "WebGapAnimMoveSlotB")
OLD_ACTION = "WebGapAnimMoveSlotAction"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
if action is None:
return {"name": None, "channels": []}
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": curve.array_index, "frames": [round(float(key.co.x), 6) for key in curve.keyframe_points], "values": [round(float(key.co.y), 6) for key in curve.keyframe_points]})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
objects = {name: bpy.data.objects.get(name) for name in OBJECTS}
if any(obj is None for obj in objects.values()):
raise RuntimeError("slot_channels_move fixture objects are missing")
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"objects": {name: action_report(obj) for name, obj in objects.items()},
"actions": {action.name: int(action.users) for action in bpy.data.actions},
}
def is_moved(state):
return state["objects"][OBJECTS[0]]["name"] != OLD_ACTION and state["objects"][OBJECTS[1]]["name"] == OLD_ACTION
def run_operator():
window = bpy.context.window
screen = window.screen
area = next(candidate for candidate in screen.areas if candidate.type == "DOPESHEET_EDITOR" and candidate.height >= 200)
area.spaces.active.ui_mode = "ACTION"
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
with bpy.context.temp_override(window=window, screen=screen, area=area, region=region):
poll = bool(bpy.ops.anim.slot_channels_move_to_new_action.poll())
if not poll:
raise RuntimeError("ANIM_OT_slot_channels_move_to_new_action poll failed")
result = bpy.ops.anim.slot_channels_move_to_new_action()
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_slot_channels_move_to_new_action returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-slot-channels-move-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=True)
before = state_report()
preserved = is_moved(before)
if preserved:
poll = True
result = {"FINISHED"}
else:
poll, result = run_operator()
after = state_report()
if not is_moved(after):
raise RuntimeError(f"slot_channels_move did not move the selected slot: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-slot-channels-move-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=True)
reopened = state_report()
if reopened != after:
raise RuntimeError(f"anim.slot_channels_move_to_new_action save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {"schemaVersion": 1, "task": "M16-GAP-00206", "operation": "ANIM_SLOT_CHANNELS_MOVE_TO_NEW_ACTION_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before, "after": reopened, "poll": poll, "operatorStatus": "FINISHED", "mainMutation": "SLOT_MOVED_TO_NEW_ACTION" if not preserved else "NONE_ALREADY_MOVED", "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-slot-channels-move-desktop-ok poll=true status=FINISHED mainMutation=slot_moved saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-slot-channels-move-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimNewSlotObject"
ACTION_NAME = "WebGapAnimNewSlotAction"
SLOT_NAME = "OBWebGapAnimNewSlot"
def action_report(action):
channels = []
if action is not None:
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
})
channels.sort(key=lambda value: (value["path"], value["index"], value["frames"], value["values"]))
return channels
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None or obj.animation_data is None or obj.animation_data.action is None:
raise RuntimeError("slot_new_for_id fixture object or action is missing")
action = obj.animation_data.action
slot = obj.animation_data.action_slot
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"action": action.name,
"slot": slot.identifier if slot else None,
"slots": sorted(slot.identifier for slot in action.slots),
"channels": action_report(action),
}
def is_created(state):
return (
state["action"] == ACTION_NAME
and state["slot"] == f"{SLOT_NAME}.001"
and state["slots"] == [SLOT_NAME, f"{SLOT_NAME}.001"]
and len(state["channels"]) == 2
)
def run_operator(obj):
window = bpy.context.window
screen = window.screen
area = next((candidate for candidate in screen.areas if candidate.height >= 200), None)
region = next((candidate for candidate in area.regions if candidate.type == "WINDOW"), None) if area else None
override = {"window": window, "screen": screen, "area": area, "region": region, "animated_id": obj}
with bpy.context.temp_override(**{key: value for key, value in override.items() if value is not None}):
poll = bool(bpy.ops.anim.slot_new_for_id.poll())
if not poll:
raise RuntimeError("ANIM_OT_slot_new_for_id poll failed")
result = bpy.ops.anim.slot_new_for_id()
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_slot_new_for_id returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-slot-new-for-id-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
obj = bpy.data.objects.get(OBJECT_NAME)
before = state_report()
preserved = is_created(before)
if preserved:
poll = True
result = {"FINISHED"}
else:
if before["action"] != ACTION_NAME or before["slot"] != SLOT_NAME or before["slots"] != [SLOT_NAME] or len(before["channels"]) != 1:
raise RuntimeError(f"slot_new_for_id source state is wrong: {before}")
poll, result = run_operator(obj)
after = state_report()
if not is_created(after):
raise RuntimeError(f"slot_new_for_id did not duplicate the assigned slot: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-slot-new-for-id-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()
if reopened != after:
raise RuntimeError(f"anim.slot_new_for_id save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00207",
"operation": "ANIM_SLOT_NEW_FOR_ID_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "SLOT_DUPLICATED" if not preserved else "NONE_ALREADY_DUPLICATED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-slot-new-for-id-desktop-ok poll=true status=FINISHED mainMutation=slot_duplicated saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-slot-new-for-id-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimConstraintSlotObject"
CONSTRAINT_NAME = "WebGapActionSlotConstraint"
ACTION_NAME = "WebGapAnimConstraintSlotAction"
SLOT_IDENTIFIER = "OBWebGapConstraintSlot"
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None:
raise RuntimeError("slot_unassign_from_constraint fixture object is missing")
constraint = next((value for value in obj.constraints if value.name == CONSTRAINT_NAME), None)
if constraint is None or constraint.type != "ACTION":
raise RuntimeError("slot_unassign_from_constraint action constraint is missing")
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"constraint": constraint.name,
"action": constraint.action.name if constraint.action else None,
"actionSlot": constraint.action_slot.identifier if constraint.action_slot else None,
"actionSlotHandle": int(constraint.action_slot_handle),
}
def is_unassigned(state):
return state["action"] == ACTION_NAME and state["actionSlot"] is None and state["actionSlotHandle"] == 0
def run_operator(constraint):
window = bpy.context.window
screen = window.screen
area = next((candidate for candidate in screen.areas if candidate.height >= 200), None)
region = next((candidate for candidate in area.regions if candidate.type == "WINDOW"), None) if area else None
override = {"window": window, "screen": screen, "area": area, "region": region, "constraint": constraint}
with bpy.context.temp_override(**{key: value for key, value in override.items() if value is not None}):
poll = bool(bpy.ops.anim.slot_unassign_from_constraint.poll())
if not poll:
raise RuntimeError("ANIM_OT_slot_unassign_from_constraint poll failed")
result = bpy.ops.anim.slot_unassign_from_constraint()
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_slot_unassign_from_constraint returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-slot-unassign-from-constraint-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
obj = bpy.data.objects.get(OBJECT_NAME)
constraint = next((value for value in obj.constraints if value.name == CONSTRAINT_NAME), None) if obj else None
before = state_report()
preserved = is_unassigned(before)
if preserved:
poll = True
result = {"FINISHED"}
else:
if before["action"] != ACTION_NAME or before["actionSlot"] != SLOT_IDENTIFIER or before["actionSlotHandle"] == 0:
raise RuntimeError(f"slot_unassign_from_constraint source state is wrong: {before}")
poll, result = run_operator(constraint)
after = state_report()
if not is_unassigned(after):
raise RuntimeError(f"slot_unassign_from_constraint did not clear the assigned slot: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-slot-unassign-constraint-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()
if reopened != after:
raise RuntimeError(f"anim.slot_unassign_from_constraint save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00208",
"operation": "ANIM_SLOT_UNASSIGN_FROM_CONSTRAINT_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "CONSTRAINT_SLOT_UNASSIGNED" if not preserved else "NONE_ALREADY_UNASSIGNED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-slot-unassign-from-constraint-desktop-ok poll=true status=FINISHED mainMutation=constraint_slot_unassigned saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-slot-unassign-from-constraint-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimUnassignIdObject"
ACTION_NAME = "WebGapAnimUnassignIdAction"
SLOT_IDENTIFIER = "OBWebGapUnassignIdSlot"
def action_report(obj):
action = obj.animation_data.action if obj and obj.animation_data else None
channels = []
if action is not None:
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({
"path": curve.data_path,
"index": curve.array_index,
"frames": [round(float(key.co.x), 6) for key in curve.keyframe_points],
"values": [round(float(key.co.y), 6) for key in curve.keyframe_points],
})
channels.sort(key=lambda value: (value["path"], value["index"], value["frames"], value["values"]))
return channels
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None or obj.animation_data is None:
raise RuntimeError("slot_unassign_from_id fixture object or animation data is missing")
action = obj.animation_data.action
slot = obj.animation_data.action_slot
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"action": action.name if action else None,
"actionSlot": slot.identifier if slot else None,
"actionSlotHandle": int(obj.animation_data.action_slot_handle),
"lastSlotIdentifier": obj.animation_data.last_slot_identifier,
"channels": action_report(obj),
}
def is_unassigned(state):
return state["action"] == ACTION_NAME and state["actionSlot"] is None and state["actionSlotHandle"] == 0 and state["lastSlotIdentifier"] == SLOT_IDENTIFIER
def run_operator(obj):
window = bpy.context.window
screen = window.screen
area = next((candidate for candidate in screen.areas if candidate.height >= 200), None)
region = next((candidate for candidate in area.regions if candidate.type == "WINDOW"), None) if area else None
override = {"window": window, "screen": screen, "area": area, "region": region, "animated_id": obj}
with bpy.context.temp_override(**{key: value for key, value in override.items() if value is not None}):
poll = bool(bpy.ops.anim.slot_unassign_from_id.poll())
if not poll:
raise RuntimeError("ANIM_OT_slot_unassign_from_id poll failed")
result = bpy.ops.anim.slot_unassign_from_id()
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_slot_unassign_from_id returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-slot-unassign-from-id-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
obj = bpy.data.objects.get(OBJECT_NAME)
before = state_report()
preserved = is_unassigned(before)
if preserved:
poll = True
result = {"FINISHED"}
else:
if before["action"] != ACTION_NAME or before["actionSlot"] != SLOT_IDENTIFIER or before["actionSlotHandle"] == 0 or before["lastSlotIdentifier"] != SLOT_IDENTIFIER:
raise RuntimeError(f"slot_unassign_from_id source state is wrong: {before}")
poll, result = run_operator(obj)
after = state_report()
if not is_unassigned(after):
raise RuntimeError(f"slot_unassign_from_id did not clear the assigned slot: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-slot-unassign-id-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()
if reopened != after:
raise RuntimeError(f"anim.slot_unassign_from_id save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00209",
"operation": "ANIM_SLOT_UNASSIGN_FROM_ID_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "ID_SLOT_UNASSIGNED" if not preserved else "NONE_ALREADY_UNASSIGNED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-slot-unassign-from-id-desktop-ok poll=true status=FINISHED mainMutation=id_slot_unassigned saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-slot-unassign-from-id-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimNlaSlotObject"
ACTION_NAME = "WebGapAnimNlaSlotAction"
STRIP_NAME = "WebGapNlaSlotStrip"
SLOT_IDENTIFIER = "OBWebGapNlaSlot"
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None or obj.animation_data is None or not obj.animation_data.nla_tracks:
raise RuntimeError("slot_unassign_from_nla_strip fixture NLA data is missing")
strips = [strip for track in obj.animation_data.nla_tracks for strip in track.strips if strip.name == STRIP_NAME]
if len(strips) != 1:
raise RuntimeError("slot_unassign_from_nla_strip fixture strip is missing")
strip = strips[0]
return {
"activeObject": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"strip": strip.name,
"action": strip.action.name if strip.action else None,
"actionSlot": strip.action_slot.identifier if strip.action_slot else None,
"actionSlotHandle": int(strip.action_slot_handle),
"lastSlotIdentifier": strip.last_slot_identifier,
"frameStart": float(strip.frame_start),
"frameEnd": float(strip.frame_end),
}
def is_unassigned(state):
return state["action"] == ACTION_NAME and state["actionSlot"] is None and state["actionSlotHandle"] == 0 and state["lastSlotIdentifier"] == SLOT_IDENTIFIER
def run_operator(strip):
window = bpy.context.window
screen = window.screen
area = next((candidate for candidate in screen.areas if candidate.height >= 200), None)
region = next((candidate for candidate in area.regions if candidate.type == "WINDOW"), None) if area else None
override = {"window": window, "screen": screen, "area": area, "region": region, "nla_strip": strip}
with bpy.context.temp_override(**{key: value for key, value in override.items() if value is not None}):
poll = bool(bpy.ops.anim.slot_unassign_from_nla_strip.poll())
if not poll:
raise RuntimeError("ANIM_OT_slot_unassign_from_nla_strip poll failed")
result = bpy.ops.anim.slot_unassign_from_nla_strip()
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_slot_unassign_from_nla_strip returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-slot-unassign-from-nla-strip-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
obj = bpy.data.objects.get(OBJECT_NAME)
strip = next((strip for track in obj.animation_data.nla_tracks for strip in track.strips if strip.name == STRIP_NAME), None) if obj and obj.animation_data else None
before = state_report()
preserved = is_unassigned(before)
if preserved:
poll = True
result = {"FINISHED"}
else:
if before["action"] != ACTION_NAME or before["actionSlot"] != SLOT_IDENTIFIER or before["actionSlotHandle"] == 0 or before["lastSlotIdentifier"] != SLOT_IDENTIFIER:
raise RuntimeError(f"slot_unassign_from_nla_strip source state is wrong: {before}")
poll, result = run_operator(strip)
after = state_report()
if not is_unassigned(after):
raise RuntimeError(f"slot_unassign_from_nla_strip did not clear the assigned slot: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-slot-unassign-nla-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()
if reopened != after:
raise RuntimeError(f"anim.slot_unassign_from_nla_strip save/reopen drift: {after} != {reopened}")
if not preserved:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
report = {
"schemaVersion": 1,
"task": "M16-GAP-00210",
"operation": "ANIM_SLOT_UNASSIGN_FROM_NLA_STRIP_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "NLA_SLOT_UNASSIGNED" if not preserved else "NONE_ALREADY_UNASSIGNED",
"saveReopen": "EXACT",
"blenderVersion": bpy.app.version_string,
}
if preserved:
report["evidenceStatus"] = "PRESERVED"
if not preserved or not output.exists():
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-slot-unassign-from-nla-strip-desktop-ok poll=true status=FINISHED mainMutation=nla_slot_unassigned saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-slot-unassign-from-nla-strip-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import shutil
import sys
import tempfile
import bpy
def state_report(scene):
return {
"current": int(scene.frame_current),
"start": int(scene.frame_start),
"end": int(scene.frame_end),
}
def write_report(fixture, output, state):
report = {
"schemaVersion": 1,
"task": "M16-GAP-00211",
"operation": "ANIM_START_FRAME_SET_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"frame": state,
"poll": True,
"operatorStatus": "FINISHED",
"mainMutation": "FRAME_START_SET",
"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")
def foreground_start_frame_set(fixture, output):
state = {"started": False}
def finish_failure(message):
print(f"anim-start-frame-set-desktop-failed: {message}")
bpy.ops.wm.quit_blender()
return None
def execute():
window = bpy.context.window
if window is None:
return 0.25
scene = bpy.context.scene
before = state_report(scene)
if before["current"] != 42 or before["start"] not in (1, 42) or before["end"] != 120:
return finish_failure(f"unexpected start_frame_set source state: {before}")
area = next((candidate for candidate in window.screen.areas if candidate.type == "TIMELINE"), None)
if area is None:
area = next((candidate for candidate in window.screen.areas if candidate.type in {"DOPESHEET_EDITOR", "GRAPH_EDITOR", "NLA_EDITOR", "SEQUENCE_EDITOR", "CLIP_EDITOR"}), None)
if area is None:
return finish_failure("no animation area available for start_frame_set")
if area.type == "TIMELINE":
area.type = "DOPESHEET_EDITOR"
region = next(candidate for candidate in area.regions if candidate.type == "WINDOW")
try:
with bpy.context.temp_override(window=window, screen=window.screen, area=area, region=region):
poll = bool(bpy.ops.anim.start_frame_set.poll())
result = bpy.ops.anim.start_frame_set()
except Exception as error:
return finish_failure(error)
if not poll or result != {"FINISHED"}:
return finish_failure(f"unexpected ANIM_OT_start_frame_set result: poll={poll} result={result}")
after_operator = state_report(scene)
if after_operator != {"current": 42, "start": 42, "end": 120}:
return finish_failure(f"start_frame_set produced unexpected state: {after_operator}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-start-frame-set-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.context.scene)
if reopened != after_operator:
return finish_failure("anim.start_frame_set save/reopen drift")
shutil.copyfile(temporary_path, fixture)
write_report(fixture, output, reopened)
print("anim-start-frame-set-desktop-ok poll=true status=FINISHED mainMutation=frame_start_set 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
state["started"] = True
bpy.app.timers.register(execute, first_interval=1.0)
return None
bpy.app.timers.register(start, first_interval=1.5)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --python check-action-start-frame-set-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)
if bpy.app.background:
raise RuntimeError("start_frame_set requires a foreground animation area")
foreground_start_frame_set(fixture, output)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-start-frame-set-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimatedTransformConstraintObject"
ACTION_NAME = "WebGapAnimatedTransformConstraintAction"
CONSTRAINT_NAME = "WebGapAnimatedTransformConstraint"
OLD_PATH = f'constraints["{CONSTRAINT_NAME}"].from_min_x'
NEW_PATH = f'constraints["{CONSTRAINT_NAME}"].from_min_x_rot'
def action_fcurves(obj):
action = obj.animation_data.action if obj.animation_data else None
if action is None:
raise RuntimeError("update_animated_transform_constraints action is missing")
curves = []
for layer in action.layers:
for strip in layer.strips:
for channelbag in strip.channelbags:
curves.extend(channelbag.fcurves)
return curves
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None or obj.animation_data is None:
raise RuntimeError("update_animated_transform_constraints object animation is missing")
constraint = obj.constraints.get(CONSTRAINT_NAME)
if constraint is None:
raise RuntimeError("update_animated_transform_constraints Transform constraint is missing")
return {
"object": obj.name,
"action": obj.animation_data.action.name if obj.animation_data.action else None,
"constraint": constraint.name,
"mapFrom": constraint.map_from,
"channels": [
{
"path": curve.data_path,
"index": int(curve.array_index),
"frames": [float(key.co.x) for key in curve.keyframe_points],
"values": [float(key.co.y) for key in curve.keyframe_points],
}
for curve in action_fcurves(obj)
],
}
def run_operator():
poll = bool(bpy.ops.anim.update_animated_transform_constraints.poll())
if not poll:
raise RuntimeError("ANIM_OT_update_animated_transform_constraints poll failed")
result = bpy.ops.anim.update_animated_transform_constraints(use_convert_to_radians=True)
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_update_animated_transform_constraints returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-update-animated-transform-constraints-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["action"] != ACTION_NAME or len(before["channels"]) != 1 or before["channels"][0]["path"] not in {OLD_PATH, NEW_PATH}:
raise RuntimeError(f"unexpected update_animated_transform_constraints source state: {before}")
poll, result = run_operator()
after = state_report()
if after["action"] != ACTION_NAME or after["mapFrom"] != "ROTATION" or len(after["channels"]) != 1:
raise RuntimeError(f"update_animated_transform_constraints produced unexpected state: {after}")
channel = after["channels"][0]
if channel["path"] != NEW_PATH or channel["frames"] != [1.0, 10.0] or channel["values"] != [-30.0, 60.0]:
raise RuntimeError(f"update_animated_transform_constraints path/value drift: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-update-transform-constraints-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()
if reopened != after:
raise RuntimeError(f"anim.update_animated_transform_constraints save/reopen drift: {after} != {reopened}")
if before["channels"][0]["path"] == OLD_PATH:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
if not output.exists():
report = {
"schemaVersion": 1,
"task": "M16-GAP-00212",
"operation": "ANIM_UPDATE_ANIMATED_TRANSFORM_CONSTRAINTS_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"useConvertToRadians": True,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "TRANSFORM_CONSTRAINT_PATHS_UPDATED",
"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-update-animated-transform-constraints-desktop-ok poll=true status=FINISHED mainMutation=transform_constraint_paths_updated saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-update-animated-transform-constraints-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
ARMATURE_NAME = "WebGapVersionBoneHideArmature"
OBJECT_NAME = "WebGapVersionBoneHideObject"
BONE_NAME = "WebGapVersionBoneHideBone"
ARMATURE_ACTION_NAME = "WebGapVersionBoneHideArmatureAction"
OBJECT_ACTION_NAME = "WebGapVersionBoneHideObjectAction"
OLD_PATH = f'bones["{BONE_NAME}"].hide'
NEW_PATH = f'pose.bones["{BONE_NAME}"].hide'
def action_fcurves(id_block):
action = id_block.animation_data.action if id_block.animation_data else None
if action is None:
raise RuntimeError(f"{id_block.name} animation action is missing")
curves = []
for layer in action.layers:
for strip in layer.strips:
for channelbag in strip.channelbags:
curves.extend(channelbag.fcurves)
return curves
def curve_report(curves):
return [
{
"path": curve.data_path,
"index": int(curve.array_index),
"frames": [float(key.co.x) for key in curve.keyframe_points],
"values": [float(key.co.y) for key in curve.keyframe_points],
}
for curve in curves
]
def state_report():
armature = bpy.data.armatures.get(ARMATURE_NAME)
obj = bpy.data.objects.get(OBJECT_NAME)
if armature is None or obj is None or obj.type != "ARMATURE" or obj.data != armature:
raise RuntimeError("version_bone_hide_property armature fixture is missing")
return {
"selected": obj.select_get(),
"active": bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,
"armatureAction": armature.animation_data.action.name if armature.animation_data and armature.animation_data.action else None,
"armatureChannels": curve_report(action_fcurves(armature)),
"objectAction": obj.animation_data.action.name if obj.animation_data and obj.animation_data.action else None,
"objectChannels": curve_report(action_fcurves(obj)),
}
def run_operator():
poll = bool(bpy.ops.anim.version_bone_hide_property.poll())
if not poll:
raise RuntimeError("ANIM_OT_version_bone_hide_property poll failed")
result = bpy.ops.anim.version_bone_hide_property()
if result != {"FINISHED"}:
raise RuntimeError(f"ANIM_OT_version_bone_hide_property returned {result}")
return poll, result
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender -b --factory-startup --python check-action-version-bone-hide-property-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=False)
before = state_report()
if before["armatureAction"] != ARMATURE_ACTION_NAME or before["objectAction"] != OBJECT_ACTION_NAME:
raise RuntimeError(f"unexpected version_bone_hide_property source actions: {before}")
if len(before["armatureChannels"]) != 1 or before["armatureChannels"][0]["path"] != OLD_PATH:
raise RuntimeError(f"unexpected armature hide channel: {before}")
object_has_new = any(channel["path"] == NEW_PATH for channel in before["objectChannels"])
poll = True
if not object_has_new:
poll, _result = run_operator()
after = state_report()
if not after["selected"] or after["active"] != OBJECT_NAME:
raise RuntimeError(f"version_bone_hide_property lost armature selection: {after}")
if len(after["armatureChannels"]) != 1 or after["armatureChannels"][0]["path"] != OLD_PATH:
raise RuntimeError(f"version_bone_hide_property changed source action: {after}")
copied = [channel for channel in after["objectChannels"] if channel["path"] == NEW_PATH]
if len(copied) != 1 or copied[0]["frames"] != [1.0, 10.0] or copied[0]["values"] != [0.0, 1.0]:
raise RuntimeError(f"version_bone_hide_property did not copy the hide channel: {after}")
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-version-bone-hide-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()
if reopened != after:
raise RuntimeError(f"anim.version_bone_hide_property save/reopen drift: {after} != {reopened}")
if not object_has_new:
bpy.ops.wm.save_as_mainfile(filepath=str(fixture), check_existing=False, compress=True)
if not output.exists():
report = {
"schemaVersion": 1,
"task": "M16-GAP-00213",
"operation": "ANIM_VERSION_BONE_HIDE_PROPERTY_DESKTOP",
"fixture": str(fixture),
"fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(),
"before": before,
"after": reopened,
"poll": poll,
"operatorStatus": "FINISHED",
"mainMutation": "BONE_HIDE_FCURVE_MOVED_TO_OBJECT_ACTION",
"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-version-bone-hide-property-desktop-ok poll=true status=FINISHED mainMutation=bone_hide_fcurve_moved saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-version-bone-hide-property-desktop-failed: {error}")
raise SystemExit(1)

View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
import hashlib
import json
import os
import pathlib
import sys
import tempfile
import bpy
OBJECT_NAME = "WebGapAnimViewCurveGraphEditorObject"
ACTION_NAME = "WebGapAnimViewCurveGraphEditorAction"
PROPERTY_NAME = "curve_target"
def view_report(area):
region = next((candidate for candidate in area.regions if candidate.type == "WINDOW"), None)
if region is None:
raise RuntimeError("Graph Editor window region is missing")
view = region.view2d
xmin, ymin = view.region_to_view(0, 0)
xmax, ymax = view.region_to_view(region.width - 1, region.height - 1)
return {
"areaType": area.type,
"mode": area.spaces.active.mode,
"view2d": {
"cur": {"xmin": round(float(xmin), 6), "xmax": round(float(xmax), 6), "ymin": round(float(ymin), 6), "ymax": round(float(ymax), 6)},
"mask": {"xmin": 0, "xmax": int(region.width - 1), "ymin": 0, "ymax": int(region.height - 1)},
},
}
def action_report(obj):
action = obj.animation_data.action if obj.animation_data else None
if action is None:
raise RuntimeError("Graph Editor Action is missing")
channels = []
for layer in action.layers:
for strip in layer.strips:
for bag in strip.channelbags:
for curve in bag.fcurves:
channels.append({"path": curve.data_path, "index": int(curve.array_index), "frames": [float(key.co.x) for key in curve.keyframe_points], "values": [float(key.co.y) for key in curve.keyframe_points], "selected": bool(curve.select)})
channels.sort(key=lambda value: (value["path"], value["index"]))
return {"name": action.name, "channels": channels}
def state_report():
obj = bpy.data.objects.get(OBJECT_NAME)
if obj is None or obj.animation_data is None:
raise RuntimeError("view_curve_in_graph_editor fixture object is missing")
area = next((candidate for screen in bpy.data.screens for candidate in screen.areas if candidate.type == "GRAPH_EDITOR"), None)
if area is None:
raise RuntimeError("Graph Editor area is missing")
return {"selected": bool(obj.select_get()), "active": bpy.context.view_layer.objects.active == obj, "value": round(float(obj[PROPERTY_NAME]), 6), "action": action_report(obj), "view": view_report(area)}
class ViewCurvePanel(bpy.types.Panel):
bl_label = "View Curve Fixture"
bl_idname = "WEBGAP_PT_view_curve_in_graph_editor"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "object"
def draw(self, context):
if context.object is not None:
self.layout.prop(context.object, f'["{PROPERTY_NAME}"]', text=PROPERTY_NAME)
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 2:
raise SystemExit("usage: blender --factory-startup --python check-action-view-curve-in-graph-editor-desktop.py -- FIXTURE REPORT")
fixture, output = (pathlib.Path(value).resolve() for value in arguments)
bpy.ops.wm.open_mainfile(filepath=str(fixture), load_ui=True)
before = state_report()
expected_channel = {"path": f'["{PROPERTY_NAME}"]', "index": 0, "frames": [1.0, 10.0], "values": [-3.0, 7.0], "selected": True}
if before["action"]["name"] != ACTION_NAME or before["action"]["channels"] != [expected_channel]:
raise RuntimeError(f"unexpected source action: {before}")
def finish_report(before_state, after_state, poll, operator_status, main_mutation):
descriptor, temporary = tempfile.mkstemp(prefix="m16-anim-view-curve-graph-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=True)
reopened = state_report()
if reopened != after_state:
raise RuntimeError(f"view_curve_in_graph_editor save/reopen drift: {after_state} != {reopened}")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps({"schemaVersion": 1, "task": "M16-GAP-00214", "operation": "ANIM_VIEW_CURVE_IN_GRAPH_EDITOR_DESKTOP", "fixture": str(fixture), "fixtureSha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), "before": before_state, "after": reopened, "poll": poll, "operatorStatus": operator_status, "mainMutation": main_mutation, "saveReopen": "EXACT", "blenderVersion": bpy.app.version_string}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"anim-view-curve-in-graph-editor-desktop-ok poll={str(poll).lower()} status={operator_status} mainMutation={main_mutation.lower()} saveReopen=exact")
finally:
temporary_path.unlink(missing_ok=True)
bpy.utils.register_class(ViewCurvePanel)
if bpy.app.background:
try:
screen = bpy.context.screen
properties_area = next(candidate for candidate in screen.areas if candidate.type == "PROPERTIES")
properties_region = next(candidate for candidate in properties_area.regions if candidate.type == "WINDOW")
with bpy.context.temp_override(window=bpy.context.window, screen=screen, area=properties_area, region=properties_region):
poll = bool(bpy.ops.anim.view_curve_in_graph_editor.poll())
result = bpy.ops.anim.view_curve_in_graph_editor(all=False, isolate=False) if poll else set()
operator_status = "FINISHED" if "FINISHED" in result else "CANCELLED"
finish_report(before, state_report(), poll, operator_status, "GRAPH_VIEW_FRAMED" if operator_status == "FINISHED" else "NONE")
finally:
bpy.utils.unregister_class(ViewCurvePanel)
return
state = {"started": False, "done": False}
def blender_window():
import subprocess
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 activate_property_button(window_id):
import subprocess
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} 1185 645; " "xdotool click 1")
subprocess.Popen(["sh", "-c", sequence], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def poll_result():
if state["done"]:
return None
screen = bpy.context.screen
properties_area = next((candidate for candidate in screen.areas if candidate.type == "PROPERTIES"), None)
if properties_area is None:
return 0.25
properties_region = next(candidate for candidate in properties_area.regions if candidate.type == "WINDOW")
with bpy.context.temp_override(window=bpy.context.window, screen=screen, area=properties_area, region=properties_region):
poll = bool(bpy.ops.anim.view_curve_in_graph_editor.poll())
result = bpy.ops.anim.view_curve_in_graph_editor(all=False, isolate=False) if poll else set()
if "FINISHED" not in result:
return 0.25
state["done"] = True
after = state_report()
finish_report(before, after, poll, "FINISHED", "GRAPH_VIEW_FRAMED")
bpy.utils.unregister_class(ViewCurvePanel)
bpy.ops.wm.quit_blender()
return None
def drive():
if state["started"]:
return 0.25
state["started"] = True
activate_property_button(blender_window())
bpy.app.timers.register(poll_result, first_interval=2.5)
return None
bpy.app.timers.register(drive, first_interval=1.5)
bpy.app.timers.register(lambda: None if state["done"] else (_ for _ in ()).throw(RuntimeError("UI Graph Editor operator timed out")), first_interval=30.0)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"anim-view-curve-in-graph-editor-desktop-failed: {error}")
raise SystemExit(1)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00175.py -- OUTPUT")
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapAnimDriverButtonEditMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2, 3)],
)
obj = bpy.data.objects.new("WebGapAnimDriverButtonEditObject", mesh)
bpy.context.scene.collection.objects.link(obj)
obj["drive_target"] = 7.25
driver = obj.driver_add('["drive_target"]')
driver.driver.type = "SCRIPTED"
driver.driver.expression = "frame * 2.5 + 1.25"
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 5
bpy.context.scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(
filepath=str(pathlib.Path(arguments[0]).resolve()), check_existing=False, compress=True
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00176.py -- OUTPUT")
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapAnimDriverButtonRemoveMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2, 3)],
)
obj = bpy.data.objects.new("WebGapAnimDriverButtonRemoveObject", mesh)
bpy.context.scene.collection.objects.link(obj)
obj["drive_target"] = 7.25
driver = obj.driver_add('["drive_target"]')
driver.driver.type = "SCRIPTED"
driver.driver.expression = "frame * 2.5 + 1.25"
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 5
bpy.context.scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(
filepath=str(pathlib.Path(arguments[0]).resolve()), check_existing=False, compress=True
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00177.py -- OUTPUT")
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = 120
scene.frame_set(42)
bpy.ops.wm.save_as_mainfile(
filepath=str(pathlib.Path(arguments[0]).resolve()), check_existing=False, compress=True
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00178.py -- OUTPUT")
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapAnimKeyframeClearButtonMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2, 3)],
)
obj = bpy.data.objects.new("WebGapAnimKeyframeClearButtonObject", mesh)
bpy.context.scene.collection.objects.link(obj)
obj["clear_target"] = 3.0
for frame, value in ((1, 1.0), (3, 3.0), (5, 5.0)):
bpy.context.scene.frame_set(frame)
obj["clear_target"] = value
obj.keyframe_insert(data_path='["clear_target"]', frame=frame)
obj.animation_data.action.name = "WebGapAnimKeyframeClearButtonAction"
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 5
bpy.context.scene.frame_set(3)
bpy.ops.wm.save_as_mainfile(
filepath=str(pathlib.Path(arguments[0]).resolve()), check_existing=False, compress=True
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00179.py -- OUTPUT")
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapAnimKeyframeClearV3DMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2, 3)],
)
obj = bpy.data.objects.new("WebGapAnimKeyframeClearV3DObject", mesh)
bpy.context.scene.collection.objects.link(obj)
obj["clear_target"] = 3.0
for frame, value in ((1, 1.0), (3, 3.0), (5, 5.0)):
bpy.context.scene.frame_set(frame)
obj["clear_target"] = value
obj.keyframe_insert(data_path='["clear_target"]', frame=frame)
obj.animation_data.action.name = "WebGapAnimKeyframeClearV3DAction"
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 5
bpy.context.scene.frame_set(3)
bpy.ops.wm.save_as_mainfile(
filepath=str(pathlib.Path(arguments[0]).resolve()), check_existing=False, compress=True
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00180.py -- OUTPUT")
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
sequence_editor = scene.sequence_editor_create()
source = pathlib.Path(__file__).resolve().parents[3] / "tests/files/web/media/sequencer-frame.png"
strip = sequence_editor.strips.new_image(
name="WebGapAnimKeyframeClearVSEStrip",
filepath=str(source),
channel=1,
frame_start=1,
)
strip.directory = "//../media/"
strip.frame_final_duration = 6
strip.select = True
sequence_editor.active_strip = strip
for frame, value in ((1, 0.25), (3, 0.5), (5, 0.75)):
scene.frame_set(frame)
strip.blend_alpha = value
strip.keyframe_insert(data_path="blend_alpha", frame=frame)
scene.animation_data.action.name = "WebGapAnimKeyframeClearVSEAction"
scene.frame_start = 1
scene.frame_end = 6
scene.frame_set(3)
bpy.ops.wm.save_as_mainfile(
filepath=str(pathlib.Path(arguments[0]).resolve()), check_existing=False, compress=True
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python3
import pathlib
import sys
import bpy
OBJECT_NAME = "WebGapAnimKeyframeDeleteObject"
ACTION_NAME = "WebGapAnimKeyframeDeleteAction"
KEYING_SET_NAME = "WebGapAnimKeyframeDeleteSet"
DATA_PATH = '["delete_target"]'
def main():
arguments = sys.argv[sys.argv.index("--") + 1 :]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --factory-startup --python M16-GAP-00181.py -- OUTPUT")
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("WebGapAnimKeyframeDeleteMesh")
mesh.from_pydata(
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
[],
[(0, 1, 2, 3)],
)
obj = bpy.data.objects.new(OBJECT_NAME, mesh)
bpy.context.scene.collection.objects.link(obj)
obj["delete_target"] = 3.0
for frame, value in ((1, 1.0), (3, 3.0), (5, 5.0)):
bpy.context.scene.frame_set(frame)
obj["delete_target"] = value
obj.keyframe_insert(data_path=DATA_PATH, frame=frame)
obj.animation_data.action.name = ACTION_NAME
keying_set = bpy.context.scene.keying_sets.new(idname=KEYING_SET_NAME, name=KEYING_SET_NAME)
keying_set.paths.add(obj, DATA_PATH, index=0)
bpy.context.scene.keying_sets.active_index = 0
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.scene.frame_start = 1
bpy.context.scene.frame_end = 5
bpy.context.scene.frame_set(3)
bpy.ops.wm.save_as_mainfile(
filepath=str(pathlib.Path(arguments[0]).resolve()), check_existing=False, compress=True
)
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More