From ab538ea732654de1df28eca3c007873042385bc3 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 19:55:19 +0100 Subject: [PATCH] Add scrolling custom character library and native skater roster --- Cargo.lock | 17 + crates/skate-game/Cargo.toml | 7 +- crates/skate-game/src/animation.rs | 69 ++ crates/skate-game/src/app.rs | 2 + crates/skate-game/src/custom_models.rs | 909 ++++++++++++++++++ crates/skate-game/src/custom_models_main.rs | 1 + crates/skate-game/src/customiser.rs | 6 + crates/skate-game/src/customiser_material.rs | 18 +- crates/skate-game/src/customiser_parts.rs | 5 +- crates/skate-game/src/graphics_menu.rs | 21 +- crates/skate-game/src/main.rs | 1 + .../skate-game/src/session_marker/effect.rs | 13 +- .../src/tests/fixtures/texture-codec.jpg | Bin 0 -> 633 bytes .../src/tests/fixtures/texture-codec.png | Bin 0 -> 75 bytes docs/custom-models.md | 15 + docs/mixamo-to-skate.md | 116 +++ docs/native-roster.md | 31 + tools/asset_pipeline/native_roster.py | 125 +++ tools/mixamo_to_skate/.gitignore | 2 + tools/mixamo_to_skate/converter.py | 424 ++++++++ tools/mixamo_to_skate/glb.py | 215 +++++ tools/mixamo_to_skate/library_import.py | 137 +++ tools/mixamo_to_skate/main.py | 104 ++ tools/mixamo_to_skate/requirements.txt | 2 + tools/mixamo_to_skate/test_converter.py | 167 ++++ tools/mixamo_to_skate/test_library.py | 31 + 26 files changed, 2420 insertions(+), 18 deletions(-) create mode 100644 crates/skate-game/src/custom_models.rs create mode 100644 crates/skate-game/src/custom_models_main.rs create mode 100644 crates/skate-game/src/tests/fixtures/texture-codec.jpg create mode 100644 crates/skate-game/src/tests/fixtures/texture-codec.png create mode 100644 docs/custom-models.md create mode 100644 docs/mixamo-to-skate.md create mode 100644 docs/native-roster.md create mode 100644 tools/asset_pipeline/native_roster.py create mode 100644 tools/mixamo_to_skate/.gitignore create mode 100644 tools/mixamo_to_skate/converter.py create mode 100644 tools/mixamo_to_skate/glb.py create mode 100644 tools/mixamo_to_skate/library_import.py create mode 100644 tools/mixamo_to_skate/main.py create mode 100644 tools/mixamo_to_skate/requirements.txt create mode 100644 tools/mixamo_to_skate/test_converter.py create mode 100644 tools/mixamo_to_skate/test_library.py diff --git a/Cargo.lock b/Cargo.lock index b458bc22..aaaeca5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2856,6 +2856,8 @@ dependencies = [ "moxcms", "num-traits", "png", + "zune-core", + "zune-jpeg", ] [[package]] @@ -6001,3 +6003,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index 45b28a88..612b96f1 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -13,12 +13,17 @@ name = "skate3-multiplayer" path = "src/multiplayer_main.rs" test = false +[[bin]] +name = "skate3-custom-models" +path = "src/custom_models_main.rs" +test = false + [features] default = ["dev-dynamic"] dev-dynamic = ["bevy/dynamic_linking"] [dependencies] -bevy.workspace = true +bevy = { workspace = true, features = ["jpeg"] } skate-core.workspace = true skate-data.workspace = true half = "=2.7.1" diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 40e3b4d7..84307f5a 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -15,6 +15,75 @@ struct BoneBinding { bone: usize, parent_bone: Option, } +impl AnimationStatus { + /// Prepare a hidden imported scene without disturbing the live bindings. + pub(crate) fn for_scene( + root: Entity, + names: &[String], + skins: &Query<(Entity, &SkinnedMesh)>, + nodes: &Query<(&Name, &Transform)>, + parents: &Query<&ChildOf>, + ) -> Result { + let mut bindings = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for (entity, skin) in skins.iter() { + if !parents.iter_ancestors(entity).any(|p| p == root) { continue; } + for &joint in &skin.joints { + if !seen.insert(joint) { continue; } + if !parents.iter_ancestors(joint).any(|p| p == root) { + return Err("Imported skin refers to a joint outside its scene".into()); + } + let (name, _) = nodes.get(joint).map_err(|_| "Imported joint has no name/transform")?; + let bone = names.iter().position(|n| n.eq_ignore_ascii_case(name.as_str())) + .ok_or_else(|| format!("Unsupported imported bone: {name}"))?; + let mut parent_bone = None; + for p in parents.iter_ancestors(joint) { + if p == root { break; } + if let Ok((name, transform)) = nodes.get(p) { + if let Some(i) = names.iter().position(|n| n.eq_ignore_ascii_case(name.as_str())) { + parent_bone = Some(i); + break; + } + if !transform.to_matrix().abs_diff_eq(Mat4::IDENTITY, 0.00001) { + return Err("Imported armature has an unsupported ancestor transform".into()); + } + } + } + bindings.push(BoneBinding { entity: joint, bone, parent_bone }); + } + } + if bindings.is_empty() { return Err("Imported scene has no skinned character".into()); } + Ok(Self { ready: true, bindings }) + } +} +#[cfg(test)] +mod custom_model_binding_tests { + use super::*; + use bevy::ecs::system::SystemState; + #[test] + fn custom_models_bind_only_the_candidate_and_reject_foreign_joints() { + let mut world = World::new(); + let root = world.spawn_empty().id(); + let hips = world.spawn((Name::new("HIPS"), Transform::default(), ChildOf(root))).id(); + let head = world.spawn((Name::new("HEAD"), Transform::default(), ChildOf(hips))).id(); + for _ in 0..2 { + world.spawn((ChildOf(root), SkinnedMesh { inverse_bindposes: default(), joints: vec![hips, head] })); + } + let foreign = world.spawn((Name::new("unrelated"), Transform::default())).id(); + world.spawn(SkinnedMesh { inverse_bindposes: default(), joints: vec![foreign] }); + let names = vec!["HIPS".into(), "HEAD".into()]; + let mut queries: SystemState<(Query<(Entity, &SkinnedMesh)>, Query<(&Name, &Transform)>, Query<&ChildOf>)> = SystemState::new(&mut world); + let (skins,nodes,parents) = queries.get(&world); + let prepared = AnimationStatus::for_scene(root,&names,&skins,&nodes,&parents).unwrap(); + assert!(prepared.ready); + assert_eq!(prepared.bindings.len(),2); + assert_eq!(prepared.bindings.iter().find(|b| b.entity == head).unwrap().parent_bone,Some(0)); + world.spawn((ChildOf(root), SkinnedMesh { inverse_bindposes: default(), joints: vec![foreign] })); + let (skins,nodes,parents) = queries.get(&world); + assert!(AnimationStatus::for_scene(root,&names,&skins,&nodes,&parents).is_err()); + assert_eq!(prepared.bindings.len(),2); + } +} pub(crate) struct AnimationPlugin; impl Plugin for AnimationPlugin { fn build(&self, app: &mut App) { diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 6afd85d4..9517d807 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -39,6 +39,7 @@ pub(crate) fn build( ) -> App { let retail_scene = config.map.as_ref().is_some_and(|map| map.materials.iter().any(|m| m.retail_definition.is_some())); let mut app = App::new(); + crate::custom_models::register_source(&mut app); app.add_plugins( DefaultPlugins .set(AssetPlugin { @@ -108,6 +109,7 @@ pub(crate) fn build( crate::performance::PerformancePlugin, )); app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); + app.add_plugins(crate::custom_models::CustomModelsPlugin); app.add_plugins(crate::multiplayer::MultiplayerPlugin); app } diff --git a/crates/skate-game/src/custom_models.rs b/crates/skate-game/src/custom_models.rs new file mode 100644 index 00000000..d647a16f --- /dev/null +++ b/crates/skate-game/src/custom_models.rs @@ -0,0 +1,909 @@ +//! Persistent local visual characters. Import and ECS publication are separate transactions. +use bevy::{ + asset::{ + LoadState, + io::{AssetSourceBuilder, file::FileAssetReader}, + }, + gltf::Gltf, + input::mouse::{MouseScrollUnit, MouseWheel}, + mesh::skinning::SkinnedMesh, + prelude::*, + scene::SceneInstance, +}; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::Instant, +}; + +const COLUMNS: usize = 3; +const CARD_HEIGHT: f32 = 246.; +const ROW_HEIGHT: f32 = CARD_HEIGHT + 10.; +#[derive(Clone, Deserialize)] +pub(crate) struct Entry { + version: u32, + pub id: String, + pub name: String, + #[serde(default)] + native: Option, +} +#[derive(Clone, Deserialize)] +struct NativeCharacter { + key: String, + category: String, +} +pub(crate) fn native_animation_style(key: &str) -> &'static str { + // TU3 GetCACSettings 82590BE0..82590DBC; other Marquees use Aggressive. + match key { + "danny_way" => "DannyWay", + "mike_carroll" => "MikeCarroll", + "pj_ladd" => "PJLadd", + "jason_dill" => "JasonDill", + "jerry_hsu" => "JerryHsu", + "rob_dyrdek" => "RobDyrdek", + _ => "Aggressive", + } +} +#[derive(Default, Serialize, Deserialize)] +struct Selection { + version: u32, + selected: Option, +} +struct Pending { + id: String, + root: Entity, + asset: Handle, + started: Instant, +} +struct Import { + child: Child, + result: PathBuf, + started: Instant, +} +#[derive(Component)] +pub(crate) struct CustomModelRoot; +#[derive(Component)] +struct Panel; +#[derive(Component)] +struct Action(usize); +#[derive(Component)] +struct ModelScroll; + +#[derive(Resource)] +pub(crate) struct CustomModels { + pub open: bool, + just_opened: bool, + pub active: Option, + entries: Vec, + directory: PathBuf, + request: Option>, + pending: Option, + active_root: Option, + stock_visibility: Vec<(Entity, Visibility)>, + import: Option, + status: String, + selected: usize, + scroll: f32, + filter: usize, + dirty: bool, +} +impl CustomModels { + pub(crate) fn native_style(&self) -> Option<&'static str> { + self.active + .as_ref() + .and_then(|id| self.entries.iter().find(|e| &e.id == id)) + .and_then(|e| e.native.as_ref()) + .map(|n| native_animation_style(&n.key)) + } + fn visible_entries(&self) -> Vec<&Entry> { + self.entries + .iter() + .filter(|e| match self.filter { + 1 => e.native.as_ref().is_some_and(|n| n.category == "Pro"), + 2 => e.native.as_ref().is_some_and(|n| n.category == "Special"), + 3 => e.native.is_none(), + _ => true, + }) + .collect() + } + pub fn begin(&mut self) { + self.open = true; + self.just_opened = true; + self.dirty = true; + } + pub fn request_stock(&mut self) { + self.request = Some(None); + } +} +pub(crate) fn library_path() -> PathBuf { + std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("Skate3RustEngine/custom-characters") +} +pub(crate) fn register_source(app: &mut App) { + let directory = library_path(); + app.register_asset_source( + "characters", + AssetSourceBuilder::new(move || Box::new(FileAssetReader::new(directory.clone()))), + ); +} +fn valid_id(id: &str) -> bool { + id.len() == 64 + && id + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} +fn discover(directory: &Path) -> Vec { + let mut entries: Vec = std::fs::read_dir(directory.join("entries")) + .into_iter() + .flatten() + .flatten() + .filter_map(|folder| { + let id = folder.file_name().to_string_lossy().into_owned(); + if !valid_id(&id) || !folder.file_type().ok()?.is_dir() { + return None; + } + let path = folder.path(); + let data = std::fs::read(path.join("manifest.json")).ok()?; + if data.len() > 65536 { + return None; + } + let mut entry: Entry = serde_json::from_slice(&data).ok()?; + if entry.version != 1 + || entry.id != id + || !path.join("character.glb").is_file() + || !path.join("preview.png").is_file() + { + return None; + } + entry.name = entry + .name + .chars() + .filter(|c| !c.is_control()) + .take(64) + .collect(); + Some(entry) + }) + .collect(); + entries.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then(a.id.cmp(&b.id)) + }); + entries +} +fn save_selection(directory: &Path, selected: Option) -> Result<(), String> { + std::fs::create_dir_all(directory).map_err(|e| e.to_string())?; + let temporary = directory.join("selection.tmp"); + std::fs::write( + &temporary, + serde_json::to_vec(&Selection { + version: 1, + selected, + }) + .unwrap(), + ) + .map_err(|e| e.to_string())?; + std::fs::rename(temporary, directory.join("selection.json")).map_err(|e| e.to_string()) +} +pub(crate) struct CustomModelsPlugin; +impl Plugin for CustomModelsPlugin { + fn build(&self, app: &mut App) { + let directory = library_path(); + let entries = discover(&directory); + let saved = std::fs::read(directory.join("selection.json")) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .filter(|s| s.version == 1) + .and_then(|s| s.selected) + .filter(|id| entries.iter().any(|e| &e.id == id)); + app.insert_resource(CustomModels { + open: false, + just_opened: false, + active: None, + entries, + directory, + request: saved.map(Some), + pending: None, + active_root: None, + stock_visibility: vec![], + import: None, + status: String::new(), + selected: 0, + scroll: 0., + filter: 0, + dirty: true, + }) + .add_systems(PreUpdate, interact.after(crate::graphics_menu::MenuInput)) + .add_systems( + Update, + (poll_import, publish, draw) + .chain() + .before(crate::customiser_parts::update) + .before(crate::app::FrameSet::Animation), + ) + .add_systems(Last, stop_import); + } +} +fn interact( + mut state: ResMut, + keys: Res>, + nav: Res, + buttons: Query<(&Interaction, &Action), Changed>, + mut wheel: MessageReader, + mut scroll_nodes: Query<(&mut ScrollPosition, &ComputedNode), With>, + config: Res, + manifest: Res, +) { + if !state.open { + wheel.clear(); + return; + } + for event in wheel.read() { + let amount = if event.unit == MouseScrollUnit::Line { + event.y * 48. + } else { + event.y + }; + for (mut position, computed) in &mut scroll_nodes { + let limit = ((computed.content_size().y - computed.size().y) + * computed.inverse_scale_factor()) + .max(0.); + state.scroll = (state.scroll - amount).clamp(0., limit); + position.y = state.scroll; + } + } + if state.just_opened { + state.just_opened = false; + return; + } + if keys.just_pressed(KeyCode::Escape) || nav.pressed & (0x2000 | 0x10) != 0 { + state.open = false; + state.dirty = true; + return; + } + let count = state.visible_entries().len() + 4; + let previous = state.selected; + if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { + state.selected = if state.selected >= 4 + COLUMNS { + state.selected - COLUMNS + } else { + state.selected.saturating_sub(1) + }; + } + if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 { + state.selected = + (state.selected + if state.selected >= 4 { COLUMNS } else { 1 }).min(count - 1); + } + if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 { + state.selected = state.selected.saturating_sub(1); + } + if keys.just_pressed(KeyCode::ArrowRight) || nav.pressed & 8 != 0 { + state.selected = (state.selected + 1).min(count - 1); + } + if previous != state.selected { + if state.selected >= 4 { + let top = ((state.selected - 4) / COLUMNS) as f32 * ROW_HEIGHT; + let height = scroll_nodes + .iter() + .next() + .map(|(_, n)| n.size().y * n.inverse_scale_factor()) + .unwrap_or(400.); + state.scroll = state + .scroll + .min(top) + .max(top + CARD_HEIGHT - height) + .max(0.); + } + state.dirty = true; + } + let mut action = if keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0 { + Some(state.selected) + } else { + None + }; + for (interaction, button) in &buttons { + if *interaction == Interaction::Pressed { + action = Some(button.0); + } + } + if let Some(action) = action { + state.selected = action; + match action { + 0 if state.import.is_none() => { + state.status = match start_import( + &state.directory, + &config + .asset_root + .join(manifest.0.character_scene.split('#').next().unwrap()), + ) { + Ok(job) => { + state.import = Some(job); + "Choose a Mixamo FBX in the file picker. Import may take a moment.".into() + } + Err(e) => e, + }; + } + 1 => state.request_stock(), + 2 => { + state.filter = (state.filter + 1) % 4; + state.scroll = 0.; + } + 3 => state.open = false, + action if action >= 4 => { + if let Some(entry) = state.visible_entries().get(action - 4) { + state.request = Some(Some(entry.id.clone())); + } + } + _ => (), + } + state.dirty = true; + } +} +fn start_import(directory: &Path, reference: &Path) -> Result { + let executable = std::env::current_exe() + .map_err(|e| e.to_string())? + .parent() + .ok_or("Missing game directory")? + .join("support/custom-models/Mixamo to Skate.exe"); + if !executable.is_file() { + return Err("Character importer is missing. Use the complete Custom Models build.".into()); + } + let jobs = directory.join("jobs"); + std::fs::create_dir_all(&jobs).map_err(|e| e.to_string())?; + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let result = jobs.join(format!("{}-{nonce}.json", std::process::id())); + let mut command = Command::new(executable); + command + .arg("--library-import") + .arg(directory) + .arg("--reference") + .arg(reference) + .arg("--result") + .arg(&result) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let child = command + .spawn() + .map_err(|e| format!("Could not open importer: {e}"))?; + Ok(Import { + child, + result, + started: Instant::now(), + }) +} +fn poll_import(mut state: ResMut) { + let Some(job) = state.import.as_mut() else { + return; + }; + if job.started.elapsed().as_secs() > 900 { + let _ = job.child.kill(); + } + let finished = match job.child.try_wait() { + Ok(Some(_)) => true, + Ok(None) => false, + Err(_) => true, + }; + if !finished { + return; + } + let job = state.import.take().unwrap(); + let reply = std::fs::read(&job.result) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()); + let _ = std::fs::remove_file(&job.result); + state.entries = discover(&state.directory); + state.status = match reply.as_ref().and_then(|r| r["status"].as_str()) { + Some("cancelled") => "Import cancelled. Your character is unchanged.".into(), + Some("ready") => { + let id = reply.as_ref().unwrap()["id"].as_str().unwrap_or(""); + if let Some(index) = state.entries.iter().position(|e| e.id == id) { + let id = state.entries[index].id.clone(); + state.filter = 0; + state.selected = index + 4; + state.scroll = (index / COLUMNS) as f32 * ROW_HEIGHT; + state.request = Some(Some(id)); + "Imported. Loading character...".into() + } else { + "Import did not produce a valid library entry.".into() + } + } + Some("error") => reply.as_ref().unwrap()["message"] + .as_str() + .unwrap_or("Import failed") + .chars() + .take(350) + .collect(), + _ => "The importer stopped before finishing. Your character is unchanged.".into(), + }; + state.dirty = true; +} +fn publish( + mut commands: Commands, + mut state: ResMut, + server: Res, + skater: Res, + mut animation: ResMut, + roots: Query>, + spawner: Res, + instances: Query<&SceneInstance>, + skins: Query<(Entity, &SkinnedMesh)>, + nodes: Query<(&Name, &Transform)>, + parents: Query<&ChildOf>, + mut scenes: Query<(Entity, &ChildOf, &mut Visibility), With>, +) { + let Ok(player) = roots.single() else { + return; + }; + if let Some(request) = state.request.take() { + if let Some(p) = state.pending.take() { + commands.entity(p.root).despawn(); + } + match request { + None => { + if let Some(root) = state.active_root.take() { + commands.entity(root).despawn(); + } + for (e, visibility) in state.stock_visibility.drain(..) { + if let Ok((_, _, mut v)) = scenes.get_mut(e) { + *v = visibility; + } + } + state.active = None; + *animation = default(); + state.status = match save_selection(&state.directory, None) { + Ok(()) => "Stock skater restored.".into(), + Err(e) => format!("Restored; selection could not be saved: {e}"), + }; + } + Some(id) + if state.active.as_ref() != Some(&id) + && state.entries.iter().any(|e| e.id == id) => + { + let path = format!("characters://entries/{id}/character.glb"); + let asset = server.load(path.clone()); + let root = commands + .spawn(( + CustomModelRoot, + Transform::default(), + Visibility::Hidden, + SceneRoot(server.load(GltfAssetLabel::Scene(0).from_asset(path))), + )) + .id(); + commands.entity(player).add_child(root); + state.pending = Some(Pending { + id, + root, + asset, + started: Instant::now(), + }); + state.status = "Loading model; your current skater stays active.".into(); + } + _ => (), + } + state.dirty = true; + } + let Some(p) = state.pending.as_ref() else { + return; + }; + let error = match server.get_load_state(p.asset.id()) { + Some(LoadState::Failed(e)) => Some(format!("Could not load model: {e}")), + _ if p.started.elapsed().as_secs() > 120 => Some("Character loading timed out.".into()), + _ => None, + }; + let prepared = if let Some(error) = error { + Some(Err(error)) + } else if server.is_loaded_with_dependencies(p.asset.id()) + && instances + .get(p.root) + .is_ok_and(|i| spawner.instance_is_ready(**i)) + { + Some(crate::animation::AnimationStatus::for_scene( + p.root, + &skater.animation.evaluator.frames.bone_names, + &skins, + &nodes, + &parents, + )) + } else { + None + }; + let Some(prepared) = prepared else { + return; + }; + let pending = state.pending.take().unwrap(); + match prepared { + Err(error) => { + commands.entity(pending.root).despawn(); + state.status = format!("{error} Previous character retained."); + } + Ok(bindings) => { + if state.active_root.is_none() { + for (entity, parent, mut visibility) in &mut scenes { + if parent.parent() == player && entity != pending.root { + state.stock_visibility.push((entity, *visibility)); + *visibility = Visibility::Hidden; + } + } + } + if let Some(old) = state.active_root.replace(pending.root) { + commands.entity(old).despawn(); + } + if let Ok((_, _, mut visibility)) = scenes.get_mut(pending.root) { + *visibility = Visibility::Inherited; + } + *animation = bindings; + state.active = Some(pending.id.clone()); + state.status = match save_selection(&state.directory, Some(pending.id)) { + Ok(()) => "Character equipped. Resume whenever you're ready.".into(), + Err(e) => format!("Equipped; could not save selection: {e}"), + }; + } + } + state.dirty = true; +} +fn stop_import(mut state: ResMut, mut exit: MessageReader) { + if exit.read().next().is_some() { + if let Some(mut job) = state.import.take() { + let _ = job.child.kill(); + let _ = job.child.wait(); + } + } +} +fn draw( + mut commands: Commands, + mut state: ResMut, + server: Res, + existing: Query>, +) { + if !state.dirty { + return; + } + state.dirty = false; + for e in &existing { + commands.entity(e).despawn(); + } + if !state.open { + return; + } + let selected = state.selected; + commands.spawn((Panel, GlobalZIndex(30), Node { position_type:PositionType::Absolute, width:percent(100), height:percent(100), + justify_content:JustifyContent::Center, align_items:AlignItems::Center, ..default() }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.94)))) + .with_children(|root| { root.spawn((Node { width:px(800), height:percent(94), max_width:percent(96), padding:UiRect::all(px(20)), + flex_direction:FlexDirection::Column, row_gap:px(14), ..default() }, BackgroundColor(Color::srgb(0.035,0.055,0.08)))) + .with_children(|panel| { + label(panel, "CUSTOM MODELS", 30.); + label(panel, "Choose a pro, special character, or your own imported model.", 17.); + panel.spawn(Node { column_gap:px(10), ..default() }).with_children(|bar| { + button(bar,0, if state.import.is_some() { "Importing..." } else { "Import model..." },selected); + button(bar,2,["Show: All", "Show: Pros", "Show: Specials", "Show: Imported"][state.filter],selected); + button(bar,1,if state.active.is_none() { "Stock skater (active)" } else { "Use stock skater" },selected); + }); + panel.spawn((ModelScroll, ScrollPosition(Vec2::new(0., state.scroll)), Node { + display:Display::Grid, grid_template_columns:RepeatedGridTrack::flex(COLUMNS as u16, 1.), + grid_auto_rows:vec![GridTrack::px(CARD_HEIGHT)], column_gap:px(10), row_gap:px(10), + flex_grow:1., min_height:px(0), overflow:Overflow::scroll_y(), align_content:AlignContent::Start, + ..default() })).with_children(|cards| { + for (i,entry) in state.visible_entries().into_iter().enumerate() { + let action=i+4; + cards.spawn((Button,Action(action),Node { min_width:px(0),height:px(CARD_HEIGHT),align_items:AlignItems::Center,padding:UiRect::all(px(6)),flex_direction:FlexDirection::Column,row_gap:px(6), ..default() }, + BackgroundColor(if selected==action { Color::srgb(0.12,0.3,0.34) } else { Color::srgb(0.075,0.105,0.14) }))) + .with_children(|card| { + card.spawn((ImageNode::new(server.load(format!("characters://entries/{}/preview.png",entry.id))),Node {width:px(128),height:px(160),..default()})); + label(card,&entry.name,15.); + if let Some(native) = &entry.native { label(card,&format!("{} · Native",native.category),12.); } + if state.active.as_ref()==Some(&entry.id) { label(card,"Equipped",14.); } + }); + } + if state.visible_entries().is_empty() { label(cards,"Your imported characters will appear here.",18.); } + }); + panel.spawn(Node { column_gap:px(10), ..default() }).with_children(|bar| { + label(bar,&format!("{} models / Scroll to browse",state.visible_entries().len()),16.); + button(bar,3,"Back",selected); + }); + label(panel,&state.status,16.); + label(panel,"Up/Down select · Enter/A equip · Escape/B back\nNative characters retain retail animation styles. Imports use your customiser style.",14.); + }); }); +} +fn label(parent: &mut ChildSpawnerCommands, text: &str, size: f32) { + parent.spawn(( + Text::new(text), + TextFont { + font_size: size, + ..default() + }, + TextColor(Color::srgb(0.85, 0.91, 0.95)), + )); +} +fn button(parent: &mut ChildSpawnerCommands, action: usize, text: &str, selected: usize) { + parent + .spawn(( + Button, + Action(action), + Node { + padding: UiRect::all(px(10)), + ..default() + }, + BackgroundColor(if action == selected { + Color::srgb(0.12, 0.3, 0.34) + } else { + Color::srgb(0.08, 0.12, 0.17) + }), + )) + .with_children(|p| label(p, text, 17.)); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + #[ignore = "Requires private retail roster and animation banks; asset loading only"] + fn native_roster_assets_bind_to_the_retail_animation_skeleton() { + use bevy::{ + asset::AssetPlugin, ecs::system::SystemState, gltf::GltfPlugin, image::ImagePlugin, + mesh::MeshPlugin, scene::ScenePlugin, + }; + let directory = PathBuf::from(std::env::var("SKATE_TEST_LIBRARY").unwrap()); + let banks = skate_data::animation_banks::AnimationBanks::load(Path::new( + &std::env::var("SKATE_TEST_ASSETS").unwrap(), + )) + .unwrap(); + let names = skate_data::animation_frames::AnimationFrames::from_banks(&banks) + .unwrap() + .bone_names; + let metadata = banks.metadata().unwrap(); + let mut supported = std::collections::HashSet::new(); + for bank in &banks.banks { + for record in bank.records().iter().filter(|r| r.header.type_id == 8) { + if let Ok(skate_data::animation_metadata::TreeMetadata::Selector(selector)) = + metadata.tree(&record.header.name) + { + if selector.parameter.eq_ignore_ascii_case("ProSkater") { + for (value, child) in selector.values.iter().zip(&selector.children) { + assert!( + metadata.tree(child).is_ok(), + "Missing native style child {child}" + ); + supported.insert(value.to_ascii_uppercase()); + } + } + } + } + } + let entries: Vec<_> = discover(&directory) + .into_iter() + .filter(|e| e.native.is_some()) + .collect(); + assert_eq!( + entries.len(), + 41, + "All registered retail pros and specials must be installed" + ); + for entry in &entries { + let style = native_animation_style(&entry.native.as_ref().unwrap().key); + assert!( + supported.contains(&style.to_ascii_uppercase()), + "Native animation bank has no {style} selector" + ); + } + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins, + AssetPlugin { + file_path: directory.to_string_lossy().into_owned(), + ..default() + }, + ImagePlugin::default(), + MeshPlugin, + ScenePlugin, + GltfPlugin::default(), + )); + app.init_asset::() + .init_asset::(); + app.finish(); + app.cleanup(); + for entry in entries { + let handle: Handle = app + .world() + .resource::() + .load(format!("entries/{}/character.glb", entry.id)); + let started = Instant::now(); + loop { + app.update(); + let server = app.world().resource::(); + if server.is_loaded_with_dependencies(handle.id()) { + break; + } + assert!( + !matches!( + server.get_load_state(handle.id()), + Some(LoadState::Failed(_)) + ), + "{} failed to load", + entry.name + ); + assert!(started.elapsed().as_secs() < 45, "{} timed out", entry.name); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let scene = app + .world() + .resource::>() + .get(&handle) + .unwrap() + .scenes[0] + .clone(); + let mut scenes = app.world_mut().resource_mut::>(); + let world = &mut scenes.get_mut(&scene).unwrap().world; + let roots: Vec = world + .query_filtered::>() + .iter(world) + .collect(); + let root = world.spawn_empty().id(); + for e in roots { + world.entity_mut(e).insert(ChildOf(root)); + } + let mut queries: SystemState<( + Query<(Entity, &SkinnedMesh)>, + Query<(&Name, &Transform)>, + Query<&ChildOf>, + )> = SystemState::new(world); + let (skins, nodes, parents) = queries.get(world); + let binding = crate::animation::AnimationStatus::for_scene( + root, &names, &skins, &nodes, &parents, + ) + .unwrap_or_else(|e| panic!("{}: {e}", entry.name)); + assert!(binding.ready); + drop(scenes); + drop(scene); + drop(handle); + app.update(); + app.update(); + } + } + #[test] + fn imported_png_and_jpeg_textures_decode_in_the_game() { + use bevy::{ + asset::RenderAssetUsages, + image::{CompressedImageFormats, ImageSampler, ImageType}, + }; + for (data, mime) in [ + ( + include_bytes!("tests/fixtures/texture-codec.jpg").as_slice(), + "image/jpeg", + ), + ( + include_bytes!("tests/fixtures/texture-codec.png").as_slice(), + "image/png", + ), + ] { + let image = Image::from_buffer( + data, + ImageType::MimeType(mime), + CompressedImageFormats::NONE, + true, + ImageSampler::default(), + RenderAssetUsages::default(), + ) + .unwrap(); + assert_eq!(image.size(), UVec2::new(2, 2)); + let pixels = image.data.unwrap(); + assert!( + pixels[0] > 200 && pixels[1] < 60 && pixels[2] < 40, + "Texture must keep its authored red colour" + ); + } + } + #[test] + #[ignore = "Requires a private model and its expected diffuse image; loads assets only, no window or renderer"] + fn private_model_keeps_its_diffuse_texture() { + use bevy::{ + asset::AssetPlugin, gltf::GltfPlugin, image::ImagePlugin, mesh::MeshPlugin, + scene::ScenePlugin, + }; + let source = PathBuf::from(std::env::var("SKATE_TEST_CHARACTER").unwrap()); + let mut app = App::new(); + app.add_plugins(( + MinimalPlugins, + AssetPlugin { + file_path: source.parent().unwrap().to_string_lossy().into_owned(), + ..default() + }, + ImagePlugin::default(), + MeshPlugin, + ScenePlugin, + GltfPlugin::default(), + )); + app.init_asset::() + .init_asset::(); + app.finish(); + app.cleanup(); + let asset: Handle = app + .world() + .resource::() + .load(source.file_name().unwrap().to_string_lossy().into_owned()); + let started = Instant::now(); + loop { + app.update(); + let server = app.world().resource::(); + if server.is_loaded_with_dependencies(asset.id()) { + break; + } + assert!( + !matches!( + server.get_load_state(asset.id()), + Some(LoadState::Failed(_)) + ), + "Model load failed" + ); + assert!(started.elapsed().as_secs() < 30, "Model load timed out"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let gltf = app.world().resource::>().get(&asset).unwrap(); + let material = app + .world() + .resource::>() + .get(&gltf.materials[0]) + .unwrap(); + let diffuse = app + .world() + .resource::>() + .get(material.base_color_texture.as_ref().unwrap()) + .unwrap(); + let bytes = std::fs::read(std::env::var("SKATE_TEST_DIFFUSE").unwrap()).unwrap(); + let expected = Image::from_buffer( + &bytes, + bevy::image::ImageType::MimeType("image/png"), + bevy::image::CompressedImageFormats::NONE, + true, + default(), + default(), + ) + .unwrap(); + assert_eq!(diffuse.size(), expected.size()); + assert_eq!( + diffuse.data, expected.data, + "Game material must use the authored diffuse, not a shifted texture handle" + ); + } + #[test] + fn library_ids_cannot_escape_the_asset_source() { + assert!(valid_id(&"ab".repeat(32))); + for bad in ["../character", "C:/model", "", &"F".repeat(64)] { + assert!(!valid_id(bad)); + } + } + #[test] + fn corrupt_or_incomplete_entries_are_not_published() { + let root = std::env::temp_dir().join(format!("skate-model-library-{}", std::process::id())); + let id = "a".repeat(64); + let entry = root.join("entries").join(&id); + std::fs::create_dir_all(&entry).unwrap(); + std::fs::write( + entry.join("manifest.json"), + format!(r#"{{"version":1,"id":"{id}","name":"Model"}}"#), + ) + .unwrap(); + assert!(discover(&root).is_empty()); + std::fs::write(entry.join("character.glb"), b"fixture").unwrap(); + std::fs::write(entry.join("preview.png"), b"fixture").unwrap(); + assert_eq!(discover(&root).len(), 1); + save_selection(&root, Some(id.clone())).unwrap(); + save_selection(&root, None).unwrap(); + let saved: Selection = + serde_json::from_slice(&std::fs::read(root.join("selection.json")).unwrap()).unwrap(); + assert!(saved.selected.is_none()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/skate-game/src/custom_models_main.rs b/crates/skate-game/src/custom_models_main.rs new file mode 100644 index 00000000..d5be06a9 --- /dev/null +++ b/crates/skate-game/src/custom_models_main.rs @@ -0,0 +1 @@ +include!("main.rs"); diff --git a/crates/skate-game/src/customiser.rs b/crates/skate-game/src/customiser.rs index 57ca9d24..76441500 100644 --- a/crates/skate-game/src/customiser.rs +++ b/crates/skate-game/src/customiser.rs @@ -990,10 +990,16 @@ fn interact( } fn preferences( state: Res, + models: Res, mut physics: ResMut, mut skater: ResMut, ) { apply_preferences(&state.draft, &mut physics, &mut skater.animation); + if let Some(style) = models.native_style() { + skater.animation.motion.playback_context.pro_skater = skate_core::animation::skeleton_input::name::encode(style.as_bytes()); + skater.animation.motion.animation.posture.set_profile(0); + physics.set_gesture_preferences(None); + } } pub(crate) fn apply_preferences( profile: &Value, diff --git a/crates/skate-game/src/customiser_material.rs b/crates/skate-game/src/customiser_material.rs index 0c3b008a..b45b6dc9 100644 --- a/crates/skate-game/src/customiser_material.rs +++ b/crates/skate-game/src/customiser_material.rs @@ -1,6 +1,6 @@ //! Skin stamps modify albedo before the unchanged StandardMaterial lighting. use bevy::{ - asset::embedded_asset, + asset::{AssetPath, embedded_asset, embedded_path}, pbr::{ExtendedMaterial, MaterialExtension}, prelude::*, render::render_resource::AsBindGroup, @@ -29,10 +29,10 @@ pub(crate) struct SkinStamp { } impl MaterialExtension for SkinStamp { fn fragment_shader() -> ShaderRef { - "embedded://skate3rust/customiser_stamp.wgsl".into() + AssetPath::from(embedded_path!("customiser_stamp.wgsl")).with_source("embedded").into() } fn prepass_fragment_shader() -> ShaderRef { - "embedded://skate3rust/customiser_prepass.wgsl".into() + AssetPath::from(embedded_path!("customiser_prepass.wgsl")).with_source("embedded").into() } fn deferred_fragment_shader() -> ShaderRef { Self::fragment_shader() @@ -64,13 +64,23 @@ pub(crate) fn placement(target: [f32; 4], art: [f32; 4]) -> (Vec4, Vec4) { mod tests { use super::*; #[test] + fn embedded_shader_paths_match_this_binary() { + for (shader, registered) in [ + (SkinStamp::fragment_shader(), embedded_path!("customiser_stamp.wgsl")), + (SkinStamp::prepass_fragment_shader(), embedded_path!("customiser_prepass.wgsl")), + ] { + let ShaderRef::Path(path) = shader else { panic!("Expected embedded shader path") }; + assert_eq!(path, AssetPath::from(registered).with_source("embedded")); + } + } + #[test] fn customiser_stamp_maps_authored_borders() { let (t, r) = placement([0.5, 0.0, 0.1, 0.4], [0.9, 0.1, 0.2, 0.8]); assert!((Vec2::new(r.x, r.y) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.2, 0.1), 1e-6)); assert!((Vec2::new(r.z, r.w) * t.xy() + t.zw()).abs_diff_eq(Vec2::new(0.8, 0.9), 1e-6)); assert_eq!( bevy::asset::embedded_path!("customiser_stamp.wgsl"), - std::path::PathBuf::from("skate3rust/customiser_stamp.wgsl") + std::path::PathBuf::from(format!("{}/customiser_stamp.wgsl", module_path!().split("::").next().unwrap())) ); } #[test] diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index 75b24086..5c90574a 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -499,12 +499,13 @@ pub(crate) fn update( mut materials: ResMut>, root: Query>, parents: Query<&ChildOf>, - mut scenes: Query<(Entity, Option<&PartRoot>, &mut Visibility), With>, + mut scenes: Query<(Entity, Option<&PartRoot>, &mut Visibility), (With, Without)>, mut mesh_materials: Query<(Entity, Option<&mut MeshMaterial3d>), With>, mut morphs: Query<(Entity, &mut MorphWeights)>, mut animation: ResMut, + custom_models: Res, ) { - if !state.enabled { + if !state.enabled || custom_models.active.is_some() { return; } let Ok(root) = root.single() else { diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index c86578c7..241831ae 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -207,12 +207,12 @@ fn setup( display: Display::None, width:percent(100), height:percent(100), align_items:AlignItems::Center, justify_content:JustifyContent::Center, position_type:PositionType::Absolute, ..default() }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.88)))).with_children(|root| { - root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() }, + root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(4),border_radius:BorderRadius::all(px(12)),..default() }, BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..12 { - panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, + for i in 0..13 { + panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); }); @@ -255,6 +255,7 @@ pub(crate) fn interact( mut config: ResMut, mut transition: ResMut, mut customiser: ResMut, + mut custom_models: ResMut, nav: Res, mut physics: ResMut, keys: Res>, @@ -270,7 +271,7 @@ pub(crate) fn interact( time.pause(); return; } - if customiser.open { return; } + if customiser.open || custom_models.open { return; } if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 { menu.open = !menu.open; } @@ -296,7 +297,7 @@ pub(crate) fn interact( } } if menu.open { - let rows = if menu.multiplayer { 11 } else { 12 }; + let rows = if menu.multiplayer { 11 } else { 13 }; if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 { menu.selected = (menu.selected + rows - 1) % rows; } @@ -409,11 +410,12 @@ pub(crate) fn interact( 9 => { exit.write(AppExit::Success); } - 10 => customiser.begin(), + 10 => { custom_models.request_stock(); customiser.begin(); }, 11 => { menu.multiplayer = true; menu.selected = 0; } + 12 => custom_models.begin(), _ => {} } } @@ -497,6 +499,7 @@ fn labels( transition: Res, time: Res>, customiser: Res, + custom_models: Res, net: Res, window: Single<&Window, With>, mut root: Single<&mut Node, With>, @@ -504,7 +507,7 @@ fn labels( mut status: Single<&mut Text, With>, mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without>, ) { - root.display = if menu.open && !customiser.open { + root.display = if menu.open && !customiser.open && !custom_models.open { Display::Flex } else { Display::None @@ -596,6 +599,7 @@ fn labels( 8 => "Resume".into(), 9 => "Quit game".into(), 10 => "Character customiser".into(), + 12 => "Custom models".into(), _ => "Multiplayer".into(), } }; @@ -618,7 +622,7 @@ fn labels( menu.status.clone() }; for (row, interaction, mut color, mut node) in &mut buttons { - node.display = if menu.multiplayer && row.0 == 11 { Display::None } else { Display::Flex }; + node.display = if menu.multiplayer && row.0 >= 11 { Display::None } else { Display::Flex }; color.0 = if row.0 == menu.selected || *interaction == Interaction::Hovered { Color::srgb(0.10, 0.30, 0.34) } else { @@ -656,7 +660,6 @@ mod tests { .insert_resource(images) .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), - maps: Vec::new(), selected_map: 0, difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), multiplayer: false, browser: false, maps: vec![crate::map_library::Entry { label: "Test world".into(), path: None }], selected_map: 0, diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 5d1a95cf..711a9b38 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -22,6 +22,7 @@ mod graphics_menu; mod customiser; mod customiser_parts; mod customiser_material; +mod custom_models; mod render_capacity; mod retail_render; mod presentation; diff --git a/crates/skate-game/src/session_marker/effect.rs b/crates/skate-game/src/session_marker/effect.rs index f947898a..f6fc15a0 100644 --- a/crates/skate-game/src/session_marker/effect.rs +++ b/crates/skate-game/src/session_marker/effect.rs @@ -1,7 +1,7 @@ //! Native noise pass, composited before the original HUD. use super::noise::Noise; use bevy::{ - asset::{RenderAssetUsages, embedded_asset}, + asset::{AssetPath, RenderAssetUsages, embedded_asset, embedded_path}, camera::visibility::RenderLayers, image::{ImageAddressMode, ImageSampler, ImageSamplerDescriptor}, prelude::*, @@ -27,7 +27,7 @@ struct Effect { } impl Material2d for Effect { fn fragment_shader() -> ShaderRef { - "embedded://skate3rust/session_marker/effect.wgsl".into() + AssetPath::from(embedded_path!("effect.wgsl")).with_source("embedded").into() } fn alpha_mode(&self) -> AlphaMode2d { AlphaMode2d::Blend @@ -39,6 +39,15 @@ struct Runtime { material: Handle, time: f64, } +#[cfg(test)] +mod shader_path_tests { + use super::*; + #[test] + fn embedded_shader_paths_match_this_binary() { + let ShaderRef::Path(path) = Effect::fragment_shader() else { panic!("Expected embedded shader path") }; + assert_eq!(path, AssetPath::from(embedded_path!("effect.wgsl")).with_source("embedded")); + } +} #[derive(Component)] struct Screen; diff --git a/crates/skate-game/src/tests/fixtures/texture-codec.jpg b/crates/skate-game/src/tests/fixtures/texture-codec.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f3dd09bf56c0b5dd82f95c4829b3b675aa257c4f GIT binary patch literal 633 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!?PEQx_)l{s{H>Z0Ffim$p8QV literal 0 HcmV?d00001 diff --git a/crates/skate-game/src/tests/fixtures/texture-codec.png b/crates/skate-game/src/tests/fixtures/texture-codec.png new file mode 100644 index 0000000000000000000000000000000000000000..eabea800aca9f7b7013c8cfa78e6db2793565477 GIT binary patch literal 75 zcmeAS@N?(olHy`uVBq!ia0vp^Od!kwBL7~QRScwrJY5_^D&{2bS*&>Iz<~ot7}yy2 XgcxO(bI&va$})Jm`njxgN@xNAT4WM! literal 0 HcmV?d00001 diff --git a/docs/custom-models.md b/docs/custom-models.md new file mode 100644 index 00000000..ae179698 --- /dev/null +++ b/docs/custom-models.md @@ -0,0 +1,15 @@ +# Custom model library + +Open the pause menu, choose **Custom models**, then **Import model...**. The native file picker accepts Mixamo FBX and self-contained GLB characters. Successful imports generate textured thumbnail cards and equip automatically. Click any saved card to swap, or **Use stock skater** to restore the regular customiser. Selection persists between sessions. The three-column grid scrolls vertically with the mouse wheel. Keyboard: arrow keys and Enter; controller: D-pad and A; Escape/B returns to the pause menu. Keyboard/controller selection scrolls into view. The Show filter switches between all models, native pros, native specials, and imports. + +The game runs `support/custom-models/Mixamo to Skate.exe` as a hidden child process; its file picker remains visible. Bundle the complete PyInstaller onedir output, FBX2glTF under `tools`, licenses, and optionally a locally generated `calibration.json`. Python and Blender are not required on the player's machine. Conversion instructions and restrictions are in `mixamo-to-skate.md`. + +The library lives in `%LOCALAPPDATA%/Skate3RustEngine/custom-characters`. Each content-addressed entry contains `manifest.json`, `character.glb`, `source.glb`, `preview.png`, and a conversion report when converted. Imports publish through an atomic directory rename, so failed imports never appear as completed entries. Saved assets do not depend on the original FBX location. Import failures/cancellation preserve the existing character. + +The game loads each candidate as a hidden scene, waits for its dependencies and scene instance, validates its stock animation bone bindings, and then commits the visibility/binding switch. The same player entity, physics, animation evaluator, camera and replay state remain in use. Stock modular scene visibility is restored when leaving the custom character. Converted characters include the reference board; the stock board customisation returns with the stock character. Custom selections are local visuals and are not transferred to multiplayer peers. + +The paired calibration reproduces the supplied stock character's bind geometry; arbitrary humanoids use the converter's rig fitting and may need adjustment for unusual proportions. This is not a promise of original weight recovery or identical deformation on every model. + +Build the separate executable with `cargo build --release --locked --target x86_64-pc-windows-msvc -p skate-game --bin skate3-custom-models --no-default-features`, using `RUSTFLAGS=-C target-feature=+crt-static`. Stage beside `support/custom-models` and launch with `--assets` pointing at the prepared owned asset directory. + +Verification: procedural Python conversion/library tests, Rust library discovery and candidate binding tests, release compilation, packaged importer conversion of the supplied FBX, thumbnail inspection, and independent Khronos glTF validation. No game launch or gameplay testing was performed; menu rendering and live visual swapping still need user playtesting. diff --git a/docs/mixamo-to-skate.md b/docs/mixamo-to-skate.md new file mode 100644 index 00000000..2d19c2db --- /dev/null +++ b/docs/mixamo-to-skate.md @@ -0,0 +1,116 @@ +# Mixamo to Skate converter + +This offline converter produces a GLB for the existing stock-pose renderer. It +does not install assets, implement a menu library, change physics, or launch the +game. Export a standard Mixamo humanoid **with skin**, preferably in T-pose. +Use owned/local assets as the stock reference. Blender is not required. + +## Player workflow + +The Windows package contains `Mixamo to Skate.exe` and `tools/FBX2glTF.exe`. +Drag one or more FBX files onto the executable, or double-click it and choose +files. Results appear in a new `-skate` directory next to the input; +existing results get a numeric suffix. The directory contains `character.glb`, +`report.json`, and a short readme. The stock reference is found in the local +Skate3RustEngine installation; multiple/missing installations prompt for it. + +No settings are modified. Output publication is transactional: failure leaves +existing characters and source files unchanged. No assets are uploaded. + +## Source use + +Install the Python requirements into your own tool environment. Obtain +FBX2glTF 0.9.7 from its official release and put the Windows executable in +`tools/mixamo_to_skate/tools/FBX2glTF.exe`, or pass `--fbx-tool` explicitly. + +```text +python tools/mixamo_to_skate/main.py model.fbx --reference path/to/private/skater.glb --noninteractive +python tools/mixamo_to_skate/main.py model.glb --reference path/to/private/skater.glb --no-board --noninteractive +``` + +The input GLB option is for an already normalized Mixamo conversion, not an +arbitrary rig. Arguments are passed directly to FBX2glTF without a shell. +Embedded FBX media extraction goes to a disposable temporary directory. + +To package with an existing Python/NumPy/PyInstaller environment: + +```text +python -m PyInstaller --onedir --console --name "Mixamo to Skate" --paths tools/mixamo_to_skate --distpath /app --workpath /build --specpath /build tools/mixamo_to_skate/main.py +``` + +Keep the resulting executable and `_internal` directory together. Add +`tools/FBX2glTF.exe` next to the executable. The official 0.9.7 Windows binary +used here has SHA-256 +`8d90fb5e0a8d186a3d9a7ff8c75eaee541c3975ce4df0d80351f20092ae0877f`. +Copy dependency license files into the package. A local private calibration +may be placed beside the executable; it is excluded from redistributable builds. + +## What conversion means + +This is **stock-proportion fitting**, not runtime motion retargeting. The +converter reads bind matrices, estimates the character's body axes and scale, +fits its bind geometry, and remaps/merges weights. The output uses the owned +stock hierarchy, stock bone axes and inverse binds (with negligible floating +point affine-row residue cleared). Local transforms are exported as TRS after +removing tiny stock matrix roundoff shear, retaining the authored inverse binds. All output +primitives share one skin, including the optional stock board. The engine +therefore retains its existing physics, contacts, motion and camera behavior. + +Mixamo Spine/Spine1/Spine2 map to stock SPINE/SPINE1/SPINE3; the unused stock +SPINE2 and NECK1 remain in the hierarchy. Missing target finger/end bones fold +into their nearest mapped ancestor. Missing toe joints are permitted. No +individual finger animation is created. Foot/hand contact quality and fitted +proportions require visual review; success is a structural validation result, +not a guarantee that every skating motion looks correct. + +Generic fitting aligns limb directions and positions to the stock rig. An +optional private `calibration.json` next to the executable improves fitting +using the user's round-trip pair: the original stock GLB and a Mixamo-rigged +copy of the same neutral geometry, normalized to GLB by FBX2glTF. Generate it +with `--calibrate --profile calibration.json` and the normalized source GLB. +It is locked to the reference SHA-256. It contains derived bind frames, not +meshes. Do not bundle private calibration or stock assets in public releases. +The converter works without it, with an explicit generic-fit warning. + +## Limits + +- Standard Mixamo body chain required; duplicate names and unrelated rigs fail. +- Stock-sized output: original unusual proportions are not preserved reliably. +- No automatic rigging, animation export, facial rigging, cloth or hair physics. +- Morph targets are rejected rather than silently lost. Source clips are omitted. +- Supports triangles, normals, UV0/UV1, vertex colours, embedded PNG/JPEG and + basic glTF materials. Texture/material extensions and external GLB resources + are rejected. FBX conversion diagnostics are retained in the report. +- Up to eight source influences; merge then retain four. More than 10% lost + weight on any vertex is an error. Any smaller loss is reported. +- Up to 512 MiB input and one million output character vertices. These are + import limits, not recommended game budgets. +- The board is copied from the local stock reference by default so the existing + combined-character presentation does not lose it. `--no-board` emits only the + character for a future modular importer. Stock outfit/profile integration and + gameplay installation are separate work. + +## Dependencies and verification + +FBX parsing uses Facebook's FBX2glTF 0.9.7 (BSD-3-Clause project, Autodesk FBX +SDK dependency). The Python conversion uses NumPy. Packaged Python builds use +PyInstaller and Tcl/Tk for the file chooser. Retain dependency notices when +packaging; no game content belongs in the redistributable tool folder. + +Primary sources: +- https://github.com/facebookincubator/FBX2glTF/releases/tag/v0.9.7 +- https://github.com/facebookincubator/FBX2glTF +- https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html + +Run `python -m unittest discover -s tools/mixamo_to_skate -p "test_*.py"`. +Tests use procedural fixtures, not retail models. Engine compilation and game +launches are not necessary for this standalone converter. Rendered gameplay +validation remains a later user-run step. + +Validation during development: nine procedural checks cover paired geometry, +unit/axis changes, hand-weight collapse and physical-pose skinning, generic +fitting, invalid joints, failed-output cleanup, existing-output protection, +reference mismatch, missing bones, morph rejection and truncated input. The +owned round-trip FBX was also converted and checked with Khronos glTF Validator +2.0.0-dev.3.10: zero errors; stock board normal maps retain the original +engine-generated tangent-space warnings. No gameplay tests were run. diff --git a/docs/native-roster.md b/docs/native-roster.md new file mode 100644 index 00000000..0d8fa1b1 --- /dev/null +++ b/docs/native-roster.md @@ -0,0 +1,31 @@ +# Native pro and special characters + +The Custom models menu includes the 41 character definitions in the owned retail `characters_marquee` table that inherit from `pro_skaters` or `ip` and name a real recipe. It includes Isaac Clarke (`isaak` / `z_legend`), Meat Man (`steak` / `security_03`), Dem Bones, Coach Frank, Dr Pepper Mascot and the retail pros. Props, ambient pedestrians, unused recipe-only assets and absent DLC definitions are not presented as additional playable pros. + +`tools/asset_pipeline/native_roster.py` extracts the original LOD0 modular RX2 meshes and recipe materials from `marquee.big`. It uses the existing retail RX2/ABIN scene exporter, retaining native vertices, weights, bone hierarchy and inverse binds. It performs no Mixamo processing, humanoid fitting, automatic weighting or animation baking. Matrix perspective residue below 1e-6 is made exactly affine for glTF compliance. Original diffuse, normal, alpha and specular textures feed the existing renderer's material representation; this does not reproduce every proprietary retail shader effect. + +Entries share the persistent model library and hot-swap mechanism. The menu's **Show** filter cycles All / Pros / Specials / Imported. Native metadata is committed with the scene, so failed loads retain both the previous model and its style. Saved selection works for native and imported characters alike. Switching to the stock customiser or an imported model restores the customiser's style preferences. + +## Native animation evidence + +Observed in Xbox 360 TU3 / retail 1.05, input dump SHA-256 `f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`, image base `0x82000000`: + +`GetCACSettings`, VA `0x82590BE0..0x82590DBC` (module offsets `0x590BE0..0x590DBC`), compares these character hashes and returns the strings at VA `0x82224820..0x82224864`: + +| Character | Hash | Animation selector | +|---|---|---| +| Danny Way | 42545B0938E341C9 | DannyWay | +| Mike Carroll | 146D3B7D88385267 | MikeCarroll | +| PJ Ladd | 1733A97B7064D4C2 | PJLadd | +| Jason Dill | 0DED833242F2F857 | JasonDill | +| Jerry Hsu | 1D939610FEA090EE | JerryHsu | +| Rob Dyrdek | BE3309CE9BA41097 | RobDyrdek | +| Other Marquees | fallback at 82590D88 | Aggressive | + +Character hashes independently resolve against the retail VLT names. These exact values are passed through the existing `ProSkater` construction attribute and graph condition, using the retained OnBoard/OffBoard banks. The skin choice does not replace those banks. Native gestures use the retail reset defaults; CAC posture overrides are disabled for these characters. Current stance, equipment tuning and in-progress skating state remain with the player. The authored selector takes effect when the native graph constructs its next animation tree, as with existing style edits; this avoids restarting a trick during a model swap. Not every pro or special character has a unique animation set. + +Private provenance and extraction reports live under `Documents/Mixamo-to-Skate/native-roster`. The source archive SHA-256 is `0e12660e22c33239d9f28a708d09b2e3f291276012c0c6296c42ae20b6b41acc`. + +## Verification + +All 41 exported models pass Khronos glTF validation with no errors. The native-roster test loads every GLB through Bevy, waits for image dependencies and binds every skinned scene against the retained retail animation skeleton. It also checks that the installed animation banks contain the selected ProSkater values and their child trees. These are offline asset tests; no game was launched and live gameplay/visual parity still requires user playtesting. diff --git a/tools/asset_pipeline/native_roster.py b/tools/asset_pipeline/native_roster.py new file mode 100644 index 00000000..3691d67e --- /dev/null +++ b/tools/asset_pipeline/native_roster.py @@ -0,0 +1,125 @@ +"""Publish owned retail Marquee models without autorigging or retargeting.""" +import argparse, hashlib, json, os, shutil, struct, sys, tempfile +import xml.etree.ElementTree as ET +from pathlib import Path +import numpy as np +from PIL import Image, ImageChops +from tools.owned_game.big import BigArchive +from tools.extract_default_skater import import_rx2_parser, decode_texture +from tools.asset_pipeline.character_glb import Glb, convert +from tools.asset_pipeline.retail_character import RX2, decode_dense_morphs + +# GetCACSettings 82590BE0..82590DBC, TU3: six named styles, Aggressive otherwise. +STYLES={'danny_way':'DannyWay','mike_carroll':'MikeCarroll','pj_ladd':'PJLadd', + 'jason_dill':'JasonDill','jerry_hsu':'JerryHsu','rob_dyrdek':'RobDyrdek'} +LABELS={'isaak':'Isaac Clarke (Dead Space)','steak':'Meat Man','dem_bones':'Dem Bones', + 'dr_pepper':'Dr Pepper Mascot','deerman':'Deerman of Dark Woods','pj_ladd':'PJ Ladd', + 'reda':'Giovanni Reda','attiba_jefferson':'Atiba Jefferson','cuz':'Cuz Parry'} + +def finalize_glb(path): + """Remove numerical perspective residue; retain native affine bind transforms.""" + data=path.read_bytes();length=struct.unpack_from('1e-6:raise ValueError('Non-affine retail joint') + for i in [3,7,11]:m[i]=0. + m[15]=1. + for skin in writer.doc['skins']: + a=writer.doc['accessors'][skin['inverseBindMatrices']];v=writer.doc['bufferViews'][a['bufferView']] + for j in range(a['count']): + offset=v.get('byteOffset',0)+a.get('byteOffset',0)+j*64 + for i in [3,7,11]:struct.pack_into(' 1e-12, 'Singular skin bind') + world = doc.worlds[ni] @ np.linalg.inv(inverse) + if joint in result: + require(np.allclose(result[joint], world, atol=1e-5, rtol=1e-4), + 'Meshes disagree about a bone bind pose') + result[joint] = world + # Unweighted endpoints inherit the nearest known bind transform. + def solve(i): + if i in result: + return result[i] + parent = doc.parents.get(i) + result[i] = (solve(parent) @ np.linalg.inv(doc.worlds[parent]) @ doc.worlds[i] + if parent is not None else doc.worlds[i]) + return result[i] + for i in range(len(doc.doc['nodes'])): + solve(i) + return result + + +def mesh_bounds(doc, exclude_board=False): + points = [] + for ni, node in enumerate(doc.doc['nodes']): + if 'mesh' not in node: + continue + for p in doc.doc['meshes'][node['mesh']]['primitives']: + name = doc.doc.get('materials', [])[p['material']].get('name', '') if 'material' in p else '' + if exclude_board and name in ('Retail_SkateBoard', 'Retail_SkateTruck', 'Retail_SkateWheel'): + continue + pos = doc.accessor(p['attributes']['POSITION']) + points.append(pos @ doc.worlds[ni][:3, :3].T + doc.worlds[ni][:3, 3]) + require(points, 'No character geometry') + return np.concatenate(points) + + +def normalization(doc, ref, binds, names): + """Resolve body axes from landmarks, then height/ground, avoiding unit guesses.""" + p = lambda k: binds[names[k]][:3, 3] + up = p('head')-p('hips') + require(np.linalg.norm(up)>1e-8, 'Degenerate head/hip landmarks') + up /= np.linalg.norm(up) + right = p('leftarm')-p('rightarm') # stock anatomical left is positive X + right -= up*np.dot(right, up) + require(np.linalg.norm(right) > 1e-8, 'Degenerate shoulder landmarks') + right /= np.linalg.norm(right) + forward = np.cross(right, up) + rotation = np.stack([right, up, forward]) + points = mesh_bounds(doc) @ rotation.T + target = mesh_bounds(ref, True) + height = np.ptp(points[:, 1]) + require(height > 1e-6, 'Degenerate character height') + scale = np.ptp(target[:, 1])/height + matrix = np.eye(4) + matrix[:3, :3] = rotation*scale + hip = rotation @ p('hips')*scale + matrix[:3, 3] = [-hip[0], target[:, 1].min()-points[:, 1].min()*scale, -hip[2]] + return matrix, scale + + +def source_skin(doc, primitive, skin): + attrs = primitive['attributes'] + require('JOINTS_0' in attrs and 'WEIGHTS_0' in attrs, 'Mesh has no skin weights') + require(not any(k.startswith(('JOINTS_', 'WEIGHTS_')) and k[-1] not in '01' for k in attrs), + 'More than eight source influences are unsupported') + js, ws = [], [] + for channel in (0, 1): + j, w = 'JOINTS_'+str(channel), 'WEIGHTS_'+str(channel) + require((j in attrs) == (w in attrs), 'Unpaired skin attributes') + if j in attrs: + js.append(doc.accessor(attrs[j]).astype(np.int64)) + ws.append(doc.accessor(attrs[w]).astype(float)) + joints, weights = np.concatenate(js, axis=1), np.concatenate(ws, axis=1) + require((joints >= 0).all() and (joints < len(skin['joints'])).all(), 'Invalid joint index') + require((weights >= 0).all() and (weights.sum(axis=1) > 1e-8).all(), 'Invalid skin weights') + return joints, weights/weights.sum(axis=1, keepdims=True) + + +def reference_rig(ref): + require(len(ref.doc.get('skins', [])) == 1, 'Stock reference must have one shared skin') + skin = ref.doc['skins'][0] + names = [ref.doc['nodes'][i].get('name', '').upper() for i in skin['joints']] + require(len(names) == len(set(names)) and set(MAP.values()) <= set(names), + 'Reference is not a complete supported stock render rig') + require(len(names) <= 256, 'Stock rig exceeds renderer joint limit') + inverse = ref.accessor(skin['inverseBindMatrices']).reshape(-1, 4, 4).transpose(0, 2, 1) + require(len(inverse) == len(names), 'Stock inverse bind count mismatch') + for j, inv in zip(skin['joints'], inverse): + require(np.allclose(ref.worlds[j] @ inv, np.eye(4), atol=2e-4), + 'Stock reference is not in its mesh bind pose') + return skin, names, inverse + + +def create_profile(source, reference): + doc, ref = Document(source), Document(reference) + reference_rig(ref) + names, binds = named_nodes(doc), bind_worlds(doc) + norm, scale = normalization(doc, ref, binds, names) + # Round-trip pairing is meaningful only when the same exported mesh returned. + # Bounding-box calibration avoids hip fitting shifts in the paired source. + src_points, ref_points = mesh_bounds(doc), mesh_bounds(ref, True) + src_box = np.stack([src_points.min(axis=0), src_points.max(axis=0)]) + ref_box = np.stack([ref_points.min(axis=0), ref_points.max(axis=0)]) + scale = float(np.ptp(ref_points[:,1])/np.ptp(src_points[:,1])) + fit = np.eye(4) + fit[:3, :3] *= scale + fit[:3, 3] = ref_box.mean(axis=0)-src_box.mean(axis=0)*scale + require(np.max(np.abs((src_box*scale+fit[:3, 3])-ref_box)) < 0.015, + 'Calibration is not the returned stock mesh (bounds differ by over 1.5 cm)') + return {'version':VERSION, 'reference_sha256':sha(reference), 'source_sha256':sha(source), + 'normalization_scale':scale, + 'bones':{k:(fit@binds[i]).tolist() for k, i in names.items()}, + 'note':'Private paired-stock calibration; contains no mesh or textures.'} + + +def fallback_targets(ref, names, binds, norm): + """Anatomical aim frames for non-calibrated use. Never apply stock bone roll to Mixamo axes.""" + _, target_names, inverse = reference_rig(ref) + stock = {n:np.linalg.inv(m) for n,m in zip(target_names, inverse)} + child = {'hips':'spine', 'spine':'spine1', 'spine1':'spine2', 'spine2':'neck', + 'neck':'head'} + for s in ('left', 'right'): + child.update({s+'shoulder':s+'arm', s+'arm':s+'forearm', s+'forearm':s+'hand', + s+'upleg':s+'leg', s+'leg':s+'foot', s+'foot':s+'toebase'}) + targets = {} + def rotation_between(a, b): + a, b = a/np.linalg.norm(a), b/np.linalg.norm(b) + v, c = np.cross(a, b), np.dot(a, b) + require(c > -0.99, 'Reversed limb in reference pose; export Mixamo T-pose') + k = np.array([[0,-v[2],v[1]], [v[2],0,-v[0]], [-v[1],v[0],0]]) + return np.eye(3)+k+k@k/(1+c) + for k, target in MAP.items(): + if k not in names: + continue + src = norm@binds[names[k]] + src[:3,:3] /= np.linalg.norm(src[:3,:3],axis=0) + result = src.copy() + result[:3, 3] = stock[target][:3, 3] + if k in child and child[k] in names: + src_direction = (norm@binds[names[child[k]]])[:3,3]-src[:3,3] + dst_direction = stock[MAP[child[k]]][:3,3]-result[:3,3] + if min(np.linalg.norm(src_direction), np.linalg.norm(dst_direction)) > 1e-6: + result[:3,:3] = rotation_between(src_direction, dst_direction)@src[:3,:3] + targets[k] = result + return targets + + +def convert_glb(source, reference, output, profile=None, include_board=True): + doc, ref = Document(source), Document(reference) + ref_skin, target_names, inverse = reference_rig(ref) + # The stock export contains ~1e-16 numerical residue in the affine row. + # glTF requires this row to be exactly (0,0,0,1). + require(np.allclose(inverse[:,3,:],[0,0,0,1],atol=1e-7), 'Non-affine stock inverse binds') + inverse[:,3,:] = [0,0,0,1] + names, binds = named_nodes(doc), bind_worlds(doc) + norm, scale = normalization(doc, ref, binds, names) + report = {'version':VERSION, 'source_sha256':sha(source), 'reference_sha256':sha(reference), + 'mode':'fit_to_stock_proportions', 'scale':float(scale), 'warnings':[], + 'vertices':0, 'triangles':0, 'collapsed_bones':{}, 'max_discarded_weight':0.0} + if profile: + require(profile.get('version') == VERSION and profile['reference_sha256'] == sha(reference), + 'Calibration belongs to another stock reference or converter version') + targets = {k:np.asarray(v,dtype=float) for k,v in profile['bones'].items()} + require(REQUIRED <= targets.keys(), 'Incomplete calibration') + # Calibration is world-space in metres; strip source export scale from its bone axes. + for m in targets.values(): + require(m.shape == (4,4) and np.isfinite(m).all(), 'Invalid calibration matrix') + m[:3,:3] /= np.linalg.norm(m[:3,:3], axis=0) + target_length = np.linalg.norm(targets['head'][:3,3]-targets['hips'][:3,3]) + source_length = np.linalg.norm(binds[names['head']][:3,3]-binds[names['hips']][:3,3]) + fitted_scale = target_length/source_length + norm[:3,:] *= fitted_scale/scale + scale = fitted_scale + report['scale'] = float(scale) + report['calibrated'] = True + else: + targets = fallback_targets(ref, names, binds, norm) + report['calibrated'] = False + report['warnings'].append('No paired calibration: generic anatomical fitting needs visual review.') + normalized = {} + for i, m in binds.items(): + m = norm@m + lengths = np.linalg.norm(m[:3,:3],axis=0) + require((lengths>1e-10).all(), 'Degenerate bind axes') + m[:3,:3] /= lengths + require(np.allclose(m[:3,:3].T@m[:3,:3],np.eye(3),atol=1e-3) and + np.linalg.det(m[:3,:3])>0, 'Sheared or mirrored bind rig is unsupported') + normalized[i] = m + target_index = {name:i for i,name in enumerate(target_names)} + def mapped(i): + original = i + while key(doc.doc['nodes'][i].get('name','')) not in MAP: + require(i in doc.parents, 'Weighted bone cannot map to stock: '+doc.doc['nodes'][original].get('name','')) + i = doc.parents[i] + k = key(doc.doc['nodes'][i]['name']) + if original != i: + report['collapsed_bones'][doc.doc['nodes'][original]['name']] = MAP[k] + return i, k, target_index[MAP[k]] + writer = Writer() + writer.materials_from(doc) + primitives = [] + for ni,node in enumerate(doc.doc['nodes']): + if 'mesh' not in node: + continue + skin = doc.doc['skins'][node['skin']] + matrices, mapped_joints = [], [] + for joint in skin['joints']: + ancestor,k,ti = mapped(joint) + # Unavailable destination fingers/endpoints follow the mapped ancestor. + use = joint if key(doc.doc['nodes'][joint].get('name','')) in targets else ancestor + tk = key(doc.doc['nodes'][use].get('name','')) + matrices.append(targets[tk] @ np.linalg.inv(normalized[use]) @ norm @ doc.worlds[ni]) + mapped_joints.append(ti) + matrices = np.asarray(matrices) + require(np.isfinite(matrices).all(), 'Invalid fit transforms') + for p in doc.doc['meshes'][node['mesh']]['primitives']: + require(p.get('mode',4) == 4, 'Only triangle meshes are supported') + require(not p.get('targets'), 'Blendshapes are not supported by version 1; export the neutral mesh') + attrs = p['attributes'] + require('NORMAL' in attrs, 'Normals are missing') + positions, normals = doc.accessor(attrs['POSITION']), doc.accessor(attrs['NORMAL']) + joints, weights = source_skin(doc,p,skin) + require(len(positions)==len(normals)==len(joints), 'Attribute lengths differ') + require(report['vertices']+len(positions) <= 1_000_000, 'Character exceeds 1 million vertices') + blended = np.einsum('vi,vijk->vjk', weights, matrices[joints]) + require((np.linalg.det(blended[:,:3,:3]) > 1e-10).all(), + 'Fit folds or collapses the mesh; export a neutral T-pose') + fitted = np.einsum('vij,vj->vi',blended[:,:3,:3],positions)+blended[:,:3,3] + normal_fit = np.einsum('vij,vj->vi',np.linalg.inv(blended[:,:3,:3]).transpose(0,2,1),normals) + normal_fit /= np.maximum(np.linalg.norm(normal_fit,axis=1,keepdims=True),1e-12) + merged = np.zeros((len(positions),len(target_names))) + for column in range(joints.shape[1]): + np.add.at(merged,(np.arange(len(positions)),np.asarray(mapped_joints)[joints[:,column]]),weights[:,column]) + output_joints = np.argsort(-merged,axis=1,kind='stable')[:,:4] + output_weights = np.take_along_axis(merged,output_joints,axis=1) + discarded = 1-output_weights.sum(axis=1) + report['max_discarded_weight'] = max(report['max_discarded_weight'],float(discarded.max())) + require(discarded.max() <= 0.1, 'Reducing to four weights loses over 10%; simplify skin weights first') + output_weights /= output_weights.sum(axis=1,keepdims=True) + output_joints[output_weights==0] = 0 + out_attrs = {'POSITION':writer.accessor(fitted,'VEC3'), 'NORMAL':writer.accessor(normal_fit,'VEC3'), + 'JOINTS_0':writer.accessor(output_joints,'VEC4',5123), + 'WEIGHTS_0':writer.accessor(output_weights,'VEC4')} + for name in ('TEXCOORD_0','TEXCOORD_1','COLOR_0'): + if name in attrs: + values = doc.accessor(attrs[name]) + require(len(values)==len(positions), 'Attribute length differs: '+name) + out_attrs[name] = writer.accessor(values,'VEC'+str(values.shape[1])) + indices = doc.accessor(p['indices']).ravel() if 'indices' in p else np.arange(len(positions)) + require(len(indices)%3==0 and indices.min()>=0 and indices.max()1e-6].ravel()) + if used and all(target_names[j] in BOARD for j in used): + out_attrs = {} + for name in ('POSITION','NORMAL','TEXCOORD_0','TEXCOORD_1','JOINTS_0','WEIGHTS_0'): + if name in attrs: + a = ref.doc['accessors'][attrs[name]] + values = ref.accessor(attrs[name]) + if name=='JOINTS_0': + values[ws==0] = 0 + out_attrs[name] = writer.accessor(values,a['type'],5123 if name=='JOINTS_0' else 5126) + primitives.append({'attributes':out_attrs,'indices':writer.accessor(ref.accessor(p['indices']).ravel(),'SCALAR',5125), + 'material':p['material']+offset}) + # A single shared rig/skin also works with the older binder which binds one skin. + nodes = [] + node_map = {} + for i,n in enumerate(ref.doc['nodes']): + if 'mesh' not in n: + node_map[i] = len(nodes) + nodes.append({'name':n.get('name',''),**clean_node_transform(n)}) + for i,oi in node_map.items(): + children = [node_map[c] for c in ref.doc['nodes'][i].get('children',[]) if c in node_map] + if children: + nodes[oi]['children'] = children + roots = [node_map[i] for i in node_map if i not in ref.parents] + mesh_node = len(nodes) + nodes.append({'name':'Imported_Skater','mesh':0,'skin':0}) + roots.append(mesh_node) + writer.doc.update(nodes=nodes,meshes=[{'name':'Converted_Character','primitives':primitives}], + skins=[{'joints':[node_map[j] for j in ref_skin['joints']], + 'inverseBindMatrices':writer.accessor(inverse.transpose(0,2,1).reshape(-1,16),'MAT4')}], + scenes=[{'nodes':roots}],scene=0) + report['board_included'] = include_board + report['warnings'].append('Fits stock proportions; fingers collapse to hands. Contact/deformation quality needs in-game review.') + if include_board: + report['warnings'].append('Stock board normal maps use engine-generated tangent space, as in the original asset.') + if doc.doc.get('animations'): + report['warnings'].append('Source animations omitted; the engine supplies stock skating poses.') + writer.save(output) + validate_output(output,reference) + return report + + +def validate_output(path, reference): + doc, ref = Document(path), Document(reference) + _, names, inverse = reference_rig(doc) + _, expected, ref_inverse = reference_rig(ref) + require(names==expected and np.allclose(inverse,ref_inverse,atol=1e-7), 'Output rig differs from stock') + require('animations' not in doc.doc, 'Unexpected imported animation') + for mesh in doc.doc['meshes']: + for p in mesh['primitives']: + positions = doc.accessor(p['attributes']['POSITION']) + joints, weights = source_skin(doc,p,doc.doc['skins'][0]) + require(len(joints)==len(positions) and joints.shape[1]==4,'Invalid output skin') + require(np.allclose(doc.accessor(p['attributes']['WEIGHTS_0']).sum(axis=1),1,atol=1e-5),'Weights not normalized') + indices = doc.accessor(p['indices']) + require(indices.max() 1e-12, + 'Non-finite or singular node transform') + return result + + +def clean_node_transform(node): + """Round tiny authored matrix shear to a valid glTF TRS representation.""" + matrix = transform(node) + u, _, vh = np.linalg.svd(matrix[:3,:3]) + rotation = u@vh + require(np.linalg.det(rotation)>0, 'Mirrored stock node unsupported') + scale = np.diag(rotation.T@matrix[:3,:3]) + require(np.allclose(rotation*scale,matrix[:3,:3],atol=2e-5), 'Stock node has significant shear') + # Stable matrix-to-quaternion, including 180-degree rotations. + trace = np.trace(rotation) + if trace > 0: + s = np.sqrt(trace+1)*2 + q = [(rotation[2,1]-rotation[1,2])/s,(rotation[0,2]-rotation[2,0])/s, + (rotation[1,0]-rotation[0,1])/s,s/4] + else: + i = int(np.argmax(np.diag(rotation)));j=(i+1)%3;k=(i+2)%3 + s = np.sqrt(1+rotation[i,i]-rotation[j,j]-rotation[k,k])*2 + q = [0.,0.,0.,0.] + q[i]=s/4;q[j]=(rotation[j,i]+rotation[i,j])/s;q[k]=(rotation[k,i]+rotation[i,k])/s + q[3]=(rotation[k,j]-rotation[j,k])/s + q = np.asarray(q)/np.linalg.norm(q) + return {'translation':matrix[:3,3].tolist(),'rotation':q.tolist(),'scale':scale.tolist()} + + +class Document: + def __init__(self, path): + self.path = Path(path) + require(self.path.stat().st_size <= 512 * 1024**2, 'GLB exceeds 512 MiB limit') + self.raw = self.path.read_bytes() + require(len(self.raw) >= 28, 'Truncated GLB') + magic, version, total = struct.unpack_from('= 0 and length >= 0 and start+length <= len(self.data), 'Buffer view out of bounds') + return self.data[start:start+length] + + def accessor(self, i): + a = self.doc['accessors'][i] + require('sparse' not in a, 'Sparse accessors need an explicit conversion first') + types = {5120:'i1', 5121:'u1', 5122:'= 0 and stride >= dtype.itemsize*width and + start+(a['count']-1)*stride+dtype.itemsize*width <= len(data), 'Accessor out of bounds') + values = np.ndarray((a['count'], width), dtype=dtype, buffer=data, + offset=start, strides=(stride, dtype.itemsize)).copy() + if a.get('normalized'): + require(dtype.kind in 'iu', 'Invalid normalized accessor') + values = np.maximum(values.astype(float)/np.iinfo(dtype).max, -1) + require(np.isfinite(values).all(), 'Non-finite accessor') + return values + + def image(self, i): + image = self.doc['images'][i] + if 'bufferView' in image: + return self.view(image['bufferView']), image['mimeType'] + uri = image.get('uri', '') + require(uri.startswith('data:') and ';base64,' in uri, 'Missing embedded texture; export FBX with media') + header, payload = uri.split(',', 1) + return base64.b64decode(payload, validate=True), header[5:].split(';')[0] + + +class Writer: + def __init__(self): + self.data = bytearray() + self.doc = {'asset': {'version':'2.0', 'generator':'Mixamo to Skate 1'}, + 'bufferViews':[], 'accessors':[], 'materials':[], 'textures':[], 'images':[]} + + def view(self, data): + self.data.extend(b'\0' * (-len(self.data) % 4)) + offset = len(self.data) + self.data.extend(data) + self.doc['bufferViews'].append({'buffer':0, 'byteOffset':offset, 'byteLength':len(data)}) + return len(self.doc['bufferViews'])-1 + + def accessor(self, values, kind, component=5126): + values = np.asarray(values, dtype={5126:'xmax or ymin>ymax:continue + denom=(b[1]-c[1])*(a[0]-c[0])+(c[0]-b[0])*(a[1]-c[1]) + if abs(denom)<1e-8:continue + yy,xx=np.mgrid[ymin:ymax+1,xmin:xmax+1];xx=xx+.5;yy=yy+.5 + w0=((b[1]-c[1])*(xx-c[0])+(c[0]-b[0])*(yy-c[1]))/denom + w1=((c[1]-a[1])*(xx-c[0])+(a[0]-c[0])*(yy-c[1]))/denom + w2=1-w0-w1 + z=w0*tri[0,2]+w1*tri[1,2]+w2*tri[2,2] + visible=(w0>=0)&(w1>=0)&(w2>=0)&(z>depth[ymin:ymax+1,xmin:xmax+1]) + if not visible.any():continue + pixels=np.broadcast_to(color,(*xx.shape,4)).copy() + if texture is not None and uv is not None: + tex=w0[...,None]*uv[face[0]]+w1[...,None]*uv[face[1]]+w2[...,None]*uv[face[2]] + tx=np.clip((tex[...,0]%1*texture.shape[1]).astype(int),0,texture.shape[1]-1) + ty=np.clip((tex[...,1]%1*texture.shape[0]).astype(int),0,texture.shape[0]-1) + pixels*=texture[ty,tx] + if material.get('alphaMode')=='MASK':visible&=pixels[...,3]>=material.get('alphaCutoff',.5) + normal=np.cross(world[face[1]]-world[face[0]],world[face[2]]-world[face[0]]) + normal/=max(np.linalg.norm(normal),1e-10) + shade=.45+.55*abs(np.dot(normal,light)) + rgb[ymin:ymax+1,xmin:xmax+1][visible]=(pixels[...,:3]*shade)[visible] + depth[ymin:ymax+1,xmin:xmax+1][visible]=z[visible] + Image.fromarray(np.uint8(np.clip(rgb,0,1)*255)).save(output) + + +def import_model(source,reference,library,tool,profile=None): + source,reference,library=Path(source),Path(reference),Path(library) + digest=hashlib.sha256((sha(source)+sha(reference)+json.dumps(profile,sort_keys=True)+'library-v1').encode()).hexdigest() + entries=library/'entries';entries.mkdir(parents=True,exist_ok=True) + target=entries/digest + if target.exists(): + require((target/'manifest.json').is_file() and (target/'preview.png').is_file(), 'Existing library entry is incomplete') + validate_output(target/'character.glb',reference) + return digest + with tempfile.TemporaryDirectory(prefix='.import-',dir=library) as temp: + staging=Path(temp)/'entry' + # Already converted stock-rig GLBs are accepted as well as Mixamo exports. + ready=False + if source.suffix.lower()=='.glb': + try:validate_output(source,reference);ready=True + except (ValueError,KeyError):pass + if ready: + staging.mkdir();shutil.copy2(source,staging/'character.glb');shutil.copy2(source,staging/'source.glb') + else: + convert_file(source,reference,staging,tool,profile,keep_source=True) + thumbnail(staging/'character.glb',staging/'preview.png') + metadata={'version':1,'id':digest,'name':source.stem[:64], + 'source_sha256':sha(source),'reference_sha256':sha(reference), + 'created_utc':datetime.now(timezone.utc).isoformat()} + (staging/'manifest.json').write_text(json.dumps(metadata,indent=2),encoding='utf-8') + staging.rename(target) + return digest + + +def run(args,app): + result={'status':'cancelled'} + try: + require(args.result is not None and args.reference is not None,'Import needs result and reference paths') + source=args.files[0] if args.files else None + if source is None: + import tkinter as tk + from tkinter import filedialog + root=tk.Tk();root.withdraw();root.attributes('-topmost',True) + try: + name=filedialog.askopenfilename(title='Import a Mixamo character', + filetypes=[('Character models','*.fbx *.glb')],parent=root) + source=Path(name) if name else None + finally:root.destroy() + if source: + profile_path=args.profile or app/'calibration.json' + profile=json.loads(profile_path.read_text(encoding='utf-8')) if profile_path.is_file() else None + if profile and profile['reference_sha256']!=sha(args.reference):profile=None + identity=import_model(source,args.reference,args.library_import, + args.fbx_tool or app/'tools/FBX2glTF.exe',profile) + result={'status':'ready','id':identity} + except Exception as error: + result={'status':'error','message':str(error)} + if args.result: + args.result.parent.mkdir(parents=True,exist_ok=True) + temporary=args.result.with_suffix('.tmp') + temporary.write_text(json.dumps(result),encoding='utf-8');os.replace(temporary,args.result) + return 1 if result['status']=='error' else 0 diff --git a/tools/mixamo_to_skate/main.py b/tools/mixamo_to_skate/main.py new file mode 100644 index 00000000..539f70a2 --- /dev/null +++ b/tools/mixamo_to_skate/main.py @@ -0,0 +1,104 @@ +"""Windows drag-and-drop / file-dialog entry point, also usable from the CLI.""" +import argparse +import json +import os +import sys +from pathlib import Path + +from converter import convert_file, create_profile, sha +from glb import ConversionError, require + + +def app_root(): + return Path(sys.executable).parent if getattr(sys,'frozen',False) else Path(__file__).resolve().parent + + +def find_reference(expected_hash=None): + base = Path(os.environ.get('LOCALAPPDATA',Path.home()/'AppData/Local'))/'Skate3RustEngine/installations' + candidates = sorted(base.glob('*/assets/private/skater.glb')) + if expected_hash: + candidates = [p for p in candidates if sha(p)==expected_hash] + if candidates: + return candidates[0] + if len(candidates)==1: + return candidates[0] + if candidates and len({sha(p) for p in candidates})==1: + return candidates[0] + return None + + +def main(argv=None): + parser = argparse.ArgumentParser(description='Convert a Mixamo humanoid to the installed Skate render rig.') + parser.add_argument('files',nargs='*',type=Path) + parser.add_argument('--reference',type=Path,help='Owned stock assets/private/skater.glb') + parser.add_argument('--profile',type=Path,help='Optional local paired-stock calibration JSON') + parser.add_argument('--output',type=Path,help='New output folder (single input only)') + parser.add_argument('--fbx-tool',type=Path) + parser.add_argument('--no-board',action='store_true',help='Output character only, for future modular import') + parser.add_argument('--calibrate',action='store_true',help='Create profile from a paired stock Mixamo GLB') + parser.add_argument('--noninteractive',action='store_true') + parser.add_argument('--library-import',type=Path,help='Import into the game character library') + parser.add_argument('--result',type=Path,help='Game import result file') + args = parser.parse_args(argv) + if args.library_import: + from library_import import run + return run(args,app_root()) + gui = not args.noninteractive and (not args.files or getattr(sys,'frozen',False)) + root = None + try: + if gui: + import tkinter as tk + from tkinter import filedialog, messagebox + root = tk.Tk() + root.withdraw() + if not args.files: + args.files = [Path(f) for f in filedialog.askopenfilenames(title='Choose Mixamo FBX characters', + filetypes=[('Mixamo characters','*.fbx *.glb')])] + if not args.files: + return 0 + profile_path = args.profile or app_root()/'calibration.json' + profile = json.loads(profile_path.read_text(encoding='utf-8')) if profile_path.is_file() else None + reference = args.reference or find_reference(profile.get('reference_sha256') if profile else None) + if reference is None and gui: + selected = filedialog.askopenfilename(title='Choose your installed stock skater.glb', + filetypes=[('Stock GLB','*.glb')]) + reference = Path(selected) if selected else None + require(reference is not None,'Stock reference not found. Use --reference path/to/skater.glb') + require(args.files,'No input selected') + require(not args.output or len(args.files)==1,'--output supports one input only') + if args.calibrate: + require(len(args.files)==1 and args.files[0].suffix.lower()=='.glb','Calibration requires one normalized Mixamo GLB') + require(not profile_path.exists(),'Calibration exists; choose another --profile path') + profile_path.write_text(json.dumps(create_profile(args.files[0],reference),indent=2),encoding='utf-8') + print('Calibration saved:',profile_path) + return 0 + tool = args.fbx_tool or app_root()/'tools/FBX2glTF.exe' + outputs = [] + for source in args.files: + destination = args.output + if destination is None: + destination = source.with_name(source.stem+'-skate') + counter = 2 + while destination.exists(): + destination = source.with_name(source.stem+f'-skate-{counter}') + counter += 1 + print('Converting:',source,flush=True) + result = convert_file(source,reference,destination,tool,profile,not args.no_board) + outputs.append(str(result)) + print('Converted and validated:',result,flush=True) + if gui: + messagebox.showinfo('Conversion complete','Saved:\n\n'+'\n'.join(outputs)+ + '\n\nThe game was not modified. These files still need visual/gameplay review.') + return 0 + except Exception as error: + print('Conversion failed:',str(error),file=sys.stderr) + if gui and root is not None: + messagebox.showerror('Conversion failed',str(error)+'\n\nExisting outputs and game files were not replaced.') + return 1 + finally: + if root is not None: + root.destroy() + + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/tools/mixamo_to_skate/requirements.txt b/tools/mixamo_to_skate/requirements.txt new file mode 100644 index 00000000..ac83e6f8 --- /dev/null +++ b/tools/mixamo_to_skate/requirements.txt @@ -0,0 +1,2 @@ +numpy>=1.26,<3 +Pillow>=10,<13 diff --git a/tools/mixamo_to_skate/test_converter.py b/tools/mixamo_to_skate/test_converter.py new file mode 100644 index 00000000..c33460ce --- /dev/null +++ b/tools/mixamo_to_skate/test_converter.py @@ -0,0 +1,167 @@ +"""Procedural fixtures only: no stock or other copyrighted meshes.""" +import json +import tempfile +import unittest +from pathlib import Path + +import numpy as np + +from converter import MAP, convert_file, convert_glb, create_profile, reference_rig +from glb import ConversionError, Document, Writer + + +def fixture(path, mixamo, scale=1, bad_weights=False, morph=False): + positions = {'hips':(0,1,0),'spine':(0,1.15,0),'spine1':(0,1.3,0), + 'spine2':(0,1.45,0),'neck':(0,1.6,0),'head':(0,1.8,0)} + for s,sign in [('left',1),('right',-1)]: + for part,p in {'shoulder':(.1,1.5,0),'arm':(.2,1.5,0),'forearm':(.45,1.5,0), + 'hand':(.7,1.5,0),'upleg':(.1,.95,0),'leg':(.1,.5,0), + 'foot':(.1,.08,0),'toebase':(.1,.03,.15)}.items(): + positions[s+part]=(p[0]*sign,p[1],p[2]) + keys = list(MAP) + w=Writer() + nodes=[] + inverses=[] + verts=[] + joint_weights=[] + joint_indices=[] + for i,k in enumerate(keys): + p=np.asarray(positions[k])*scale + nodes.append({'name':('mixamorig:'+k if mixamo else MAP[k]),'translation':p.tolist()}) + if i: + nodes[-1]['translation']=(p-np.asarray(positions['hips'])*scale).tolist() + m=np.eye(4);m[:3,3]=-p + inverses.append(m.T.ravel()) + for v in [(0,0,0),(.015,0,0),(0,.015,0)]: + verts.append(p+np.asarray(v)*scale) + joint_indices.append([i,0,0,0]) + joint_weights.append([1,0,0,0]) + nodes[0]['children']=list(range(1,len(nodes))) + if mixamo: + # Extra weighted finger is not present in the destination rig. + hand=keys.index('lefthand');finger=len(nodes) + nodes.append({'name':'mixamorig:LeftHandIndex1','translation':[.02*scale,0,0]}) + nodes[hand]['children']=[finger] + m=np.eye(4);m[:3,3]=-(np.asarray(positions['lefthand'])+np.array([.02,0,0]))*scale + inverses.append(m.T.ravel()) + for i in range(hand*3,hand*3+3): + joint_indices[i]=[finger,hand,0,0];joint_weights[i]=[.7,.3,0,0] + if bad_weights: + joint_indices[0][0]=999 + attrs={'POSITION':w.accessor(verts,'VEC3'), + 'NORMAL':w.accessor([[0,0,1]]*len(verts),'VEC3'), + 'JOINTS_0':w.accessor(joint_indices,'VEC4',5123), + 'WEIGHTS_0':w.accessor(joint_weights,'VEC4')} + primitive={'attributes':attrs,'indices':w.accessor(np.arange(len(verts)),'SCALAR',5125)} + if morph: + primitive['targets']=[{'POSITION':attrs['POSITION']}] + skin={'joints':list(range(len(nodes))),'inverseBindMatrices':w.accessor(inverses,'MAT4')} + nodes.append({'name':'body','mesh':0,'skin':0}) + w.doc.update(nodes=nodes,skins=[skin],meshes=[{'primitives':[primitive]}], + scenes=[{'nodes':[0,len(nodes)-1]}],scene=0) + w.save(path) + + +class ConversionTests(unittest.TestCase): + def setUp(self): + self.tmp=tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root=Path(self.tmp.name) + self.ref=self.root/'stock.glb';self.src=self.root/'mixamo.glb';self.out=self.root/'out.glb' + fixture(self.ref,False) + fixture(self.src,True,.01) + + def test_calibrated_roundtrip_scale_weights_and_runtime_pose(self): + profile=create_profile(self.src,self.ref) + report=convert_glb(self.src,self.ref,self.out,profile,False) + src,ref=Document(self.out),Document(self.ref) + p=src.doc['meshes'][0]['primitives'][0] + r=ref.doc['meshes'][0]['primitives'][0] + got=src.accessor(p['attributes']['POSITION']) + expected=ref.accessor(r['attributes']['POSITION']) + np.testing.assert_allclose(got,expected,atol=2e-6) + _,names,inverse=reference_rig(src) + hand=names.index('LEFTHAND') + js=src.accessor(p['attributes']['JOINTS_0']).astype(int) + ws=src.accessor(p['attributes']['WEIGHTS_0']) + np.testing.assert_allclose(ws.sum(1),1,atol=1e-6) + for i in range(hand*3,hand*3+3): + self.assertEqual(js[i,0],hand) + self.assertAlmostEqual(ws[i,0],1) + # Simulate a translated stock hand: its vertices move exactly 20 cm, + # while an unrelated head vertex stays put. No engine/game is run. + globals=np.linalg.inv(inverse) + globals[hand,0,3]+=.2 + skin_mats=globals@inverse + hv=np.column_stack([got,np.ones(len(got))]) + deformed=np.einsum('vi,vijk,vk->vj',ws,skin_mats[js],hv)[:,:3] + expected_pose=got.copy();expected_pose[hand*3:hand*3+3,0]+=.2 + np.testing.assert_allclose(deformed,expected_pose,atol=2e-6) + self.assertIn('mixamorig:LeftHandIndex1',report['collapsed_bones']) + + def test_generic_fit_and_no_imported_bones(self): + report=convert_glb(self.src,self.ref,self.out,None,False) + self.assertFalse(report['calibrated']) + names=[n.get('name','') for n in Document(self.out).doc['nodes']] + self.assertFalse(any('mixamorig' in n for n in names)) + got,ref=Document(self.out),Document(self.ref) + p=got.doc['meshes'][0]['primitives'][0]['attributes']['POSITION'] + r=ref.doc['meshes'][0]['primitives'][0]['attributes']['POSITION'] + np.testing.assert_allclose(got.accessor(p),ref.accessor(r),atol=2e-6) + + def test_rotated_translated_rescaled_scene_uses_same_calibration(self): + profile=create_profile(self.src,self.ref) + d=Document(self.src);w=Writer();w.doc=d.doc;w.data=bytearray(d.data) + root=len(w.doc['nodes']) + w.doc['nodes'].append({'name':'export_axes','children':[0,root-1], + 'rotation':[0,2**-.5,0,2**-.5], + 'scale':[3,3,3],'translation':[8,2,-3]}) + w.doc['scenes'][0]['nodes']=[root];w.save(self.src) + convert_glb(self.src,self.ref,self.out,profile,False) + got,ref=Document(self.out),Document(self.ref) + p=got.doc['meshes'][0]['primitives'][0]['attributes']['POSITION'] + r=ref.doc['meshes'][0]['primitives'][0]['attributes']['POSITION'] + np.testing.assert_allclose(got.accessor(p),ref.accessor(r),atol=2e-6) + + def test_bad_joint_fails_transaction_without_mutating_source(self): + fixture(self.src,True,.01,bad_weights=True) + before=self.src.read_bytes();ref_before=self.ref.read_bytes() + with self.assertRaisesRegex(ConversionError,'joint index'): + convert_file(self.src,self.ref,self.root/'result','unused') + self.assertFalse((self.root/'result').exists()) + self.assertEqual(self.src.read_bytes(),before) + self.assertEqual(self.ref.read_bytes(),ref_before) + self.assertFalse(list(self.root.glob('.mixamo-*'))) + + def test_existing_output_never_overwritten(self): + destination=self.root/'result';destination.mkdir() + (destination/'keep').write_text('keep') + with self.assertRaisesRegex(ConversionError,'already exists'): + convert_file(self.src,self.ref,destination,'unused') + self.assertEqual((destination/'keep').read_text(),'keep') + + def test_profile_identity_mismatch(self): + profile=create_profile(self.src,self.ref) + profile['reference_sha256']='wrong' + with self.assertRaisesRegex(ConversionError,'another stock reference'): + convert_glb(self.src,self.ref,self.out,profile,False) + + def test_morph_rejected_explicitly(self): + fixture(self.src,True,.01,morph=True) + with self.assertRaisesRegex(ConversionError,'Blendshapes'): + convert_glb(self.src,self.ref,self.out,None,False) + + def test_truncated_glb_rejected(self): + self.src.write_bytes(self.src.read_bytes()[:-20]) + with self.assertRaisesRegex(ConversionError,'header'): + Document(self.src) + + def test_missing_bone_rejected(self): + d=Document(self.src);w=Writer();w.doc=d.doc;w.data=bytearray(d.data) + w.doc['nodes'][1]['name']='wrong';w.save(self.src) + with self.assertRaisesRegex(ConversionError,'missing'): + convert_glb(self.src,self.ref,self.out,None,False) + + +if __name__=='__main__': + unittest.main() diff --git a/tools/mixamo_to_skate/test_library.py b/tools/mixamo_to_skate/test_library.py new file mode 100644 index 00000000..4e2593bd --- /dev/null +++ b/tools/mixamo_to_skate/test_library.py @@ -0,0 +1,31 @@ +import json +import tempfile +import unittest +from pathlib import Path +import numpy as np +from PIL import Image +from test_converter import fixture +from converter import create_profile +from library_import import import_model + +class LibraryTests(unittest.TestCase): + def test_publish_preview_deduplicate_and_failed_import(self): + with tempfile.TemporaryDirectory() as temp: + root=Path(temp); ref=root/'ref.glb'; src=root/'model.glb'; library=root/'library' + fixture(ref,False); fixture(src,True,.01) + profile=create_profile(src,ref) + identity=import_model(src,ref,library,None,profile) + entry=library/'entries'/identity + self.assertTrue((entry/'source.glb').is_file()) + self.assertEqual(json.loads((entry/'manifest.json').read_text())['name'],'model') + with Image.open(entry/'preview.png') as preview: + self.assertEqual(preview.size,(256,320)) + self.assertGreater(len(np.unique(np.asarray(preview).reshape(-1,3),axis=0)),1) + self.assertEqual(import_model(src,ref,library,None,profile),identity) + bad=root/'bad.glb';bad.write_bytes(b'broken') + with self.assertRaises(Exception):import_model(bad,ref,library,None,profile) + self.assertEqual(len(list((library/'entries').iterdir())),1) + converted=import_model(entry/'character.glb',ref,library,None,profile) + self.assertTrue((library/'entries'/converted/'preview.png').is_file()) + +if __name__=='__main__':unittest.main()