fix(gltf_auto_export): fix animation export (#148)
* fix(gltf_auto_export): added fix for correct "deep-copying" of objects & hierarchy so that animated meshes & nested hierarchies get exported correctly * test(gltf_auto_export): * added visual testing to check for overall regression : * added screenshoting in bevy app * added visual compare with reference screenshot on python side * added testing of correct export of animated models * added testing of correct export of empties * added testing of correct export of nested hierarchies * added testing of correct export of blueprints, with & without components etc * fixes #147
This commit is contained in:
parent
dfc2be8c50
commit
7ffcd55f5d
Binary file not shown.
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 645 KiB |
|
@ -1,15 +1,93 @@
|
|||
pub mod in_game;
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
fs::{self},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use bevy_gltf_blueprints::{AnimationPlayerLink, BlueprintName};
|
||||
pub use in_game::*;
|
||||
|
||||
use bevy::{prelude::*, time::common_conditions::on_timer};
|
||||
use bevy::{
|
||||
prelude::*, render::view::screenshot::ScreenshotManager, time::common_conditions::on_timer,
|
||||
window::PrimaryWindow,
|
||||
};
|
||||
use bevy_gltf_worlflow_examples_common::{AppState, GameState};
|
||||
|
||||
use crate::{TupleTestF32, UnitTest};
|
||||
|
||||
fn start_game(mut next_app_state: ResMut<NextState<AppState>>) {
|
||||
next_app_state.set(AppState::AppLoading);
|
||||
}
|
||||
|
||||
// if the export from Blender worked correctly, we should have animations (simplified here by using AnimationPlayerLink)
|
||||
// if the export from Blender worked correctly, we should have an Entity called "Cylinder" that has two components: UnitTest, TupleTestF32
|
||||
// if the export from Blender worked correctly, we should have an Entity called "Blueprint4_nested" that has a child called "Blueprint3" that has a "BlueprintName" component with value Blueprint3
|
||||
|
||||
fn validate_export(
|
||||
parents: Query<&Parent>,
|
||||
children: Query<&Children>,
|
||||
names: Query<&Name>,
|
||||
blueprints: Query<(Entity, &Name, &BlueprintName)>,
|
||||
animation_player_links: Query<(Entity, &AnimationPlayerLink)>,
|
||||
exported_cylinder: Query<(Entity, &Name, &UnitTest, &TupleTestF32)>,
|
||||
empties_candidates: Query<(Entity, &Name, &GlobalTransform)>,
|
||||
) {
|
||||
let animations_found = !animation_player_links.is_empty();
|
||||
|
||||
let mut cylinder_found = false;
|
||||
if let Ok(nested_cylinder) = exported_cylinder.get_single() {
|
||||
let parent_name = names
|
||||
.get(parents.get(nested_cylinder.0).unwrap().get())
|
||||
.unwrap();
|
||||
cylinder_found = parent_name.to_string() == *"Cube.001"
|
||||
&& nested_cylinder.1.to_string() == *"Cylinder"
|
||||
&& nested_cylinder.3 .0 == 75.1;
|
||||
}
|
||||
|
||||
let mut nested_blueprint_found = false;
|
||||
for (entity, name, blueprint_name) in blueprints.iter() {
|
||||
if name.to_string() == *"Blueprint4_nested" && blueprint_name.0 == *"Blueprint4_nested" {
|
||||
if let Ok(cur_children) = children.get(entity) {
|
||||
for child in cur_children.iter() {
|
||||
if let Ok((_, child_name, child_blueprint_name)) = blueprints.get(*child) {
|
||||
if child_name.to_string() == *"Blueprint3"
|
||||
&& child_blueprint_name.0 == *"Blueprint3"
|
||||
{
|
||||
nested_blueprint_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut empty_found = false;
|
||||
for (_, name, _) in empties_candidates.iter() {
|
||||
if name.to_string() == *"Empty" {
|
||||
empty_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(
|
||||
"bevy_diagnostics.json",
|
||||
format!(
|
||||
"{{ \"animations\": {}, \"cylinder_found\": {} , \"nested_blueprint_found\": {}, \"empty_found\": {} }}",
|
||||
animations_found, cylinder_found, nested_blueprint_found, empty_found
|
||||
),
|
||||
)
|
||||
.expect("Unable to write file");
|
||||
}
|
||||
|
||||
fn generate_screenshot(
|
||||
main_window: Query<Entity, With<PrimaryWindow>>,
|
||||
mut screenshot_manager: ResMut<ScreenshotManager>,
|
||||
) {
|
||||
screenshot_manager
|
||||
.save_screenshot_to_disk(main_window.single(), "screenshot.png")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn exit_game(mut app_exit_events: ResMut<Events<bevy::app::AppExit>>) {
|
||||
app_exit_events.send(bevy::app::AppExit);
|
||||
}
|
||||
|
@ -18,11 +96,14 @@ pub struct GamePlugin;
|
|||
impl Plugin for GamePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Update, (spawn_test).run_if(in_state(GameState::InGame)))
|
||||
.add_systems(Update, validate_export)
|
||||
.add_systems(Update, generate_screenshot.run_if(on_timer(Duration::from_secs_f32(0.2)))) // TODO: run once
|
||||
.add_systems(OnEnter(AppState::MenuRunning), start_game)
|
||||
.add_systems(OnEnter(AppState::AppRunning), setup_game)
|
||||
.add_systems(
|
||||
Update,
|
||||
exit_game.run_if(on_timer(Duration::from_secs_f32(0.5))),
|
||||
) // shut down the app after this time
|
||||
.add_systems(OnEnter(AppState::AppRunning), setup_game);
|
||||
;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,11 +7,11 @@ use std::ops::Range;
|
|||
|
||||
#[derive(Component, Reflect, Default, Debug)]
|
||||
#[reflect(Component)]
|
||||
struct UnitTest;
|
||||
pub struct UnitTest;
|
||||
|
||||
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
|
||||
#[reflect(Component)]
|
||||
struct TupleTestF32(f32);
|
||||
pub struct TupleTestF32(pub f32);
|
||||
|
||||
#[derive(Component, Reflect, Default, Debug, Deref, DerefMut)]
|
||||
#[reflect(Component)]
|
||||
|
|
|
@ -24,12 +24,38 @@ def remove_unwanted_custom_properties(object):
|
|||
if cp in object:
|
||||
del object[cp]
|
||||
|
||||
def duplicate_object(object):
|
||||
obj_copy = object.copy()
|
||||
if object.data:
|
||||
data = object.data.copy()
|
||||
obj_copy.data = data
|
||||
if object.animation_data and object.animation_data.action:
|
||||
obj_copy.animation_data.action = object.animation_data.action.copy()
|
||||
return obj_copy
|
||||
|
||||
#also removes unwanted custom_properties for all objects in hiearchy
|
||||
def duplicate_object_recursive(object, parent, collection):
|
||||
original_name = object.name
|
||||
object.name = original_name + "____bak"
|
||||
copy = duplicate_object(object)
|
||||
copy.name = original_name
|
||||
collection.objects.link(copy)
|
||||
|
||||
remove_unwanted_custom_properties(copy)
|
||||
|
||||
if parent:
|
||||
copy.parent = parent
|
||||
|
||||
for child in object.children:
|
||||
duplicate_object_recursive(child, copy, collection)
|
||||
return copy
|
||||
|
||||
|
||||
# copies the contents of a collection into another one while replacing library instances with empties
|
||||
def copy_hollowed_collection_into(source_collection, destination_collection, parent_empty=None, filter=None, library_collections=[], addon_prefs={}):
|
||||
collection_instances_combine_mode = getattr(addon_prefs, "collection_instances_combine_mode")
|
||||
legacy_mode = getattr(addon_prefs, "export_legacy_mode")
|
||||
collection_instances_combine_mode= collection_instances_combine_mode
|
||||
|
||||
for object in source_collection.objects:
|
||||
if filter is not None and filter(object) is False:
|
||||
continue
|
||||
|
@ -54,19 +80,13 @@ def copy_hollowed_collection_into(source_collection, destination_collection, par
|
|||
if parent_empty is not None:
|
||||
empty_obj.parent = parent_empty
|
||||
else:
|
||||
# we create a copy of our object, to leave the original one as it is
|
||||
original_name = object.name
|
||||
object.name = original_name + "____bak"
|
||||
copy = object.copy()
|
||||
copy.name = original_name
|
||||
remove_unwanted_custom_properties(copy)
|
||||
|
||||
# we create a copy of our object and its children, to leave the original one as it is
|
||||
if object.parent == None:
|
||||
copy = duplicate_object_recursive(object, None, destination_collection)
|
||||
|
||||
if parent_empty is not None:
|
||||
copy.parent = parent_empty
|
||||
destination_collection.objects.link(copy)
|
||||
else:
|
||||
# root_objects.append(object)
|
||||
destination_collection.objects.link(copy)
|
||||
if parent_empty is not None:
|
||||
copy.parent = parent_empty
|
||||
|
||||
# for every sub-collection of the source, copy its content into a new sub-collection of the destination
|
||||
for collection in source_collection.children:
|
||||
|
|
|
@ -219,4 +219,24 @@ def did_export_parameters_change(current_params, previous_params):
|
|||
print("copying ", key,"to", components_holder)
|
||||
if not key in components_holder:
|
||||
components_holder[key] = components[key]
|
||||
"""
|
||||
"""
|
||||
|
||||
# potentially useful alternative
|
||||
def duplicate_object2(object, original_name):
|
||||
print("copy object", object)
|
||||
|
||||
with bpy.context.temp_override(object=object, active_object = object):
|
||||
bpy.ops.object.duplicate(linked=False)
|
||||
new_obj = bpy.context.active_object
|
||||
|
||||
print("new obj", new_obj, "bpy.context.view_layer", bpy.context.view_layer.objects)
|
||||
for obj in bpy.context.view_layer.objects:
|
||||
print("obj", obj)
|
||||
bpy.context.view_layer.update()
|
||||
new_obj.name = original_name
|
||||
|
||||
if object.animation_data:
|
||||
print("OJECT ANIMATION")
|
||||
new_obj.animation_data.action = object.animation_data.action.copy()
|
||||
|
||||
return new_obj
|
|
@ -4,3 +4,6 @@ addopts = -svv
|
|||
testpaths =
|
||||
tests
|
||||
|
||||
# dependencies:
|
||||
# pytest_blender
|
||||
# pixelmatch
|
|
@ -5,6 +5,9 @@ import json
|
|||
import pytest
|
||||
import shutil
|
||||
|
||||
from PIL import Image
|
||||
from pixelmatch.contrib.PIL import pixelmatch
|
||||
|
||||
@pytest.fixture
|
||||
def setup_data(request):
|
||||
print("\nSetting up resources...")
|
||||
|
@ -25,7 +28,13 @@ def setup_data(request):
|
|||
|
||||
if os.path.exists(other_materials_path):
|
||||
shutil.rmtree(other_materials_path)"""
|
||||
|
||||
diagnostics_file_path = os.path.join(root_path, "bevy_diagnostics.json")
|
||||
if os.path.exists(diagnostics_file_path):
|
||||
os.remove(diagnostics_file_path)
|
||||
|
||||
screenshot_observed_path = os.path.join(root_path, "screenshot.png")
|
||||
if os.path.exists(screenshot_observed_path):
|
||||
os.remove(screenshot_observed_path)
|
||||
|
||||
request.addfinalizer(finalizer)
|
||||
|
||||
|
@ -65,7 +74,8 @@ def test_export_complex(setup_data):
|
|||
export_output_folder="./models",
|
||||
export_scene_settings=True,
|
||||
export_blueprints=True,
|
||||
export_legacy_mode=False
|
||||
export_legacy_mode=False,
|
||||
export_animations=True
|
||||
)
|
||||
# blueprint1 => has an instance, got changed, should export
|
||||
# blueprint2 => has NO instance, but marked as asset, should export
|
||||
|
@ -80,6 +90,8 @@ def test_export_complex(setup_data):
|
|||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint3.glb")) == True
|
||||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint4_nested.glb")) == True
|
||||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint5.glb")) == False
|
||||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint6_animated.glb")) == True
|
||||
assert os.path.exists(os.path.join(models_path, "library", "Blueprint7_hierarchy.glb")) == True
|
||||
|
||||
# now run bevy
|
||||
bla = "cargo run --features bevy/dynamic_linking"
|
||||
|
@ -91,3 +103,23 @@ def test_export_complex(setup_data):
|
|||
return_code = subprocess.call(["cargo", "run", "--features", "bevy/dynamic_linking"], cwd=root_path)
|
||||
print("RETURN CODE OF BEVY APP", return_code)
|
||||
assert return_code == 0
|
||||
|
||||
with open(os.path.join(root_path, "bevy_diagnostics.json")) as diagnostics_file:
|
||||
diagnostics = json.load(diagnostics_file)
|
||||
print("diagnostics", diagnostics)
|
||||
assert diagnostics["animations"] == True
|
||||
assert diagnostics["cylinder_found"] == True
|
||||
assert diagnostics["empty_found"] == True
|
||||
|
||||
# last but not least, do a visual compare
|
||||
screenshot_expected_path = os.path.join(root_path, "expected_screenshot.png")
|
||||
screenshot_observed_path = os.path.join(root_path, "screenshot.png")
|
||||
img_a = Image.open(screenshot_expected_path)
|
||||
img_b = Image.open(screenshot_observed_path)
|
||||
img_diff = Image.new("RGBA", img_a.size)
|
||||
mismatch = pixelmatch(img_a, img_b, img_diff, includeAA=True)
|
||||
print("image mismatch", mismatch)
|
||||
assert mismatch < 50
|
||||
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue