diff --git a/Cargo.lock b/Cargo.lock index 2db86ea..0fd1768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -261,6 +261,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -299,6 +321,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -328,6 +361,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -543,6 +587,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.3", +] + [[package]] name = "blocking" version = "1.6.2" @@ -1042,6 +1095,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.11.0", + "block2 0.6.2", + "libc", "objc2 0.6.3", ] @@ -1528,8 +1583,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -2074,7 +2131,10 @@ dependencies = [ "loroscope", "parallel_rdp", "pm64", + "project", "raw-window-handle", + "rfd", + "star_rod_interop", "subsecond", "tracing", "tracing-subscriber", @@ -2702,7 +2762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", @@ -2718,6 +2778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", + "block2 0.6.2", "objc2 0.6.3", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -2730,7 +2791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -2742,7 +2803,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2754,7 +2815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2789,7 +2850,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2801,7 +2862,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -2820,7 +2881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -2854,7 +2915,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -2867,7 +2928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2879,7 +2940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -2902,7 +2963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -2922,7 +2983,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -2934,7 +2995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -3126,8 +3187,6 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" name = "pm64" version = "0.1.0" dependencies = [ - "loro", - "loroscope", "parallel_rdp", "rsp", ] @@ -3159,6 +3218,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3255,6 +3320,14 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "project" +version = "0.1.0" +dependencies = [ + "loro", + "loroscope", +] + [[package]] name = "pxfm" version = "0.1.27" @@ -3449,6 +3522,39 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rsp" version = "0.1.0" @@ -3809,6 +3915,17 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "star_rod_interop" +version = "0.1.0" +dependencies = [ + "loro", + "loroscope", + "project", + "roxmltree", + "thiserror 2.0.18", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4195,8 +4312,15 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -5128,7 +5252,7 @@ dependencies = [ "android-activity", "atomic-waker", "bitflags 2.11.0", - "block2", + "block2 0.5.1", "bytemuck", "calloop 0.13.0", "cfg_aliases", @@ -5490,6 +5614,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow", "zvariant_derive", "zvariant_utils", diff --git a/Cargo.toml b/Cargo.toml index 4f43da2..89f953f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ subsecond = "0.7" dioxus-devtools = "0.7" ash = "0.38" raw-window-handle = "0.6" +roxmltree = "0.21" +rfd = "0.15" thiserror = "2" anyhow = "1" diff --git a/crates/kammy/Cargo.toml b/crates/kammy/Cargo.toml index 2374835..93c5f7c 100644 --- a/crates/kammy/Cargo.toml +++ b/crates/kammy/Cargo.toml @@ -10,6 +10,9 @@ edition = "2024" [dependencies] parallel_rdp = { path = "../parallel_rdp" } pm64 = { path = "../pm64" } +project = { path = "../project" } +star_rod_interop = { path = "../star_rod_interop" } +rfd = { workspace = true } loroscope = { path = "../loroscope" } loro = { workspace = true } diff --git a/crates/kammy/src/app.rs b/crates/kammy/src/app.rs index 2f97072..55d4971 100644 --- a/crates/kammy/src/app.rs +++ b/crates/kammy/src/app.rs @@ -7,16 +7,15 @@ use std::cell::Cell; use std::collections::HashMap; -use crate::Project; use crate::dock::{Dock, DockPosition}; use crate::editor::map::MapEditor; -use crate::editor::todo::TodoEditor; use crate::editor::{Editor, EditorId, Inspect, TileBehavior, UndoBehavior}; use crate::gpu::GpuState; use crate::tool::ToolContext; use crate::tool::assets::AssetsTool; use crate::tool::hierarchy::HierarchyTool; use crate::tool::inspector::InspectorTool; +use project::Project; /// Callback for initialising CRDT data when a new editor is added. type SetupCrdt = dyn Fn(EditorId, &Project); @@ -50,34 +49,88 @@ impl KammyApp { id } - /// Creates the app with a todo editor and tool docks. + /// Creates the app with a demo map editor and tool docks. pub fn new() -> Self { let project = Project::new(); - let todo_editor_id = EditorId(0); + let map_editor_id = EditorId(0); + let scene_key = "demo".to_owned(); let next_editor_id = 1u64; let mut tiles = egui_tiles::Tiles::default(); - let todo_tile = tiles.insert_pane({ - let editor: Box = Box::new(TodoEditor::new(todo_editor_id)); + let map_tile = tiles.insert_pane({ + let editor: Box = + Box::new(MapEditor::new(map_editor_id, scene_key.clone())); editor }); - let editor_tabs = tiles.insert_tab_tile(vec![todo_tile]); + let editor_tabs = tiles.insert_tab_tile(vec![map_tile]); let tree = egui_tiles::Tree::new("kammy_editors", editor_tabs, tiles); - // Create CRDT data for the initial editor - let key = todo_editor_id.to_string(); - let initial = project.tabs().get_or_create(&key); - let _ = initial.items(); + // Create demo scene with ground plane and cube + let scene = project.scenes().get_or_create(&scene_key); + let model_tree = scene.model_tree(); + + let (_ground_id, ground) = model_tree.create_root(); + ground.set_name("Ground"); + let green = (80, 140, 80); + add_triangle( + &ground, + (-300.0, 0.0, -300.0), + (-300.0, 0.0, 300.0), + (300.0, 0.0, 300.0), + green, + ); + add_triangle( + &ground, + (-300.0, 0.0, -300.0), + (300.0, 0.0, 300.0), + (300.0, 0.0, -300.0), + green, + ); + + let (_cube_id, cube) = model_tree.create_root(); + cube.set_name("Cube"); + let s = 75.0; + + // Front (z=+s): red + let c = (220, 60, 60); + add_triangle(&cube, (-s, -s, s), (s, -s, s), (s, s, s), c); + add_triangle(&cube, (-s, -s, s), (s, s, s), (-s, s, s), c); + + // Back (z=-s): green + let c = (60, 180, 60); + add_triangle(&cube, (s, -s, -s), (-s, -s, -s), (-s, s, -s), c); + add_triangle(&cube, (s, -s, -s), (-s, s, -s), (s, s, -s), c); + + // Top (y=+s): blue + let c = (60, 60, 220); + add_triangle(&cube, (-s, s, s), (s, s, s), (s, s, -s), c); + add_triangle(&cube, (-s, s, s), (s, s, -s), (-s, s, -s), c); + + // Bottom (y=-s): yellow + let c = (220, 220, 60); + add_triangle(&cube, (-s, -s, -s), (s, -s, -s), (s, -s, s), c); + add_triangle(&cube, (-s, -s, -s), (s, -s, s), (-s, -s, s), c); + + // Right (x=+s): cyan + let c = (60, 220, 220); + add_triangle(&cube, (s, -s, s), (s, -s, -s), (s, s, -s), c); + add_triangle(&cube, (s, -s, s), (s, s, -s), (s, s, s), c); + + // Left (x=-s): magenta + let c = (220, 60, 220); + add_triangle(&cube, (-s, -s, -s), (-s, -s, s), (-s, s, s), c); + add_triangle(&cube, (-s, -s, -s), (-s, s, s), (-s, s, -s), c); + project.doc().set_next_commit_origin("meta"); project.doc().commit(); - let origin = format!("e{}/", todo_editor_id.0); + let origin = format!("e{}/", map_editor_id.0); let mut undo_manager = loro::UndoManager::new(&project.doc()); undo_manager.add_exclude_origin_prefix("meta"); let mut undo_behaviors = HashMap::new(); undo_behaviors.insert( - todo_editor_id, + map_editor_id, UndoBehavior::Own { undo_manager, origin, @@ -108,8 +161,8 @@ impl KammyApp { project, tree, undo_behaviors, - active_document: Some(todo_tile), - active_editor_id: Some(todo_editor_id), + active_document: Some(map_tile), + active_editor_id: Some(map_editor_id), inspect: None, next_editor_id, left_dock, @@ -177,83 +230,54 @@ impl KammyApp { } } - fn add_todo_editor(&mut self) { + fn add_map_editor(&mut self, scene_key: String) { + let key = scene_key.clone(); self.add_editor( - |id| Box::new(TodoEditor::new(id)), - Some(&|id, project| { - let key = id.to_string(); - let data = project.tabs().get_or_create(&key); - let _ = data.items(); + |id| Box::new(MapEditor::new(id, scene_key)), + Some(&move |_id, project| { + let scene = project.scenes().get_or_create(&key); + let _ = scene.model_tree(); project.doc().set_next_commit_origin("meta"); project.doc().commit(); }), ); } - fn add_map_editor(&mut self) { - self.add_editor( - |id| Box::new(MapEditor::new(id)), - Some(&|_id, project| { - let tree = project.map_model(); - - // Ground plane: two green triangles at y=0 - let (_ground_id, ground) = tree.create_root(); - ground.set_name("Ground"); - let green = (80, 140, 80); - add_triangle( - &ground, - (-300.0, 0.0, -300.0), - (-300.0, 0.0, 300.0), - (300.0, 0.0, 300.0), - green, - ); - add_triangle( - &ground, - (-300.0, 0.0, -300.0), - (300.0, 0.0, 300.0), - (300.0, 0.0, -300.0), - green, - ); - - // Colored cube: 12 triangles (2 per face), half-size = 75 - let (_cube_id, cube) = tree.create_root(); - cube.set_name("Cube"); - let s = 75.0; - - // Front (z=+s): red - let c = (220, 60, 60); - add_triangle(&cube, (-s, -s, s), (s, -s, s), (s, s, s), c); - add_triangle(&cube, (-s, -s, s), (s, s, s), (-s, s, s), c); - - // Back (z=-s): green - let c = (60, 180, 60); - add_triangle(&cube, (s, -s, -s), (-s, -s, -s), (-s, s, -s), c); - add_triangle(&cube, (s, -s, -s), (-s, s, -s), (s, s, -s), c); - - // Top (y=+s): blue - let c = (60, 60, 220); - add_triangle(&cube, (-s, s, s), (s, s, s), (s, s, -s), c); - add_triangle(&cube, (-s, s, s), (s, s, -s), (-s, s, -s), c); - - // Bottom (y=-s): yellow - let c = (220, 220, 60); - add_triangle(&cube, (-s, -s, -s), (s, -s, -s), (s, -s, s), c); - add_triangle(&cube, (-s, -s, -s), (s, -s, s), (-s, -s, s), c); - - // Right (x=+s): cyan - let c = (60, 220, 220); - add_triangle(&cube, (s, -s, s), (s, -s, -s), (s, s, -s), c); - add_triangle(&cube, (s, -s, s), (s, s, -s), (s, s, s), c); - - // Left (x=-s): magenta - let c = (220, 60, 220); - add_triangle(&cube, (-s, -s, -s), (-s, -s, s), (-s, s, s), c); - add_triangle(&cube, (-s, -s, -s), (-s, s, s), (-s, s, -s), c); + fn import_map_from_file(&mut self) { + let Some(path) = rfd::FileDialog::new() + .add_filter("PM64 Map XML", &["xml"]) + .pick_file() + else { + return; + }; + + let xml = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + tracing::error!("failed to read {path:?}: {e:?}"); + return; + } + }; + + let scene_key = path.file_stem().map_or_else( + || "imported".to_owned(), + |s| s.to_string_lossy().into_owned(), + ); + let key = scene_key.clone(); + self.add_editor( + |id| Box::new(MapEditor::new(id, scene_key)), + Some(&move |_id, project| { + let scene = project.scenes().get_or_create(&key); + if let Err(e) = star_rod_interop::import_map_xml(&xml, &scene) { + tracing::error!("import error: {e:?}"); + } project.doc().set_next_commit_origin("meta"); project.doc().commit(); }), ); + + tracing::info!("imported map from {path:?}"); } /// Returns the [`EditorId`] for the active document, if any. @@ -340,11 +364,11 @@ impl KammyApp { ui.separator(); - if ui.button("+ Todo").clicked() { - self.add_todo_editor(); - } if ui.button("+ Map").clicked() { - self.add_map_editor(); + self.add_map_editor("untitled".to_owned()); + } + if ui.button("Import").clicked() { + self.import_map_from_file(); } }); }); @@ -353,15 +377,12 @@ impl KammyApp { fn status_bar_ui(&mut self, ctx: &egui::Context) { egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { ui.horizontal(|ui| { - // Left zone: left dock tool icons self.left_dock.status_bar_icons(ui); ui.separator(); - // Center zone: bottom dock tool icons self.bottom_dock.status_bar_icons(ui); - // Right zone: right dock tool icons + FPS (right-aligned) ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let fps = 1.0 / ctx.input(|i| i.stable_dt).max(f32::EPSILON); ui.weak(format!("{fps:.0} FPS")); @@ -448,7 +469,6 @@ impl KammyApp { let Some(old_active) = old_active else { continue; }; - // Only fix up if the previously active tab was removed if tabs.children.contains(old_active) { continue; } @@ -471,7 +491,7 @@ impl KammyApp { } /// Sets the position and color of a CRDT vertex accessor. -fn set_vertex(v: &pm64::model::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i64) { +fn set_vertex(v: &project::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i64) { v.set_x(x); v.set_y(y); v.set_z(z); @@ -484,7 +504,7 @@ fn set_vertex(v: &pm64::model::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b /// Adds a single colored triangle to a model node. fn add_triangle( - node: &pm64::model::ModelNode, + node: &project::ModelNode, p0: (f64, f64, f64), p1: (f64, f64, f64), p2: (f64, f64, f64), diff --git a/crates/kammy/src/editor.rs b/crates/kammy/src/editor.rs index 1a16593..17ef5f7 100644 --- a/crates/kammy/src/editor.rs +++ b/crates/kammy/src/editor.rs @@ -5,7 +5,6 @@ //! Editor trait, built-in editor implementations, and tile-tree dispatch. pub mod map; -pub mod todo; use std::cell::Cell; use std::collections::HashMap; @@ -13,8 +12,8 @@ use std::fmt; use tracing::debug; -use crate::Project; use crate::gpu::GpuState; +use project::Project; /// Trait for objects that provide property-editing UI in the Inspector panel. /// diff --git a/crates/kammy/src/editor/map.rs b/crates/kammy/src/editor/map.rs index 29e35c8..846a10e 100644 --- a/crates/kammy/src/editor/map.rs +++ b/crates/kammy/src/editor/map.rs @@ -11,10 +11,9 @@ pub mod camera; use pm64::gbi::{NodeData, TriangleData, VertexData}; -use pm64::model::ModelNode; +use project::{ModelNode, Project}; use super::{Editor, EditorContext, EditorId}; -use crate::Project; use crate::widget::n64_viewport::N64Viewport; const FB_WIDTH: u32 = 320; @@ -25,6 +24,8 @@ const FB_ORIGIN: u32 = 0x0000_0100; /// Editor that renders PM64 map geometry via the N64 RSP + RDP pipeline. pub struct MapEditor { id: EditorId, + /// Key into `project.scenes()` for the scene this editor displays. + scene_key: String, viewport: N64Viewport, camera: camera::OrbitCamera, } @@ -33,15 +34,17 @@ impl std::fmt::Debug for MapEditor { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("MapEditor") .field("id", &self.id) + .field("scene_key", &self.scene_key) .finish_non_exhaustive() } } impl MapEditor { - /// Creates a new map editor with the given stable ID. - pub fn new(id: EditorId) -> Self { + /// Creates a new map editor for the given scene key. + pub fn new(id: EditorId, scene_key: String) -> Self { Self { id, + scene_key, viewport: N64Viewport::new(4 * 1024 * 1024), camera: camera::OrbitCamera::default(), } @@ -54,11 +57,10 @@ impl Editor for MapEditor { } fn title(&self) -> String { - "Map".to_owned() + self.scene_key.clone() } fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) { - // Handle camera input first so this frame's drag is reflected immediately. let interact_rect = ui.available_rect_before_wrap(); let interact_response = ui.interact( interact_rect, @@ -67,7 +69,7 @@ impl Editor for MapEditor { ); self.camera.handle_input(&interact_response); - let nodes = extract_nodes(ctx.project); + let nodes = extract_nodes(ctx.project, &self.scene_key); #[expect( clippy::cast_possible_truncation, clippy::as_conversions, @@ -90,9 +92,12 @@ impl Editor for MapEditor { } } -/// Extracts all model nodes from the project's CRDT tree into plain render data. -fn extract_nodes(project: &Project) -> Vec { - let tree = project.map_model(); +/// Extracts all model nodes from the given scene's model tree into plain render data. +fn extract_nodes(project: &Project, scene_key: &str) -> Vec { + let Some(scene) = project.scenes().get(scene_key) else { + return Vec::new(); + }; + let tree = scene.model_tree(); let mut nodes = Vec::new(); for root_id in tree.roots() { collect_node(&tree, root_id, &mut nodes); @@ -130,7 +135,7 @@ fn collect_node(tree: &loroscope::Tree, id: loro::TreeID, out: &mut V clippy::as_conversions, reason = "vertex coords are small integers that fit in i16/u8" )] -fn extract_vertex(v: &pm64::model::Vertex) -> VertexData { +fn extract_vertex(v: &project::Vertex) -> VertexData { let c = v.color(); VertexData { x: v.x() as i16, diff --git a/crates/kammy/src/editor/todo.rs b/crates/kammy/src/editor/todo.rs deleted file mode 100644 index f7fd55f..0000000 --- a/crates/kammy/src/editor/todo.rs +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Alex Bates -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -//! Todo-list editor. - -use super::{Editor, EditorContext, EditorId}; - -/// A simple todo-list editor. -#[derive(Debug)] -pub struct TodoEditor { - id: EditorId, - new_todo_text: String, -} - -impl TodoEditor { - /// Creates a new empty todo editor with the given stable ID. - pub fn new(id: EditorId) -> Self { - Self { - id, - new_todo_text: String::new(), - } - } -} - -impl Editor for TodoEditor { - fn id(&self) -> EditorId { - self.id - } - - fn title(&self) -> String { - subsecond::call(|| "Todos".to_owned()) - } - - fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) { - let key = self.id.to_string(); - let tab_data = ctx.project.tabs().get_or_create(&key); - - ui.horizontal(|ui| { - let response = ui.text_edit_singleline(&mut self.new_todo_text); - let submitted = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if (ui.button("Add").clicked() || submitted) && !self.new_todo_text.is_empty() { - let item = tab_data.items().push_new(); - item.set_title(&self.new_todo_text); - self.new_todo_text.clear(); - response.request_focus(); - } - }); - - ui.separator(); - - let items = tab_data.items(); - let len = items.len(); - - if len == 0 { - ui.vertical_centered(|ui| { - ui.add_space(20.0); - ui.label("No todos yet!"); - }); - return; - } - - let done_count = (0..len) - .filter(|&i| items.get(i).is_some_and(|item| item.done())) - .count(); - ui.label(format!("{done_count}/{len} completed")); - ui.add_space(4.0); - - let mut to_delete = None; - - egui::ScrollArea::vertical().show(ui, |ui| { - for i in 0..len { - if let Some(item) = items.get(i) { - egui::Frame::NONE.inner_margin(4.0).show(ui, |ui| { - ui.horizontal(|ui| { - let mut done = item.done(); - if ui.checkbox(&mut done, "").changed() { - item.set_done(done); - } - - let title = item.title(); - if done { - ui.label( - egui::RichText::new(&title) - .strikethrough() - .color(ui.visuals().weak_text_color()), - ); - } else { - ui.label(&title); - } - - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui.button("\u{1f5d1}").clicked() { - to_delete = Some(i); - } - }, - ); - }); - }); - } - } - }); - - if let Some(i) = to_delete { - items.delete(i, 1); - } - } -} diff --git a/crates/kammy/src/main.rs b/crates/kammy/src/main.rs index cf9c0c8..3a5c06d 100644 --- a/crates/kammy/src/main.rs +++ b/crates/kammy/src/main.rs @@ -17,37 +17,12 @@ mod widget; use std::sync::Arc; use egui::ViewportId; -use loroscope::loroscope; -use pm64::model::ModelNode; use tracing_subscriber::EnvFilter; use winit::application::ApplicationHandler; use winit::event::WindowEvent; use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::window::{Window, WindowAttributes, WindowId}; -/// A single todo item with a title and completion status. -#[loroscope] -#[derive(Debug)] -pub struct TodoItem { - pub title: String, - pub done: bool, -} - -/// Data for a single editor tab (a list of todo items). -#[loroscope] -#[derive(Debug)] -pub struct TabData { - pub items: List, -} - -/// Root project data, holding all tabs keyed by tile ID. -#[loroscope] -#[derive(Debug)] -pub struct Project { - pub tabs: Map, - pub map_model: Tree, -} - /// Application wrapper that implements [`winit::application::ApplicationHandler`]. struct WinitApp { /// Set once the window is created in `resumed`. diff --git a/crates/kammy/src/tests/map.rs b/crates/kammy/src/tests/map.rs index f66e874..13c56ad 100644 --- a/crates/kammy/src/tests/map.rs +++ b/crates/kammy/src/tests/map.rs @@ -58,9 +58,10 @@ fn triangle_renders_to_expected_color() { const FB_HEIGHT: u32 = 240; const FB_ORIGIN: u32 = 0x0000_0100; - // Create a Project with a red triangle in the CRDT tree - let project = crate::Project::new(); - let tree = project.map_model(); + // Create a Project with a red triangle in a scene's model tree + let project = project::Project::new(); + let scene = project.scenes().get_or_create("test"); + let tree = scene.model_tree(); let (_node_id, node) = tree.create_root(); node.set_name("test"); @@ -141,8 +142,6 @@ fn triangle_renders_to_expected_color() { let (w, h) = (w as usize, h as usize); // Check the center pixel is red-ish (RGBA8). - // The VI filtering and N64 color format conversion mean exact values vary, - // but a red vertex-colored triangle should produce clearly red pixels. let idx = (h / 2 * w + w / 2) * 4; let (r, g, b) = (buffer[idx], buffer[idx + 1], buffer[idx + 2]); diff --git a/crates/kammy/src/tests/undo.rs b/crates/kammy/src/tests/undo.rs index 08e2e4b..f1828c7 100644 --- a/crates/kammy/src/tests/undo.rs +++ b/crates/kammy/src/tests/undo.rs @@ -6,40 +6,18 @@ use egui_kittest::kittest::Queryable; -/// Helper: type text into the todo input and click Add. -fn add_todo(harness: &mut egui_kittest::Harness<'_>, text: &str) { - harness - .get_by_role(egui::accesskit::Role::TextInput) - .click(); - harness.run(); - harness - .get_by_role(egui::accesskit::Role::TextInput) - .type_text(text); - harness.get_by_label("Add").click(); - harness.run(); -} - #[test] -fn single_tab_undo_redo() { +fn undo_redo_buttons_present() { let mut harness = super::make_harness(); + // Use step() instead of run() because the map editor continuously requests repaint + harness.step(); - add_todo(&mut harness, "buy milk"); - assert!( - harness.query_by_label("buy milk").is_some(), - "item should exist after adding" - ); - - harness.get_by_label("⟲ Undo").click(); - harness.run(); assert!( - harness.query_by_label("buy milk").is_none(), - "item should be gone after undo" + harness.query_by_label("⟲ Undo").is_some(), + "undo button should be present" ); - - harness.get_by_label("⟳ Redo").click(); - harness.run(); assert!( - harness.query_by_label("buy milk").is_some(), - "item should reappear after redo" + harness.query_by_label("⟳ Redo").is_some(), + "redo button should be present" ); } diff --git a/crates/pm64/Cargo.toml b/crates/pm64/Cargo.toml index 468d07d..72933d2 100644 --- a/crates/pm64/Cargo.toml +++ b/crates/pm64/Cargo.toml @@ -8,13 +8,8 @@ version = "0.1.0" edition = "2024" [dependencies] -loroscope = { path = "../loroscope" } parallel_rdp = { path = "../parallel_rdp" } rsp = { path = "../rsp" } -loro = { workspace = true } - -[dev-dependencies] -loro = { workspace = true } [lints] workspace = true diff --git a/crates/pm64/src/lib.rs b/crates/pm64/src/lib.rs index 5d00b89..9629cca 100644 --- a/crates/pm64/src/lib.rs +++ b/crates/pm64/src/lib.rs @@ -5,5 +5,4 @@ //! Paper Mario 64 map data structures and rendering pipeline. pub mod gbi; -pub mod model; pub mod render; diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml new file mode 100644 index 0000000..b86af3b --- /dev/null +++ b/crates/project/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 Alex Bates +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +[package] +name = "project" +version = "0.1.0" +edition = "2024" + +[dependencies] +loroscope = { path = "../loroscope" } +loro = { workspace = true } + +[dev-dependencies] +loro = { workspace = true } + +[lints] +workspace = true diff --git a/crates/pm64/src/model.rs b/crates/project/src/lib.rs similarity index 68% rename from crates/pm64/src/model.rs rename to crates/project/src/lib.rs index fdd5216..05ccd60 100644 --- a/crates/pm64/src/model.rs +++ b/crates/project/src/lib.rs @@ -2,11 +2,10 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -//! CRDT-backed model data for PM64 map geometry. +//! CRDT-backed document schema for kammy projects. //! -//! These structs are backed by Loro via the [`loroscope`] macro, so every -//! field change is a CRDT operation suitable for collaborative editing and -//! undo/redo. +//! All types use the [`loroscope`] macro so that every field change is a CRDT +//! operation suitable for collaborative editing and undo/redo. use loroscope::loroscope; @@ -49,6 +48,28 @@ pub struct ModelNode { pub triangles: List, } +/// A map or stage. +#[loroscope] +#[derive(Debug)] +pub struct Scene { + /// Map description (from XML `desc` attribute). + pub display_name: String, + /// Background identifier (from XML `background` attribute). + pub background: String, + /// Texture archive name (from XML `textures` attribute). + pub textures: String, + /// The 3D model hierarchy. + pub model_tree: Tree, +} + +/// Root project data. +#[loroscope] +#[derive(Debug)] +pub struct Project { + /// Map scenes keyed by map name (e.g. "kmr_20"). + pub scenes: Map, +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used, clippy::float_cmp)] @@ -59,13 +80,13 @@ mod tests { #[loroscope] struct TestRoot { - pub map_model: Tree, + pub model_tree: Tree, } #[test] fn create_model_node_with_triangle() { let root = TestRoot::new(); - let tree = root.map_model(); + let tree = root.model_tree(); let (node_id, node) = tree.create_root(); node.set_name("test_node"); @@ -88,7 +109,6 @@ mod tests { tri.v2().set_y(100.0); tri.v2().set_z(0.0); - // Read back let read_node = tree.get(node_id).unwrap(); assert_eq!(read_node.name(), "test_node"); assert_eq!(read_node.triangles().len(), 1); @@ -103,7 +123,7 @@ mod tests { #[test] fn multiple_triangles_in_node() { let root = TestRoot::new(); - let tree = root.map_model(); + let tree = root.model_tree(); let (_id, node) = tree.create_root(); node.set_name("multi"); @@ -124,7 +144,7 @@ mod tests { #[test] fn tree_hierarchy() { let root = TestRoot::new(); - let tree = root.map_model(); + let tree = root.model_tree(); let (parent_id, parent) = tree.create_root(); parent.set_name("parent"); @@ -136,4 +156,21 @@ mod tests { assert_eq!(tree.children(parent_id).unwrap()[0], child_id); assert_eq!(tree.get(child_id).unwrap().name(), "child"); } + + #[test] + fn scene_metadata() { + let project = Project::new(); + let scene = project.scenes().get_or_create("kmr_20"); + scene.set_display_name("Goomba Village"); + scene.set_background("bg_sky"); + scene.set_textures("kmr_tex"); + + assert_eq!(scene.display_name(), "Goomba Village"); + assert_eq!(scene.background(), "bg_sky"); + assert_eq!(scene.textures(), "kmr_tex"); + + let (_id, node) = scene.model_tree().create_root(); + node.set_name("root"); + assert_eq!(scene.model_tree().roots().len(), 1); + } } diff --git a/crates/star_rod_interop/Cargo.toml b/crates/star_rod_interop/Cargo.toml new file mode 100644 index 0000000..f708bb8 --- /dev/null +++ b/crates/star_rod_interop/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 Alex Bates +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +[package] +name = "star_rod_interop" +version = "0.1.0" +edition = "2024" + +[dependencies] +project = { path = "../project" } +loroscope = { path = "../loroscope" } +loro = { workspace = true } +roxmltree = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +loro = { workspace = true } + +[lints] +workspace = true diff --git a/crates/star_rod_interop/src/lib.rs b/crates/star_rod_interop/src/lib.rs new file mode 100644 index 0000000..47db2a6 --- /dev/null +++ b/crates/star_rod_interop/src/lib.rs @@ -0,0 +1,541 @@ +// SPDX-FileCopyrightText: 2026 Alex Bates +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Import Star Rod decomp XML files into the kammy CRDT project. +//! +//! Parses the `` and `` sections from a PM64 map geometry +//! XML, building the corresponding node hierarchy and triangle data in a +//! [`project::Scene`]. + +use std::collections::HashMap; + +use project::Scene; + +/// Errors that can occur during map XML import. +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + /// The XML could not be parsed. + #[error("XML parse error: {0}")] + XmlParse(#[from] roxmltree::Error), + + /// A required section is missing from the XML. + #[error("missing required element: {0}")] + MissingElement(&'static str), + + /// An attribute value could not be parsed as the expected type. + #[error("invalid attribute {attr} on <{element}>: {reason}")] + InvalidAttribute { + /// Element tag name. + element: String, + /// Attribute name. + attr: String, + /// Description of what went wrong. + reason: String, + }, + + /// A `` references a node ID not found in ``. + #[error("model references unknown node id {0:?}")] + UnknownNodeId(String), +} + +/// Parses a PM64 map XML and populates the given scene's model tree. +/// +/// Sets the scene's name, background, and textures fields from the `` +/// element attributes, then walks `` to build the node hierarchy +/// and `` to fill in triangle data. +/// +/// # Errors +/// +/// Returns [`ImportError`] if the XML is malformed, required sections are +/// missing, or coordinate/color values cannot be parsed. +pub fn import_map_xml(xml: &str, scene: &Scene) -> Result<(), ImportError> { + let doc = roxmltree::Document::parse(xml)?; + let map_el = doc.root_element(); + + // Read attributes + if let Some(desc) = map_el.attribute("desc") { + scene.set_display_name(desc); + } else if let Some(name) = map_el.attribute("name") { + scene.set_display_name(name); + } + if let Some(bg) = map_el.attribute("background") { + scene.set_background(bg); + } + if let Some(tex) = map_el.attribute("textures") { + scene.set_textures(tex); + } + + // Pass 1: Build node hierarchy from + let model_tree_el = map_el + .children() + .find(|n| n.has_tag_name("ModelTree")) + .ok_or(ImportError::MissingElement(""))?; + + // Map hex ID string → CRDT TreeID + let mut id_map: HashMap = HashMap::new(); + let crdt_tree = scene.model_tree(); + + for root_node in model_tree_el.children().filter(|n| n.has_tag_name("Node")) { + build_tree_recursive(&root_node, None, &crdt_tree, &mut id_map)?; + } + + // Pass 2: Walk , link each to its node, parse triangle batches + let models_el = map_el + .children() + .find(|n| n.has_tag_name("Models")) + .ok_or(ImportError::MissingElement(""))?; + + for model_el in models_el.children().filter(|n| n.has_tag_name("Model")) { + let Some(map_obj) = model_el.children().find(|n| n.has_tag_name("MapObject")) else { + continue; + }; + + let Some(hex_id) = map_obj.attribute("id") else { + continue; + }; + + let Some(&tree_id) = id_map.get(hex_id) else { + continue; + }; + + let Some(node) = crdt_tree.get(tree_id) else { + continue; + }; + + // Find → → * + let shape_mesh = model_el.children().find(|n| n.has_tag_name("ShapeMesh")); + let display_list = + shape_mesh.and_then(|sm| sm.children().find(|n| n.has_tag_name("DisplayList"))); + + if let Some(dl) = display_list { + for batch in dl.children().filter(|n| n.has_tag_name("TriangleBatch")) { + parse_triangle_batch(&batch, &node)?; + } + } + } + + Ok(()) +} + +/// Recursively builds CRDT tree nodes from `` elements. +fn build_tree_recursive( + xml_node: &roxmltree::Node<'_, '_>, + parent: Option, + crdt_tree: &loroscope::Tree, + id_map: &mut HashMap, +) -> Result<(), ImportError> { + let name = xml_node.attribute("name").unwrap_or(""); + let hex_id = xml_node.attribute("id").unwrap_or(""); + + let (tree_id, crdt_node) = if let Some(parent_id) = parent { + crdt_tree.create_child(parent_id) + } else { + crdt_tree.create_root() + }; + + crdt_node.set_name(name); + id_map.insert(hex_id.to_owned(), tree_id); + + for child in xml_node.children().filter(|n| n.has_tag_name("Node")) { + build_tree_recursive(&child, Some(tree_id), crdt_tree, id_map)?; + } + + Ok(()) +} + +/// Parses a `` element and adds its triangles to the node. +fn parse_triangle_batch( + batch: &roxmltree::Node<'_, '_>, + node: &project::ModelNode, +) -> Result<(), ImportError> { + let vertex_table = batch + .children() + .find(|n| n.has_tag_name("VertexTable")) + .ok_or(ImportError::MissingElement(""))?; + + let triangle_list = batch + .children() + .find(|n| n.has_tag_name("TriangleList")) + .ok_or(ImportError::MissingElement(""))?; + + // Parse vertices into a temporary buffer + let vertices: Vec = vertex_table + .children() + .filter(|n| n.has_tag_name("Vertex")) + .map(parse_vertex_element) + .collect::>()?; + + // Parse triangle indices and write to CRDT + for tri_el in triangle_list + .children() + .filter(|n| n.has_tag_name("Triangle")) + { + let ijk = tri_el + .attribute("ijk") + .ok_or(ImportError::InvalidAttribute { + element: "Triangle".to_owned(), + attr: "ijk".to_owned(), + reason: "missing attribute".to_owned(), + })?; + + let indices = parse_index_triple(ijk)?; + let [i, j, k] = indices; + + let get_vert = |idx: usize| -> Result<&ParsedVertex, ImportError> { + vertices.get(idx).ok_or(ImportError::InvalidAttribute { + element: "Triangle".to_owned(), + attr: "ijk".to_owned(), + reason: format!( + "index {idx} out of range (vertex count: {})", + vertices.len() + ), + }) + }; + + let v0 = get_vert(i)?; + let v1 = get_vert(j)?; + let v2 = get_vert(k)?; + + let tri = node.triangles().push_new(); + write_vertex(&tri.v0(), v0); + write_vertex(&tri.v1(), v1); + write_vertex(&tri.v2(), v2); + } + + Ok(()) +} + +/// Intermediate vertex data parsed from XML. +struct ParsedVertex { + x: f64, + y: f64, + z: f64, + r: i64, + g: i64, + b: i64, + a: i64, +} + +/// Parses a `` element. +fn parse_vertex_element(el: roxmltree::Node<'_, '_>) -> Result { + let xyz = el.attribute("xyz").ok_or(ImportError::InvalidAttribute { + element: "Vertex".to_owned(), + attr: "xyz".to_owned(), + reason: "missing attribute".to_owned(), + })?; + + let coords = parse_f64_csv(xyz, "Vertex", "xyz")?; + if coords.len() != 3 { + return Err(ImportError::InvalidAttribute { + element: "Vertex".to_owned(), + attr: "xyz".to_owned(), + reason: format!("expected 3 components, got {}", coords.len()), + }); + } + + let (r, g, b, a) = if let Some(rgba) = el.attribute("rgba") { + let c = parse_i64_csv(rgba, "Vertex", "rgba")?; + if c.len() != 4 { + return Err(ImportError::InvalidAttribute { + element: "Vertex".to_owned(), + attr: "rgba".to_owned(), + reason: format!("expected 4 components, got {}", c.len()), + }); + } + (c[0], c[1], c[2], c[3]) + } else { + (255, 255, 255, 255) + }; + + Ok(ParsedVertex { + x: coords[0], + y: coords[1], + z: coords[2], + r, + g, + b, + a, + }) +} + +/// Writes a parsed vertex to a CRDT vertex accessor. +fn write_vertex(crdt: &project::Vertex, v: &ParsedVertex) { + crdt.set_x(v.x); + crdt.set_y(v.y); + crdt.set_z(v.z); + let color = crdt.color(); + color.set_r(v.r); + color.set_g(v.g); + color.set_b(v.b); + color.set_a(v.a); +} + +/// Parses a "i,j,k" index triple string into three `usize` values. +fn parse_index_triple(s: &str) -> Result<[usize; 3], ImportError> { + let parts: Vec<&str> = s.split(',').collect(); + if parts.len() != 3 { + return Err(ImportError::InvalidAttribute { + element: "Triangle".to_owned(), + attr: "ijk".to_owned(), + reason: format!("expected 3 indices, got {}", parts.len()), + }); + } + + let parse = |part: &str| -> Result { + part.trim() + .parse() + .map_err(|e| ImportError::InvalidAttribute { + element: "Triangle".to_owned(), + attr: "ijk".to_owned(), + reason: format!("invalid index {part:?}: {e}"), + }) + }; + + Ok([parse(parts[0])?, parse(parts[1])?, parse(parts[2])?]) +} + +/// Parses a comma-separated string of floats. +fn parse_f64_csv(s: &str, element: &str, attr: &str) -> Result, ImportError> { + s.split(',') + .map(|part| { + part.trim() + .parse() + .map_err(|e| ImportError::InvalidAttribute { + element: element.to_owned(), + attr: attr.to_owned(), + reason: format!("invalid number {part:?}: {e}"), + }) + }) + .collect() +} + +/// Parses a comma-separated string of integers. +fn parse_i64_csv(s: &str, element: &str, attr: &str) -> Result, ImportError> { + s.split(',') + .map(|part| { + part.trim() + .parse() + .map_err(|e| ImportError::InvalidAttribute { + element: element.to_owned(), + attr: attr.to_owned(), + reason: format!("invalid integer {part:?}: {e}"), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + fn make_scene() -> Scene { + let project = project::Project::new(); + project.scenes().get_or_create("test") + } + + #[test] + fn minimal_single_node() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + assert_eq!(scene.display_name(), "Test"); + assert_eq!(scene.background(), "bg_test"); + assert_eq!(scene.textures(), "tex_test"); + assert_eq!(scene.model_tree().roots().len(), 1); + } + + #[test] + fn node_with_triangles() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + + let tree = scene.model_tree(); + let roots = tree.roots(); + assert_eq!(roots.len(), 1); + + let root_id = roots[0]; + let root = tree.get(root_id).unwrap(); + assert_eq!(root.name(), "Root"); + + let children = tree.children(root_id).unwrap(); + assert_eq!(children.len(), 1); + + let mesh_node = tree.get(children[0]).unwrap(); + assert_eq!(mesh_node.name(), "Mesh"); + assert_eq!(mesh_node.triangles().len(), 1); + + let tri = mesh_node.triangles().get(0).unwrap(); + assert_eq!(tri.v0().x(), 0.0); + assert_eq!(tri.v0().color().r(), 255); + assert_eq!(tri.v1().x(), 100.0); + assert_eq!(tri.v1().color().g(), 255); + assert_eq!(tri.v2().y(), 100.0); + assert_eq!(tri.v2().color().b(), 255); + } + + #[test] + fn multiple_batches() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let tree = scene.model_tree(); + let node = tree.get(tree.roots()[0]).unwrap(); + assert_eq!(node.triangles().len(), 2); + } + + #[test] + fn missing_rgba_defaults_to_white() { + let scene = make_scene(); + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let tri = scene + .model_tree() + .get(scene.model_tree().roots()[0]) + .unwrap() + .triangles() + .get(0) + .unwrap(); + assert_eq!(tri.v0().color().r(), 255); + assert_eq!(tri.v0().color().g(), 255); + assert_eq!(tri.v0().color().b(), 255); + assert_eq!(tri.v0().color().a(), 255); + } + + #[test] + fn malformed_xml_returns_error() { + let scene = make_scene(); + let result = import_map_xml(" + + +"#; + let result = import_map_xml(xml, &scene); + assert!(matches!(result, Err(ImportError::MissingElement(_)))); + } + + #[test] + fn name_attribute_fallback() { + let scene = make_scene(); + let xml = r#" + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + assert_eq!(scene.display_name(), "kmr_20"); + } +} diff --git a/crates/star_rod_interop/tests/import.rs b/crates/star_rod_interop/tests/import.rs new file mode 100644 index 0000000..bb267a3 --- /dev/null +++ b/crates/star_rod_interop/tests/import.rs @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2026 Alex Bates +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Integration tests for PM64 map XML import. + +#![allow(clippy::unwrap_used)] + +use project::Project; +use star_rod_interop::import_map_xml; + +fn make_scene(key: &str) -> (Project, project::Scene) { + let project = Project::new(); + let scene = project.scenes().get_or_create(key); + (project, scene) +} + +#[test] +fn import_realistic_map() { + let (_project, scene) = make_scene("realistic"); + + let xml = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + + // Verify metadata + assert_eq!(scene.display_name(), "Test Village"); + assert_eq!(scene.background(), "arn_bg"); + assert_eq!(scene.textures(), "test_tex"); + + // Verify tree hierarchy: Root -> [Ground, House, Tree -> [Trunk, Leaves]] + let tree = scene.model_tree(); + let roots = tree.roots(); + assert_eq!(roots.len(), 1, "should have exactly one root"); + + let root = tree.get(roots[0]).unwrap(); + assert_eq!(root.name(), "Root"); + + let children = tree.children(roots[0]).unwrap(); + assert_eq!(children.len(), 3, "Root should have 3 children"); + + // Check Ground node has 2 triangles + let ground = tree.get(children[0]).unwrap(); + assert_eq!(ground.name(), "Ground"); + assert_eq!(ground.triangles().len(), 2); + + // Spot-check a vertex + let tri0 = ground.triangles().get(0).unwrap(); + assert_eq!(tri0.v0().x(), -200.0); + assert_eq!(tri0.v0().color().r(), 80); + + // Check tree subtree: Tree -> [Trunk, Leaves] + let tree_node_id = children[2]; + let tree_node = tree.get(tree_node_id).unwrap(); + assert_eq!(tree_node.name(), "Tree"); + + let tree_children = tree.children(tree_node_id).unwrap(); + assert_eq!(tree_children.len(), 2); + + let trunk = tree.get(tree_children[0]).unwrap(); + assert_eq!(trunk.name(), "Trunk"); + assert_eq!(trunk.triangles().len(), 1); + + let leaves = tree.get(tree_children[1]).unwrap(); + assert_eq!(leaves.name(), "Leaves"); + assert_eq!(leaves.triangles().len(), 0); +} + +#[test] +fn models_without_shape_mesh_are_ok() { + let (_project, scene) = make_scene("no_mesh"); + + let xml = r#" + + + + + + + + + + + + + + +"#; + + import_map_xml(xml, &scene).unwrap(); + let tree = scene.model_tree(); + let children = tree.children(tree.roots()[0]).unwrap(); + let empty = tree.get(children[0]).unwrap(); + assert_eq!(empty.name(), "Empty"); + assert_eq!(empty.triangles().len(), 0); +}