From a9a606ca98ea03f1ae735541faf8b2a1832ca22e Mon Sep 17 00:00:00 2001 From: Danilo Nobre Nunes Date: Thu, 11 Jun 2026 22:03:55 -0300 Subject: [PATCH 1/3] test(fixtures): add the end-to-end mixed-feature fixture The one fixture that stacks every Blender-to-Godot feature in a single document: a skinned body polygon (per-bone weights + multi-face polygons), a sprite_frame mouth driven from the jaw bone, a slot with mixed mesh + sprite attachments, every element packed into one shared atlas, and one animation. Single-feature fixtures cannot catch the interactions between builders; this is the safety net before the queued schema-expressiveness wave churns the writer. Lands in the blender_to_godot bucket (new fixtures go there per its README). The golden is auto-discovered by run_tests.py for the test-blender re-export diff; test-godot gains a tests/fixtures copy plus assertions in test_importer.gd that walk the whole stack. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/fixtures/mixed_feature.proscenio | 323 ++++++++++++++++ apps/godot/tests/test_importer.gd | 69 ++++ .../blender_to_godot/mixed_feature/atlas.png | 3 + .../mixed_feature/godot/MixedFeature.gd | 43 +++ .../mixed_feature/godot/MixedFeature.tscn | 19 + .../mixed_feature/mixed_feature.blend | 3 + .../mixed_feature.expected.proscenio | 323 ++++++++++++++++ .../fixtures/mixed_feature/build_blend.py | 360 ++++++++++++++++++ .../fixtures/mixed_feature/draw_layers.py | 75 ++++ 9 files changed, 1218 insertions(+) create mode 100644 apps/godot/tests/fixtures/mixed_feature.proscenio create mode 100644 examples/generated/blender_to_godot/mixed_feature/atlas.png create mode 100644 examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.gd create mode 100644 examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.tscn create mode 100644 examples/generated/blender_to_godot/mixed_feature/mixed_feature.blend create mode 100644 examples/generated/blender_to_godot/mixed_feature/mixed_feature.expected.proscenio create mode 100644 packages/fixtures/mixed_feature/build_blend.py create mode 100644 packages/fixtures/mixed_feature/draw_layers.py diff --git a/apps/godot/tests/fixtures/mixed_feature.proscenio b/apps/godot/tests/fixtures/mixed_feature.proscenio new file mode 100644 index 00000000..c45606ed --- /dev/null +++ b/apps/godot/tests/fixtures/mixed_feature.proscenio @@ -0,0 +1,323 @@ +{ + "format_version": 1, + "name": "mixed_feature", + "pixels_per_unit": 100.0, + "skeleton": { + "bones": [ + { + "name": "root", + "parent": null, + "position": [ + 0.0, + 30.000002 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + }, + { + "name": "spine", + "parent": null, + "position": [ + 0.0, + -10.0 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + }, + { + "name": "head", + "parent": null, + "position": [ + 0.0, + -50.0 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + }, + { + "name": "jaw", + "parent": null, + "position": [ + 40.0, + -30.000002 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + } + ] + }, + "elements": [ + { + "name": "body", + "bone": "root", + "texture_region": [ + 0.0, + 0.5, + 0.5, + 0.5 + ], + "polygon": [ + [ + -20.0, + 19.999998 + ], + [ + 20.0, + 19.999998 + ], + [ + 20.0, + -30.000002 + ], + [ + -20.0, + -30.000002 + ], + [ + 20.0, + -80.0 + ], + [ + -20.0, + -80.0 + ] + ], + "uv": [ + [ + 0.0, + 1.0 + ], + [ + 0.5, + 1.0 + ], + [ + 0.5, + 0.75 + ], + [ + 0.0, + 0.75 + ], + [ + 0.5, + 0.5 + ], + [ + 0.0, + 0.5 + ] + ], + "polygons": [ + [ + 0, + 1, + 2, + 3 + ], + [ + 3, + 2, + 4, + 5 + ] + ], + "texture": "atlas.png", + "weights": [ + { + "bone": "root", + "values": [ + 1.0, + 1.0, + 0.5, + 0.5, + 0.0, + 0.0 + ] + }, + { + "bone": "spine", + "values": [ + 0.0, + 0.0, + 0.5, + 0.5, + 1.0, + 1.0 + ] + } + ] + }, + { + "type": "sprite", + "name": "face_glow", + "bone": "", + "hframes": 2, + "vframes": 1, + "frame": 0, + "centered": true, + "texture_region": [ + 0.5, + 0.0, + 0.5, + 0.5 + ] + }, + { + "name": "face_neutral", + "bone": "", + "texture_region": [ + 0.5, + 0.5, + 0.5, + 0.5 + ], + "polygon": [ + [ + -16.0, + 16.0 + ], + [ + 16.0, + 16.0 + ], + [ + 16.0, + -16.0 + ], + [ + -16.0, + -16.0 + ] + ], + "uv": [ + [ + 0.5, + 1.0 + ], + [ + 1.0, + 1.0 + ], + [ + 1.0, + 0.5 + ], + [ + 0.5, + 0.5 + ] + ], + "texture": "atlas.png" + }, + { + "type": "sprite", + "name": "mouth", + "bone": "head", + "hframes": 4, + "vframes": 1, + "frame": 2, + "centered": true, + "texture_region": [ + 0.0, + 0.0, + 0.5, + 0.5 + ] + } + ], + "slots": [ + { + "name": "face.slot", + "attachments": [ + "face_glow", + "face_neutral" + ], + "default": "face_neutral" + } + ], + "atlas": "atlas.png", + "animations": [ + { + "name": "mixed_anim", + "length": 0.958333, + "loop": true, + "tracks": [ + { + "type": "bone_transform", + "target": "jaw", + "keys": [ + { + "time": 0.0, + "rotation": 0.0 + }, + { + "time": 0.291667, + "rotation": -1.570796 + }, + { + "time": 0.625, + "rotation": 1.570796 + }, + { + "time": 0.958333, + "rotation": 0.0 + } + ] + }, + { + "type": "sprite_frame", + "target": "mouth", + "keys": [ + { + "time": 0.0, + "interp": "constant", + "frame": 2 + }, + { + "time": 0.041667, + "interp": "constant", + "frame": 1 + }, + { + "time": 0.125, + "interp": "constant", + "frame": 0 + }, + { + "time": 0.458333, + "interp": "constant", + "frame": 2 + }, + { + "time": 0.5, + "interp": "constant", + "frame": 3 + }, + { + "time": 0.833333, + "interp": "constant", + "frame": 2 + } + ] + } + ] + } + ] +} diff --git a/apps/godot/tests/test_importer.gd b/apps/godot/tests/test_importer.gd index 3c3f912c..8a2fa518 100644 --- a/apps/godot/tests/test_importer.gd +++ b/apps/godot/tests/test_importer.gd @@ -30,6 +30,7 @@ const FIXTURE := "res://tests/fixtures/dummy.proscenio" const EFFECT_FIXTURE := "res://tests/fixtures/effect.proscenio" const SKINNED_FIXTURE := "res://tests/fixtures/skinned_dummy.proscenio" const SLOTS_FIXTURE := "res://tests/fixtures/slots_demo.proscenio" +const MIXED_FIXTURE := "res://tests/fixtures/mixed_feature.proscenio" var _failures: Array[String] = [] var _passes: int = 0 # gdlint: ignore=unused-private-class-variable @@ -40,6 +41,7 @@ func _initialize() -> void: _run_effect_checks() _run_skinned_checks() _run_slot_checks() + _run_mixed_checks() _finish() @@ -248,6 +250,73 @@ func _run_slot_checks() -> void: character.free() +func _run_mixed_checks() -> void: + # The feature-stack fixture: skinned body + sprite_frame mouth + a slot with + # mixed (mesh + sprite) attachments + a shared atlas + a Drive-from-Bone + # animation, all in one document. + var data := _load_fixture(MIXED_FIXTURE) + if data.is_empty(): + _fail("could not load %s" % MIXED_FIXTURE) + return + + var character := _build_character(data) + _assert_eq(character.name, "mixed_feature", "mixed: root name") + var skeleton: Skeleton2D = character.get_node("Skeleton2D") + + var bones := _collect_descendants_of_type(skeleton, "Bone2D") + _assert_eq(bones.size(), 4, "mixed: bone count") + var bone_names := PackedStringArray() + for bone: Node in bones: + bone_names.append(String(bone.name)) + bone_names.sort() + _assert_eq(", ".join(bone_names), "head, jaw, root, spine", "mixed: bone names") + + # Skinned body: a Polygon2D parented to the skeleton, two bone weights, two faces. + var body := skeleton.find_child("body", true, false) + _assert_true(body != null and body is Polygon2D, "mixed: body is Polygon2D") + if body is Polygon2D: + var poly: Polygon2D = body + _assert_true(poly.get_parent() == skeleton, "mixed: body parented to skeleton (skinned)") + _assert_eq(poly.get_bone_count(), 2, "mixed: body bone count = 2") + _assert_eq(poly.polygons.size(), 2, "mixed: body multi-face polygons = 2") + + # sprite_frame mouth: a 4-frame Sprite2D. + var mouth := skeleton.find_child("mouth", true, false) + _assert_true(mouth != null and mouth is Sprite2D, "mixed: mouth is Sprite2D") + if mouth is Sprite2D: + _assert_eq((mouth as Sprite2D).hframes, 4, "mixed: mouth hframes = 4") + + # Slot with mixed attachments: 'face.slot' sanitizes to 'face_slot'. + var slot_node := skeleton.find_child("face_slot", true, false) + _assert_true(slot_node != null, "mixed: 'face_slot' Node2D anchored") + var neutral: Node = null + var glow: Node = null + if slot_node != null: + neutral = slot_node.find_child("face_neutral", false, false) + glow = slot_node.find_child("face_glow", false, false) + _assert_true( + neutral != null and neutral is Polygon2D, "mixed: face_neutral attachment is Polygon2D" + ) + _assert_true(glow != null and glow is Sprite2D, "mixed: face_glow attachment is Sprite2D") + _assert_true( + neutral != null and (neutral as CanvasItem).visible, "mixed: default attachment visible" + ) + _assert_true( + glow != null and not (glow as CanvasItem).visible, "mixed: non-default attachment hidden" + ) + + # One animation carrying the driven mouth frames plus the jaw bone track. + var player: AnimationPlayer = character.get_node("AnimationPlayer") + _assert_true(player.has_animation("mixed_anim"), "mixed: mixed_anim present") + if player.has_animation("mixed_anim"): + var anim := player.get_animation("mixed_anim") + _assert_true(anim.get_track_count() >= 2, "mixed: mixed_anim has a bone + a frame track") + + _assert_saved_scene_has_no_scripts(character, "mixed") + + character.free() + + func _build_character(data: Dictionary) -> Node2D: var document: ProscenioDocumentRes = ProscenioDocumentRes.from_dict(data) var character := Node2D.new() diff --git a/examples/generated/blender_to_godot/mixed_feature/atlas.png b/examples/generated/blender_to_godot/mixed_feature/atlas.png new file mode 100644 index 00000000..1959c297 --- /dev/null +++ b/examples/generated/blender_to_godot/mixed_feature/atlas.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee57f285143b722bebee17778ac88de4a392af25e5a973ad933bb634862929a7 +size 632 diff --git a/examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.gd b/examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.gd new file mode 100644 index 00000000..2119e0f4 --- /dev/null +++ b/examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.gd @@ -0,0 +1,43 @@ +@tool +class_name MixedFeature +extends Node2D + +## Documentation-by-example wrapper for the imported [code]mixed_feature.scn[/code]. +## +## The feature-stack fixture - the one document that exercises every +## Blender-to-Godot feature at once: a skinned [code]body[/code] polygon, +## a sprite_frame [code]mouth[/code] driven from the [code]jaw[/code] bone, +## a slot ([code]face.slot[/code]) holding one mesh and one sprite +## attachment, all packed into a single [code]atlas.png[/code], plus the +## [code]mixed_anim[/code] action keying the jaw (which the driver projects +## onto the mouth frame). +## +## Use this fixture when a change could touch more than one builder at once +## ([code]mesh_builder.gd[/code] / [code]sprite_builder.gd[/code] / +## [code]slot_builder.gd[/code] / [code]animation_builder.gd[/code]); the +## single-feature fixtures cannot catch interactions between them. + +@export var default_animation: StringName = "mixed_anim" +@export var autoplay: bool = true + +@onready var _player: AnimationPlayer = _find_player() + + +func _ready() -> void: + if not autoplay: + return + if _player == null: + push_warning("MixedFeature: no AnimationPlayer found in the imported scene") + return + if not _player.has_animation(default_animation): + push_warning("MixedFeature: animation '%s' not in library" % default_animation) + return + _player.play(default_animation) + + +func _find_player() -> AnimationPlayer: + for child: Node in get_children(): + var found: AnimationPlayer = child.find_child("AnimationPlayer", true, false) + if found != null: + return found + return null diff --git a/examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.tscn b/examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.tscn new file mode 100644 index 00000000..b07a8239 --- /dev/null +++ b/examples/generated/blender_to_godot/mixed_feature/godot/MixedFeature.tscn @@ -0,0 +1,19 @@ +; Documentation-by-example wrapper scene for the Proscenio mixed_feature fixture. +; +; The single fixture that stacks every Blender-to-Godot feature: a skinned +; body polygon, a sprite_frame mouth driven from a bone, a slot with mixed +; (mesh + sprite) attachments, a shared atlas, and one animation. Wrapper +; instances the imported scene so extra nodes survive a reimport. +; +; Drop `examples/generated/blender_to_godot/mixed_feature/` at +; `res://examples/mixed_feature/` in your Godot project, or adjust paths. + +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://examples/mixed_feature/mixed_feature.proscenio" id="1_mixed"] +[ext_resource type="Script" path="res://examples/mixed_feature/godot/MixedFeature.gd" id="2_mixed"] + +[node name="MixedFeature" type="Node2D"] +script = ExtResource("2_mixed") + +[node name="MixedFeatureCharacter" parent="." instance=ExtResource("1_mixed")] diff --git a/examples/generated/blender_to_godot/mixed_feature/mixed_feature.blend b/examples/generated/blender_to_godot/mixed_feature/mixed_feature.blend new file mode 100644 index 00000000..ae8cf388 --- /dev/null +++ b/examples/generated/blender_to_godot/mixed_feature/mixed_feature.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:101edf050c6318f2f1acc3b221a07622e326b94743973bc45a0d8955405fb06e +size 100353 diff --git a/examples/generated/blender_to_godot/mixed_feature/mixed_feature.expected.proscenio b/examples/generated/blender_to_godot/mixed_feature/mixed_feature.expected.proscenio new file mode 100644 index 00000000..c45606ed --- /dev/null +++ b/examples/generated/blender_to_godot/mixed_feature/mixed_feature.expected.proscenio @@ -0,0 +1,323 @@ +{ + "format_version": 1, + "name": "mixed_feature", + "pixels_per_unit": 100.0, + "skeleton": { + "bones": [ + { + "name": "root", + "parent": null, + "position": [ + 0.0, + 30.000002 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + }, + { + "name": "spine", + "parent": null, + "position": [ + 0.0, + -10.0 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + }, + { + "name": "head", + "parent": null, + "position": [ + 0.0, + -50.0 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + }, + { + "name": "jaw", + "parent": null, + "position": [ + 40.0, + -30.000002 + ], + "rotation": -0.0, + "scale": [ + 1.0, + 1.0 + ], + "length": 50.0 + } + ] + }, + "elements": [ + { + "name": "body", + "bone": "root", + "texture_region": [ + 0.0, + 0.5, + 0.5, + 0.5 + ], + "polygon": [ + [ + -20.0, + 19.999998 + ], + [ + 20.0, + 19.999998 + ], + [ + 20.0, + -30.000002 + ], + [ + -20.0, + -30.000002 + ], + [ + 20.0, + -80.0 + ], + [ + -20.0, + -80.0 + ] + ], + "uv": [ + [ + 0.0, + 1.0 + ], + [ + 0.5, + 1.0 + ], + [ + 0.5, + 0.75 + ], + [ + 0.0, + 0.75 + ], + [ + 0.5, + 0.5 + ], + [ + 0.0, + 0.5 + ] + ], + "polygons": [ + [ + 0, + 1, + 2, + 3 + ], + [ + 3, + 2, + 4, + 5 + ] + ], + "texture": "atlas.png", + "weights": [ + { + "bone": "root", + "values": [ + 1.0, + 1.0, + 0.5, + 0.5, + 0.0, + 0.0 + ] + }, + { + "bone": "spine", + "values": [ + 0.0, + 0.0, + 0.5, + 0.5, + 1.0, + 1.0 + ] + } + ] + }, + { + "type": "sprite", + "name": "face_glow", + "bone": "", + "hframes": 2, + "vframes": 1, + "frame": 0, + "centered": true, + "texture_region": [ + 0.5, + 0.0, + 0.5, + 0.5 + ] + }, + { + "name": "face_neutral", + "bone": "", + "texture_region": [ + 0.5, + 0.5, + 0.5, + 0.5 + ], + "polygon": [ + [ + -16.0, + 16.0 + ], + [ + 16.0, + 16.0 + ], + [ + 16.0, + -16.0 + ], + [ + -16.0, + -16.0 + ] + ], + "uv": [ + [ + 0.5, + 1.0 + ], + [ + 1.0, + 1.0 + ], + [ + 1.0, + 0.5 + ], + [ + 0.5, + 0.5 + ] + ], + "texture": "atlas.png" + }, + { + "type": "sprite", + "name": "mouth", + "bone": "head", + "hframes": 4, + "vframes": 1, + "frame": 2, + "centered": true, + "texture_region": [ + 0.0, + 0.0, + 0.5, + 0.5 + ] + } + ], + "slots": [ + { + "name": "face.slot", + "attachments": [ + "face_glow", + "face_neutral" + ], + "default": "face_neutral" + } + ], + "atlas": "atlas.png", + "animations": [ + { + "name": "mixed_anim", + "length": 0.958333, + "loop": true, + "tracks": [ + { + "type": "bone_transform", + "target": "jaw", + "keys": [ + { + "time": 0.0, + "rotation": 0.0 + }, + { + "time": 0.291667, + "rotation": -1.570796 + }, + { + "time": 0.625, + "rotation": 1.570796 + }, + { + "time": 0.958333, + "rotation": 0.0 + } + ] + }, + { + "type": "sprite_frame", + "target": "mouth", + "keys": [ + { + "time": 0.0, + "interp": "constant", + "frame": 2 + }, + { + "time": 0.041667, + "interp": "constant", + "frame": 1 + }, + { + "time": 0.125, + "interp": "constant", + "frame": 0 + }, + { + "time": 0.458333, + "interp": "constant", + "frame": 2 + }, + { + "time": 0.5, + "interp": "constant", + "frame": 3 + }, + { + "time": 0.833333, + "interp": "constant", + "frame": 2 + } + ] + } + ] + } + ] +} diff --git a/packages/fixtures/mixed_feature/build_blend.py b/packages/fixtures/mixed_feature/build_blend.py new file mode 100644 index 00000000..05bb2a48 --- /dev/null +++ b/packages/fixtures/mixed_feature/build_blend.py @@ -0,0 +1,360 @@ +"""Assemble mixed_feature.blend - the end-to-end feature-stack fixture. + +Run with:: + + blender --background --python packages/fixtures/mixed_feature/build_blend.py + +Stacks every Blender-to-Godot feature into one rig so the golden catches +interactions a single-feature fixture cannot: + +- **Armature** ``mixed_rig`` with four flat bones (tails along -Y, the + 2D-cutout convention): ``root`` / ``spine`` skin the body, ``head`` + anchors the mouth, ``jaw`` drives it. +- **Skinned body** ``body`` - a 2-face polygon weighted across ``root`` + (lower) and ``spine`` (upper), so the export carries per-bone weights + and the per-face ``polygons`` index arrays. +- **sprite_frame mouth** ``mouth`` - a 4-frame Sprite2D bone-parented to + ``head``, its cell driven from ``jaw`` rotation. +- **Drive-from-Bone** - a scripted driver on ``mouth.proscenio.frame`` + reading ``jaw`` ROT_Y (mirrors the Drive-from-Bone operator defaults). +- **Slot with mixed attachments** ``face.slot`` - one mesh attachment + (``face_neutral``) and one sprite attachment (``face_glow``), default + ``face_neutral``. +- **Packed atlas** - every element shares one ``atlas.png`` via UV bounds + (meshes) or a manual region (sprites), so the export emits a single + top-level ``atlas`` plus per-element regions. +- **One animation** ``mixed_anim`` keying ``jaw`` rotation, which bakes + into a ``bone_transform`` track plus the driven ``sprite_frame`` track. + +Run ``draw_layers.py`` first or this script aborts on the missing atlas. +""" + +from __future__ import annotations + +import math +import sys +from pathlib import Path + +import bpy + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURE_DIR = REPO_ROOT / "examples" / "generated" / "blender_to_godot" / "mixed_feature" +ATLAS_PATH = FIXTURE_DIR / "atlas.png" +BLEND_PATH = FIXTURE_DIR / "mixed_feature.blend" + +PIXELS_PER_UNIT = 100.0 + +# Manual atlas regions for the two sprite strips, in the top-down normalized +# convention the importer expects ([x, y, w, h], y=0 at the PNG top). +MOUTH_REGION = (0.0, 0.0, 0.5, 0.5) # top-left quadrant, 4 frames +GLOW_REGION = (0.5, 0.0, 0.5, 0.5) # top-right quadrant, 2 frames + + +def main() -> None: + if not ATLAS_PATH.exists(): + print( + f"[build_mixed_feature] missing {ATLAS_PATH} - run draw_layers.py first", + file=sys.stderr, + ) + sys.exit(1) + _wipe_blend() + armature_obj = _build_armature() + _build_skinned_body(armature_obj) + mouth_obj = _build_mouth(armature_obj) + _install_mouth_driver(mouth_obj, armature_obj) + _build_slot(armature_obj) + _build_action(armature_obj) + _save_blend() + _rewrite_images_to_relpath() + bpy.ops.wm.save_mainfile() + print(f"[build_mixed_feature] wrote {BLEND_PATH}") + + +def _wipe_blend() -> None: + for collection in ( + bpy.data.objects, + bpy.data.meshes, + bpy.data.armatures, + bpy.data.materials, + bpy.data.images, + bpy.data.actions, + ): + while collection: + collection.remove(collection[0]) + + +def _dual(obj: bpy.types.Object, pg_name: str, cp_key: str, value: object) -> None: + """Write a proscenio field to both the PropertyGroup and its CP fallback. + + The headless writer reads the Custom Property when the addon's + PropertyGroup is not registered; the PG path is what panels read in an + interactive session. Authoring both keeps the fixture honest in both. + """ + if hasattr(obj, "proscenio"): + setattr(obj.proscenio, pg_name, value) + obj[cp_key] = value + + +def _build_armature() -> bpy.types.Object: + arm_data = bpy.data.armatures.new("mixed_rig") + arm_obj = bpy.data.objects.new("mixed_rig", arm_data) + bpy.context.scene.collection.objects.link(arm_obj) + bpy.context.view_layer.objects.active = arm_obj + bpy.ops.object.mode_set(mode="EDIT") + # Flat bones (no hierarchy), tails along -Y toward the Front Ortho + # camera so each exports with rotation 0. Z places them up the screen. + for name, (hx, hz) in ( + ("root", (0.0, -0.3)), + ("spine", (0.0, 0.1)), + ("head", (0.0, 0.5)), + ("jaw", (0.4, 0.3)), + ): + bone = arm_data.edit_bones.new(name) + bone.head = (hx, 0.0, hz) + bone.tail = (hx, -0.5, hz) + bpy.ops.object.mode_set(mode="OBJECT") + return arm_obj + + +def _atlas_material(name: str) -> bpy.types.Material: + """A material whose Base Color samples the shared atlas (nearest-neighbor).""" + mat = bpy.data.materials.new(name=name) + mat.use_nodes = True + nt = mat.node_tree + while nt.nodes: + nt.nodes.remove(nt.nodes[0]) + out = nt.nodes.new(type="ShaderNodeOutputMaterial") + bsdf = nt.nodes.new(type="ShaderNodeBsdfPrincipled") + tex = nt.nodes.new(type="ShaderNodeTexImage") + tex.image = bpy.data.images.load(str(ATLAS_PATH), check_existing=True) + tex.interpolation = "Closest" + nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) + nt.links.new(tex.outputs["Alpha"], bsdf.inputs["Alpha"]) + nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"]) + return mat + + +def _quad_mesh(name: str, w: float, h: float, uvs: list[tuple[float, float]]) -> bpy.types.Mesh: + """A single-face quad in the XZ plane with the four corner UVs given.""" + mesh = bpy.data.meshes.new(name) + mesh.from_pydata( + vertices=[ + (-w / 2, 0.0, -h / 2), + (w / 2, 0.0, -h / 2), + (w / 2, 0.0, h / 2), + (-w / 2, 0.0, h / 2), + ], + edges=[], + faces=[(0, 1, 2, 3)], + ) + mesh.update() + uv = mesh.uv_layers.new(name="UVMap") + for i, corner in enumerate(uvs): + uv.data[i].uv = corner + return mesh + + +def _build_skinned_body(armature_obj: bpy.types.Object) -> bpy.types.Object: + """A 2-face polygon skinned across ``root`` (lower) and ``spine`` (upper). + + Six vertices in a 2x3 grid; the lower face weights to ``root``, the upper + to ``spine``, the shared middle row splits 50/50. UVs cover the atlas + bottom-left quadrant. + """ + mesh = bpy.data.meshes.new("body") + mesh.from_pydata( + vertices=[ + (-0.2, 0.0, -0.5), # 0 bottom-left + (0.2, 0.0, -0.5), # 1 bottom-right + (0.2, 0.0, 0.0), # 2 mid-right + (-0.2, 0.0, 0.0), # 3 mid-left + (0.2, 0.0, 0.5), # 4 top-right + (-0.2, 0.0, 0.5), # 5 top-left + ], + edges=[], + faces=[(0, 1, 2, 3), (3, 2, 4, 5)], + ) + mesh.update() + # UVs into the atlas bottom-left quadrant (Blender bottom-up: v in [0, 0.5]). + vert_uv = { + 0: (0.0, 0.0), + 1: (0.5, 0.0), + 2: (0.5, 0.25), + 3: (0.0, 0.25), + 4: (0.5, 0.5), + 5: (0.0, 0.5), + } + uv = mesh.uv_layers.new(name="UVMap") + for loop in mesh.loops: + uv.data[loop.index].uv = vert_uv[loop.vertex_index] + + obj = bpy.data.objects.new("body", mesh) + bpy.context.scene.collection.objects.link(obj) + obj.parent = armature_obj + obj.parent_type = "OBJECT" + + # `root` first so it is the resolved fallback bone (first vertex group). + vg_root = obj.vertex_groups.new(name="root") + vg_spine = obj.vertex_groups.new(name="spine") + vg_root.add([0, 1], 1.0, "REPLACE") + vg_root.add([2, 3], 0.5, "REPLACE") + vg_spine.add([2, 3], 0.5, "REPLACE") + vg_spine.add([4, 5], 1.0, "REPLACE") + + mesh.materials.append(_atlas_material("body.mat")) + _dual(obj, "element_type", "proscenio_type", "mesh") + return obj + + +def _build_mouth(armature_obj: bpy.types.Object) -> bpy.types.Object: + """A 4-frame Sprite2D bone-parented to ``head``, region = atlas top-left.""" + w = 0.32 + mesh = _quad_mesh("mouth", w, w, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]) + obj = bpy.data.objects.new("mouth", mesh) + bpy.context.scene.collection.objects.link(obj) + obj.parent = armature_obj + obj.parent_type = "BONE" + obj.parent_bone = "head" + + mesh.materials.append(_atlas_material("mouth.mat")) + _dual(obj, "element_type", "proscenio_type", "sprite") + _dual(obj, "hframes", "proscenio_hframes", 4) + _dual(obj, "vframes", "proscenio_vframes", 1) + _dual(obj, "frame", "proscenio_frame", 0) + _dual(obj, "centered", "proscenio_centered", True) + _apply_manual_region(obj, MOUTH_REGION) + return obj + + +def _build_slot(armature_obj: bpy.types.Object) -> bpy.types.Object: + """A slot Empty holding one mesh + one sprite attachment (the mixed case). + + Object-parented to the armature (not a bone) so the attachment quads keep + their screen-plane orientation - the slot_cycle convention; the slot's + ``bone`` field falls out empty. + """ + empty = bpy.data.objects.new("face.slot", None) + empty.empty_display_type = "PLAIN_AXES" + empty.empty_display_size = 0.1 + bpy.context.scene.collection.objects.link(empty) + empty.parent = armature_obj + empty.parent_type = "OBJECT" + _dual(empty, "is_slot", "proscenio_is_slot", True) + _dual(empty, "slot_default", "proscenio_slot_default", "face_neutral") + + # Mesh attachment: UVs into the atlas bottom-right quadrant. + neutral_mesh = _quad_mesh( + "face_neutral", 0.32, 0.32, [(0.5, 0.0), (1.0, 0.0), (1.0, 0.5), (0.5, 0.5)] + ) + neutral = bpy.data.objects.new("face_neutral", neutral_mesh) + bpy.context.scene.collection.objects.link(neutral) + neutral.parent = empty + neutral.parent_type = "OBJECT" + neutral_mesh.materials.append(_atlas_material("face_neutral.mat")) + _dual(neutral, "element_type", "proscenio_type", "mesh") + + # Sprite attachment: 2-frame strip, region = atlas top-right quadrant. + glow_mesh = _quad_mesh( + "face_glow", 0.32, 0.32, [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + ) + glow = bpy.data.objects.new("face_glow", glow_mesh) + bpy.context.scene.collection.objects.link(glow) + glow.parent = empty + glow.parent_type = "OBJECT" + glow_mesh.materials.append(_atlas_material("face_glow.mat")) + _dual(glow, "element_type", "proscenio_type", "sprite") + _dual(glow, "hframes", "proscenio_hframes", 2) + _dual(glow, "vframes", "proscenio_vframes", 1) + _dual(glow, "frame", "proscenio_frame", 0) + _dual(glow, "centered", "proscenio_centered", True) + _apply_manual_region(glow, GLOW_REGION) + return empty + + +def _apply_manual_region(obj: bpy.types.Object, region: tuple[float, float, float, float]) -> None: + rx, ry, rw, rh = region + _dual(obj, "region_mode", "proscenio_region_mode", "manual") + _dual(obj, "region_x", "proscenio_region_x", rx) + _dual(obj, "region_y", "proscenio_region_y", ry) + _dual(obj, "region_w", "proscenio_region_w", rw) + _dual(obj, "region_h", "proscenio_region_h", rh) + + +def _install_mouth_driver(mouth_obj: bpy.types.Object, armature_obj: bpy.types.Object) -> None: + """Wire ``jaw`` ROT_Y to ``mouth.proscenio.frame`` (Drive-from-Bone shape).""" + data_path = "proscenio.frame" + if ( + mouth_obj.animation_data is not None + and mouth_obj.animation_data.drivers.find(data_path) is not None + ): + mouth_obj.driver_remove(data_path) + + fcurve = mouth_obj.driver_add(data_path) + while fcurve.keyframe_points: + fcurve.keyframe_points.remove(fcurve.keyframe_points[0]) + + driver = fcurve.driver + driver.type = "SCRIPTED" + driver.expression = "var * 2 + 2" + var = driver.variables[0] if driver.variables else driver.variables.new() + var.name = "var" + var.type = "TRANSFORMS" + target = var.targets[0] + target.id = armature_obj + target.bone_target = "jaw" + target.transform_type = "ROT_Y" + target.transform_space = "WORLD_SPACE" + target.rotation_mode = "XYZ" + + if hasattr(mouth_obj, "proscenio"): + mouth_obj.proscenio.driver_target = "frame" + mouth_obj.proscenio.driver_source_armature = armature_obj + mouth_obj.proscenio.driver_source_bone = "jaw" + mouth_obj.proscenio.driver_source_axis = "ROT_Y" + mouth_obj.proscenio.driver_expression = "var * 2 + 2" + + +def _build_action(armature_obj: bpy.types.Object) -> None: + """Animate ``jaw`` ROT_Y -pi/2 -> +pi/2 -> 0 over 24 frames. + + The driver projects this into the mouth's frame cell, so the export + carries a ``bone_transform`` track for ``jaw`` plus the driven + ``sprite_frame`` track for ``mouth``. + """ + armature_obj.animation_data_create() + action = bpy.data.actions.new(name="mixed_anim") + armature_obj.animation_data.action = action + bpy.context.scene.frame_start = 1 + bpy.context.scene.frame_end = 24 + + jaw_pose = armature_obj.pose.bones["jaw"] + jaw_pose.rotation_mode = "XYZ" + for frame, value in ((1, 0.0), (8, -math.pi / 2), (16, math.pi / 2), (24, 0.0)): + bpy.context.scene.frame_set(frame) + jaw_pose.rotation_euler = (0.0, value, 0.0) + jaw_pose.keyframe_insert(data_path="rotation_euler", frame=frame, index=1) + + +def _save_blend() -> None: + BLEND_PATH.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH), check_existing=False) + + +def _rewrite_images_to_relpath() -> None: + """After save_as, rewrite each image filepath to a ``//``-relative path.""" + for img in bpy.data.images: + if not img.filepath: + continue + try: + img.filepath = bpy.path.relpath(img.filepath) + except ValueError: + # Different drive on Windows - leave the absolute path. + pass + + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"[build_mixed_feature] FAILED: {exc}", file=sys.stderr) + raise diff --git a/packages/fixtures/mixed_feature/draw_layers.py b/packages/fixtures/mixed_feature/draw_layers.py new file mode 100644 index 00000000..08c24745 --- /dev/null +++ b/packages/fixtures/mixed_feature/draw_layers.py @@ -0,0 +1,75 @@ +"""Generate the mixed_feature atlas PNG (the feature-stack fixture, Pillow only). + +Run with:: + + python packages/fixtures/mixed_feature/draw_layers.py + +Pure Python - no Blender required. Produces one 128x128 ``atlas.png`` under +``examples/generated/blender_to_godot/mixed_feature/`` with four 64x64 quadrants, +one per element of the fixture so every feature shares a single packed atlas: + +- top-left -> ``mouth`` 4-frame strip (sprite_frame, hframes=4) +- top-right -> ``face_glow`` 2-frame strip (slot sprite attachment, hframes=2) +- bottom-left -> ``body`` texture (skinned polygon) +- bottom-right -> ``face_neutral`` texture (slot mesh attachment) + +The pixels are only there so the atlas is non-empty and visually +distinguishable; the golden carries geometry / UVs / weights, not pixels. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "_shared")) +from _draw import Canvas, border, circle, rect # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURE_DIR = REPO_ROOT / "examples" / "generated" / "blender_to_godot" / "mixed_feature" +ATLAS_PATH = FIXTURE_DIR / "atlas.png" + +ATLAS_W = 128 +ATLAS_H = 128 +QUAD = 64 + +# Per-frame tints for the two sprite strips, so consecutive cells differ. +_MOUTH_TINTS = ( + (0.85, 0.25, 0.30, 1.0), + (0.85, 0.45, 0.30, 1.0), + (0.85, 0.65, 0.30, 1.0), + (0.85, 0.85, 0.30, 1.0), +) +_GLOW_TINTS = ( + (0.30, 0.45, 0.85, 1.0), + (0.55, 0.75, 1.00, 1.0), +) + + +def main() -> None: + canvas = Canvas.empty(ATLAS_W, ATLAS_H) + + # Top-left quadrant: mouth, 4 frames of 16x64 across x[0, 64). + frame_w = QUAD // len(_MOUTH_TINTS) + for i, tint in enumerate(_MOUTH_TINTS): + rect(canvas, i * frame_w, 0, frame_w, QUAD, tint) + + # Top-right quadrant: face_glow, 2 frames of 32x64 across x[64, 128). + glow_w = QUAD // len(_GLOW_TINTS) + for i, tint in enumerate(_GLOW_TINTS): + rect(canvas, QUAD + i * glow_w, 0, glow_w, QUAD, tint) + + # Bottom-left quadrant: body (skinned), a solid torso block. + rect(canvas, 0, QUAD, QUAD, QUAD, (0.40, 0.70, 0.45, 1.0)) + border(canvas, (0.20, 0.40, 0.25, 1.0)) + + # Bottom-right quadrant: face_neutral, a face disc. + rect(canvas, QUAD, QUAD, QUAD, QUAD, (0.95, 0.80, 0.65, 1.0)) + circle(canvas, QUAD + QUAD / 2, QUAD + QUAD / 2, QUAD / 3, (0.80, 0.55, 0.40, 1.0)) + + canvas.save(ATLAS_PATH) + print(f"[draw_mixed_feature] wrote {ATLAS_PATH}") + + +if __name__ == "__main__": + main() From 28a6755312874bcc3b98b5062de469e8a7469cac Mon Sep 17 00:00:00 2001 From: Danilo Nobre Nunes Date: Thu, 11 Jun 2026 22:04:34 -0300 Subject: [PATCH 2/3] docs(specs): tick the mixed-feature fixture in spec 035 Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/035-project-health/TODO.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/035-project-health/TODO.md b/specs/035-project-health/TODO.md index c8689ae0..e3b5749f 100644 --- a/specs/035-project-health/TODO.md +++ b/specs/035-project-health/TODO.md @@ -34,11 +34,11 @@ Sequenced from the verdicts in [STUDY.md](STUDY.md): six items land now (the blo ### End-to-end mixed-feature fixture -Carried to a focused follow-up PR, split from the toolchain/shipping PR (the STUDY flags this as the one heavier now-item). Its precondition is met: the export-correctness writer fixes (picker-first `find_armature`, `MeshElement.polygons`) are already in code. +Delivered in a focused follow-up PR, split from the toolchain/shipping PR (the STUDY flags this as the one heavier now-item). Precondition was met: the export-correctness writer fixes (picker-first `find_armature`, `MeshElement.polygons`) are already in code. -- [ ] Sequence after the export-correctness blocking writer fixes (armature picker, multi-polygon) so the golden bakes once. -- [ ] Author the fixture in the categorization buckets under `examples/generated/`: skinned polygon body + sprite_frame mouth + slot with mixed attachments + packed atlas + Drive-from-Bone + one animation. -- [ ] Bake the Blender-to-Godot golden and wire it into the existing `test-blender` re-export diff and `test-godot` smoke; populate the dev project via [sync_fixtures.py](../../scripts/godot/sync_fixtures.py) (never edit the synced copies). +- [x] Sequence after the export-correctness blocking writer fixes (armature picker, multi-polygon) so the golden bakes once. +- [x] Author the fixture in the categorization buckets under `examples/generated/`: skinned polygon body + sprite_frame mouth + slot with mixed attachments + packed atlas + Drive-from-Bone + one animation. (Single shared `atlas.png`; sprite_frames carry manual atlas regions, meshes derive theirs from UV.) +- [x] Bake the Blender-to-Godot golden and wire it into the existing `test-blender` re-export diff and `test-godot` smoke; populate the dev project via [sync_fixtures.py](../../scripts/godot/sync_fixtures.py) (never edit the synced copies). - [ ] Optional follow-up PR: the PSD-to-Blender leg (photoshop manifest golden) once the Blender leg is green. ## Deferred From 4d551462cfa9eba8b7c35bd04a4bbd9f77d67516 Mon Sep 17 00:00:00 2001 From: Danilo Nobre Nunes Date: Thu, 11 Jun 2026 22:16:41 -0300 Subject: [PATCH 3/3] docs(fixtures): explain the intentional driver overshoot in mixed_feature The Drive-from-Bone expression `var * 2 + 2` overshoots [0, 3] by design; the writer's frame-driver bake clamps it to the grid. Document that the raw range is the point (the fixture exercises the clamp path), so it does not read as a bug. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/fixtures/mixed_feature/build_blend.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/fixtures/mixed_feature/build_blend.py b/packages/fixtures/mixed_feature/build_blend.py index 05bb2a48..b1939371 100644 --- a/packages/fixtures/mixed_feature/build_blend.py +++ b/packages/fixtures/mixed_feature/build_blend.py @@ -295,6 +295,12 @@ def _install_mouth_driver(mouth_obj: bpy.types.Object, armature_obj: bpy.types.O driver = fcurve.driver driver.type = "SCRIPTED" + # Matches the Drive-from-Bone operator default (and the mouth_drive fixture): + # `var` is jaw ROT_Y in radians, so across the action's [-pi/2, +pi/2] sweep + # `var * 2 + 2` ranges roughly [-1.1, 5.1]. The overshoot is intentional - the + # writer's frame-driver bake clamps to [0, hframes*vframes-1], yielding a full + # 0..3 cell sweep with frame 2 at the rest pose. The fixture exercises exactly + # that clamp path, so the raw expression is the point, not a bug. driver.expression = "var * 2 + 2" var = driver.variables[0] if driver.variables else driver.variables.new() var.name = "var"