Editor for papermario-dx mods
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add star rod map xml import

+1075 -301
+141 -16
Cargo.lock
··· 262 262 ] 263 263 264 264 [[package]] 265 + name = "ashpd" 266 + version = "0.11.1" 267 + source = "registry+https://github.com/rust-lang/crates.io-index" 268 + checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" 269 + dependencies = [ 270 + "async-fs", 271 + "async-net", 272 + "enumflags2", 273 + "futures-channel", 274 + "futures-util", 275 + "rand 0.9.2", 276 + "raw-window-handle", 277 + "serde", 278 + "serde_repr", 279 + "url", 280 + "wayland-backend", 281 + "wayland-client", 282 + "wayland-protocols", 283 + "zbus", 284 + ] 285 + 286 + [[package]] 265 287 name = "async-broadcast" 266 288 version = "0.7.2" 267 289 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 300 322 ] 301 323 302 324 [[package]] 325 + name = "async-fs" 326 + version = "2.2.0" 327 + source = "registry+https://github.com/rust-lang/crates.io-index" 328 + checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" 329 + dependencies = [ 330 + "async-lock", 331 + "blocking", 332 + "futures-lite", 333 + ] 334 + 335 + [[package]] 303 336 name = "async-io" 304 337 version = "2.6.0" 305 338 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 329 362 ] 330 363 331 364 [[package]] 365 + name = "async-net" 366 + version = "2.0.0" 367 + source = "registry+https://github.com/rust-lang/crates.io-index" 368 + checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" 369 + dependencies = [ 370 + "async-io", 371 + "blocking", 372 + "futures-lite", 373 + ] 374 + 375 + [[package]] 332 376 name = "async-process" 333 377 version = "2.5.0" 334 378 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 544 588 ] 545 589 546 590 [[package]] 591 + name = "block2" 592 + version = "0.6.2" 593 + source = "registry+https://github.com/rust-lang/crates.io-index" 594 + checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" 595 + dependencies = [ 596 + "objc2 0.6.3", 597 + ] 598 + 599 + [[package]] 547 600 name = "blocking" 548 601 version = "1.6.2" 549 602 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1042 1095 checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" 1043 1096 dependencies = [ 1044 1097 "bitflags 2.11.0", 1098 + "block2 0.6.2", 1099 + "libc", 1045 1100 "objc2 0.6.3", 1046 1101 ] 1047 1102 ··· 1528 1583 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 1529 1584 dependencies = [ 1530 1585 "futures-core", 1586 + "futures-io", 1531 1587 "futures-macro", 1532 1588 "futures-task", 1589 + "memchr", 1533 1590 "pin-project-lite", 1534 1591 "slab", 1535 1592 ] ··· 2074 2131 "loroscope", 2075 2132 "parallel_rdp", 2076 2133 "pm64", 2134 + "project", 2077 2135 "raw-window-handle", 2136 + "rfd", 2137 + "star_rod_interop", 2078 2138 "subsecond", 2079 2139 "tracing", 2080 2140 "tracing-subscriber", ··· 2702 2762 checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" 2703 2763 dependencies = [ 2704 2764 "bitflags 2.11.0", 2705 - "block2", 2765 + "block2 0.5.1", 2706 2766 "libc", 2707 2767 "objc2 0.5.2", 2708 2768 "objc2-core-data", ··· 2718 2778 checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" 2719 2779 dependencies = [ 2720 2780 "bitflags 2.11.0", 2781 + "block2 0.6.2", 2721 2782 "objc2 0.6.3", 2722 2783 "objc2-core-graphics", 2723 2784 "objc2-foundation 0.3.2", ··· 2730 2791 checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" 2731 2792 dependencies = [ 2732 2793 "bitflags 2.11.0", 2733 - "block2", 2794 + "block2 0.5.1", 2734 2795 "objc2 0.5.2", 2735 2796 "objc2-core-location", 2736 2797 "objc2-foundation 0.2.2", ··· 2742 2803 source = "registry+https://github.com/rust-lang/crates.io-index" 2743 2804 checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" 2744 2805 dependencies = [ 2745 - "block2", 2806 + "block2 0.5.1", 2746 2807 "objc2 0.5.2", 2747 2808 "objc2-foundation 0.2.2", 2748 2809 ] ··· 2754 2815 checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" 2755 2816 dependencies = [ 2756 2817 "bitflags 2.11.0", 2757 - "block2", 2818 + "block2 0.5.1", 2758 2819 "objc2 0.5.2", 2759 2820 "objc2-foundation 0.2.2", 2760 2821 ] ··· 2789 2850 source = "registry+https://github.com/rust-lang/crates.io-index" 2790 2851 checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" 2791 2852 dependencies = [ 2792 - "block2", 2853 + "block2 0.5.1", 2793 2854 "objc2 0.5.2", 2794 2855 "objc2-foundation 0.2.2", 2795 2856 "objc2-metal", ··· 2801 2862 source = "registry+https://github.com/rust-lang/crates.io-index" 2802 2863 checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" 2803 2864 dependencies = [ 2804 - "block2", 2865 + "block2 0.5.1", 2805 2866 "objc2 0.5.2", 2806 2867 "objc2-contacts", 2807 2868 "objc2-foundation 0.2.2", ··· 2820 2881 checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" 2821 2882 dependencies = [ 2822 2883 "bitflags 2.11.0", 2823 - "block2", 2884 + "block2 0.5.1", 2824 2885 "dispatch", 2825 2886 "libc", 2826 2887 "objc2 0.5.2", ··· 2854 2915 source = "registry+https://github.com/rust-lang/crates.io-index" 2855 2916 checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" 2856 2917 dependencies = [ 2857 - "block2", 2918 + "block2 0.5.1", 2858 2919 "objc2 0.5.2", 2859 2920 "objc2-app-kit 0.2.2", 2860 2921 "objc2-foundation 0.2.2", ··· 2867 2928 checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" 2868 2929 dependencies = [ 2869 2930 "bitflags 2.11.0", 2870 - "block2", 2931 + "block2 0.5.1", 2871 2932 "objc2 0.5.2", 2872 2933 "objc2-foundation 0.2.2", 2873 2934 ] ··· 2879 2940 checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" 2880 2941 dependencies = [ 2881 2942 "bitflags 2.11.0", 2882 - "block2", 2943 + "block2 0.5.1", 2883 2944 "objc2 0.5.2", 2884 2945 "objc2-foundation 0.2.2", 2885 2946 "objc2-metal", ··· 2902 2963 checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" 2903 2964 dependencies = [ 2904 2965 "bitflags 2.11.0", 2905 - "block2", 2966 + "block2 0.5.1", 2906 2967 "objc2 0.5.2", 2907 2968 "objc2-cloud-kit", 2908 2969 "objc2-core-data", ··· 2922 2983 source = "registry+https://github.com/rust-lang/crates.io-index" 2923 2984 checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" 2924 2985 dependencies = [ 2925 - "block2", 2986 + "block2 0.5.1", 2926 2987 "objc2 0.5.2", 2927 2988 "objc2-foundation 0.2.2", 2928 2989 ] ··· 2934 2995 checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" 2935 2996 dependencies = [ 2936 2997 "bitflags 2.11.0", 2937 - "block2", 2998 + "block2 0.5.1", 2938 2999 "objc2 0.5.2", 2939 3000 "objc2-core-location", 2940 3001 "objc2-foundation 0.2.2", ··· 3126 3187 name = "pm64" 3127 3188 version = "0.1.0" 3128 3189 dependencies = [ 3129 - "loro", 3130 - "loroscope", 3131 3190 "parallel_rdp", 3132 3191 "rsp", 3133 3192 ] ··· 3160 3219 ] 3161 3220 3162 3221 [[package]] 3222 + name = "pollster" 3223 + version = "0.4.0" 3224 + source = "registry+https://github.com/rust-lang/crates.io-index" 3225 + checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" 3226 + 3227 + [[package]] 3163 3228 name = "portable-atomic" 3164 3229 version = "1.13.1" 3165 3230 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3256 3321 checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" 3257 3322 3258 3323 [[package]] 3324 + name = "project" 3325 + version = "0.1.0" 3326 + dependencies = [ 3327 + "loro", 3328 + "loroscope", 3329 + ] 3330 + 3331 + [[package]] 3259 3332 name = "pxfm" 3260 3333 version = "0.1.27" 3261 3334 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3450 3523 checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" 3451 3524 3452 3525 [[package]] 3526 + name = "rfd" 3527 + version = "0.15.4" 3528 + source = "registry+https://github.com/rust-lang/crates.io-index" 3529 + checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" 3530 + dependencies = [ 3531 + "ashpd", 3532 + "block2 0.6.2", 3533 + "dispatch2", 3534 + "js-sys", 3535 + "log", 3536 + "objc2 0.6.3", 3537 + "objc2-app-kit 0.3.2", 3538 + "objc2-core-foundation", 3539 + "objc2-foundation 0.3.2", 3540 + "pollster", 3541 + "raw-window-handle", 3542 + "urlencoding", 3543 + "wasm-bindgen", 3544 + "wasm-bindgen-futures", 3545 + "web-sys", 3546 + "windows-sys 0.59.0", 3547 + ] 3548 + 3549 + [[package]] 3550 + name = "roxmltree" 3551 + version = "0.21.1" 3552 + source = "registry+https://github.com/rust-lang/crates.io-index" 3553 + checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" 3554 + dependencies = [ 3555 + "memchr", 3556 + ] 3557 + 3558 + [[package]] 3453 3559 name = "rsp" 3454 3560 version = "0.1.0" 3455 3561 ··· 3810 3916 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 3811 3917 3812 3918 [[package]] 3919 + name = "star_rod_interop" 3920 + version = "0.1.0" 3921 + dependencies = [ 3922 + "loro", 3923 + "loroscope", 3924 + "project", 3925 + "roxmltree", 3926 + "thiserror 2.0.18", 3927 + ] 3928 + 3929 + [[package]] 3813 3930 name = "static_assertions" 3814 3931 version = "1.1.0" 3815 3932 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4195 4312 "idna", 4196 4313 "percent-encoding", 4197 4314 "serde", 4315 + "serde_derive", 4198 4316 ] 4317 + 4318 + [[package]] 4319 + name = "urlencoding" 4320 + version = "2.1.3" 4321 + source = "registry+https://github.com/rust-lang/crates.io-index" 4322 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 4199 4323 4200 4324 [[package]] 4201 4325 name = "utf-8" ··· 5128 5252 "android-activity", 5129 5253 "atomic-waker", 5130 5254 "bitflags 2.11.0", 5131 - "block2", 5255 + "block2 0.5.1", 5132 5256 "bytemuck", 5133 5257 "calloop 0.13.0", 5134 5258 "cfg_aliases", ··· 5490 5614 "endi", 5491 5615 "enumflags2", 5492 5616 "serde", 5617 + "url", 5493 5618 "winnow", 5494 5619 "zvariant_derive", 5495 5620 "zvariant_utils",
+2
Cargo.toml
··· 22 22 dioxus-devtools = "0.7" 23 23 ash = "0.38" 24 24 raw-window-handle = "0.6" 25 + roxmltree = "0.21" 26 + rfd = "0.15" 25 27 thiserror = "2" 26 28 anyhow = "1" 27 29
+3
crates/kammy/Cargo.toml
··· 10 10 [dependencies] 11 11 parallel_rdp = { path = "../parallel_rdp" } 12 12 pm64 = { path = "../pm64" } 13 + project = { path = "../project" } 14 + star_rod_interop = { path = "../star_rod_interop" } 15 + rfd = { workspace = true } 13 16 14 17 loroscope = { path = "../loroscope" } 15 18 loro = { workspace = true }
+108 -88
crates/kammy/src/app.rs
··· 7 7 use std::cell::Cell; 8 8 use std::collections::HashMap; 9 9 10 - use crate::Project; 11 10 use crate::dock::{Dock, DockPosition}; 12 11 use crate::editor::map::MapEditor; 13 - use crate::editor::todo::TodoEditor; 14 12 use crate::editor::{Editor, EditorId, Inspect, TileBehavior, UndoBehavior}; 15 13 use crate::gpu::GpuState; 16 14 use crate::tool::ToolContext; 17 15 use crate::tool::assets::AssetsTool; 18 16 use crate::tool::hierarchy::HierarchyTool; 19 17 use crate::tool::inspector::InspectorTool; 18 + use project::Project; 20 19 21 20 /// Callback for initialising CRDT data when a new editor is added. 22 21 type SetupCrdt = dyn Fn(EditorId, &Project); ··· 50 49 id 51 50 } 52 51 53 - /// Creates the app with a todo editor and tool docks. 52 + /// Creates the app with a demo map editor and tool docks. 54 53 pub fn new() -> Self { 55 54 let project = Project::new(); 56 - let todo_editor_id = EditorId(0); 55 + let map_editor_id = EditorId(0); 56 + let scene_key = "demo".to_owned(); 57 57 let next_editor_id = 1u64; 58 58 59 59 let mut tiles = egui_tiles::Tiles::default(); 60 - let todo_tile = tiles.insert_pane({ 61 - let editor: Box<dyn Editor> = Box::new(TodoEditor::new(todo_editor_id)); 60 + let map_tile = tiles.insert_pane({ 61 + let editor: Box<dyn Editor> = 62 + Box::new(MapEditor::new(map_editor_id, scene_key.clone())); 62 63 editor 63 64 }); 64 - let editor_tabs = tiles.insert_tab_tile(vec![todo_tile]); 65 + let editor_tabs = tiles.insert_tab_tile(vec![map_tile]); 65 66 let tree = egui_tiles::Tree::new("kammy_editors", editor_tabs, tiles); 66 67 67 - // Create CRDT data for the initial editor 68 - let key = todo_editor_id.to_string(); 69 - let initial = project.tabs().get_or_create(&key); 70 - let _ = initial.items(); 68 + // Create demo scene with ground plane and cube 69 + let scene = project.scenes().get_or_create(&scene_key); 70 + let model_tree = scene.model_tree(); 71 + 72 + let (_ground_id, ground) = model_tree.create_root(); 73 + ground.set_name("Ground"); 74 + let green = (80, 140, 80); 75 + add_triangle( 76 + &ground, 77 + (-300.0, 0.0, -300.0), 78 + (-300.0, 0.0, 300.0), 79 + (300.0, 0.0, 300.0), 80 + green, 81 + ); 82 + add_triangle( 83 + &ground, 84 + (-300.0, 0.0, -300.0), 85 + (300.0, 0.0, 300.0), 86 + (300.0, 0.0, -300.0), 87 + green, 88 + ); 89 + 90 + let (_cube_id, cube) = model_tree.create_root(); 91 + cube.set_name("Cube"); 92 + let s = 75.0; 93 + 94 + // Front (z=+s): red 95 + let c = (220, 60, 60); 96 + add_triangle(&cube, (-s, -s, s), (s, -s, s), (s, s, s), c); 97 + add_triangle(&cube, (-s, -s, s), (s, s, s), (-s, s, s), c); 98 + 99 + // Back (z=-s): green 100 + let c = (60, 180, 60); 101 + add_triangle(&cube, (s, -s, -s), (-s, -s, -s), (-s, s, -s), c); 102 + add_triangle(&cube, (s, -s, -s), (-s, s, -s), (s, s, -s), c); 103 + 104 + // Top (y=+s): blue 105 + let c = (60, 60, 220); 106 + add_triangle(&cube, (-s, s, s), (s, s, s), (s, s, -s), c); 107 + add_triangle(&cube, (-s, s, s), (s, s, -s), (-s, s, -s), c); 108 + 109 + // Bottom (y=-s): yellow 110 + let c = (220, 220, 60); 111 + add_triangle(&cube, (-s, -s, -s), (s, -s, -s), (s, -s, s), c); 112 + add_triangle(&cube, (-s, -s, -s), (s, -s, s), (-s, -s, s), c); 113 + 114 + // Right (x=+s): cyan 115 + let c = (60, 220, 220); 116 + add_triangle(&cube, (s, -s, s), (s, -s, -s), (s, s, -s), c); 117 + add_triangle(&cube, (s, -s, s), (s, s, -s), (s, s, s), c); 118 + 119 + // Left (x=-s): magenta 120 + let c = (220, 60, 220); 121 + add_triangle(&cube, (-s, -s, -s), (-s, -s, s), (-s, s, s), c); 122 + add_triangle(&cube, (-s, -s, -s), (-s, s, s), (-s, s, -s), c); 123 + 71 124 project.doc().set_next_commit_origin("meta"); 72 125 project.doc().commit(); 73 126 74 - let origin = format!("e{}/", todo_editor_id.0); 127 + let origin = format!("e{}/", map_editor_id.0); 75 128 let mut undo_manager = loro::UndoManager::new(&project.doc()); 76 129 undo_manager.add_exclude_origin_prefix("meta"); 77 130 78 131 let mut undo_behaviors = HashMap::new(); 79 132 undo_behaviors.insert( 80 - todo_editor_id, 133 + map_editor_id, 81 134 UndoBehavior::Own { 82 135 undo_manager, 83 136 origin, ··· 108 161 project, 109 162 tree, 110 163 undo_behaviors, 111 - active_document: Some(todo_tile), 112 - active_editor_id: Some(todo_editor_id), 164 + active_document: Some(map_tile), 165 + active_editor_id: Some(map_editor_id), 113 166 inspect: None, 114 167 next_editor_id, 115 168 left_dock, ··· 177 230 } 178 231 } 179 232 180 - fn add_todo_editor(&mut self) { 233 + fn add_map_editor(&mut self, scene_key: String) { 234 + let key = scene_key.clone(); 181 235 self.add_editor( 182 - |id| Box::new(TodoEditor::new(id)), 183 - Some(&|id, project| { 184 - let key = id.to_string(); 185 - let data = project.tabs().get_or_create(&key); 186 - let _ = data.items(); 236 + |id| Box::new(MapEditor::new(id, scene_key)), 237 + Some(&move |_id, project| { 238 + let scene = project.scenes().get_or_create(&key); 239 + let _ = scene.model_tree(); 187 240 project.doc().set_next_commit_origin("meta"); 188 241 project.doc().commit(); 189 242 }), 190 243 ); 191 244 } 192 245 193 - fn add_map_editor(&mut self) { 194 - self.add_editor( 195 - |id| Box::new(MapEditor::new(id)), 196 - Some(&|_id, project| { 197 - let tree = project.map_model(); 198 - 199 - // Ground plane: two green triangles at y=0 200 - let (_ground_id, ground) = tree.create_root(); 201 - ground.set_name("Ground"); 202 - let green = (80, 140, 80); 203 - add_triangle( 204 - &ground, 205 - (-300.0, 0.0, -300.0), 206 - (-300.0, 0.0, 300.0), 207 - (300.0, 0.0, 300.0), 208 - green, 209 - ); 210 - add_triangle( 211 - &ground, 212 - (-300.0, 0.0, -300.0), 213 - (300.0, 0.0, 300.0), 214 - (300.0, 0.0, -300.0), 215 - green, 216 - ); 217 - 218 - // Colored cube: 12 triangles (2 per face), half-size = 75 219 - let (_cube_id, cube) = tree.create_root(); 220 - cube.set_name("Cube"); 221 - let s = 75.0; 222 - 223 - // Front (z=+s): red 224 - let c = (220, 60, 60); 225 - add_triangle(&cube, (-s, -s, s), (s, -s, s), (s, s, s), c); 226 - add_triangle(&cube, (-s, -s, s), (s, s, s), (-s, s, s), c); 227 - 228 - // Back (z=-s): green 229 - let c = (60, 180, 60); 230 - add_triangle(&cube, (s, -s, -s), (-s, -s, -s), (-s, s, -s), c); 231 - add_triangle(&cube, (s, -s, -s), (-s, s, -s), (s, s, -s), c); 232 - 233 - // Top (y=+s): blue 234 - let c = (60, 60, 220); 235 - add_triangle(&cube, (-s, s, s), (s, s, s), (s, s, -s), c); 236 - add_triangle(&cube, (-s, s, s), (s, s, -s), (-s, s, -s), c); 237 - 238 - // Bottom (y=-s): yellow 239 - let c = (220, 220, 60); 240 - add_triangle(&cube, (-s, -s, -s), (s, -s, -s), (s, -s, s), c); 241 - add_triangle(&cube, (-s, -s, -s), (s, -s, s), (-s, -s, s), c); 246 + fn import_map_from_file(&mut self) { 247 + let Some(path) = rfd::FileDialog::new() 248 + .add_filter("PM64 Map XML", &["xml"]) 249 + .pick_file() 250 + else { 251 + return; 252 + }; 242 253 243 - // Right (x=+s): cyan 244 - let c = (60, 220, 220); 245 - add_triangle(&cube, (s, -s, s), (s, -s, -s), (s, s, -s), c); 246 - add_triangle(&cube, (s, -s, s), (s, s, -s), (s, s, s), c); 254 + let xml = match std::fs::read_to_string(&path) { 255 + Ok(s) => s, 256 + Err(e) => { 257 + tracing::error!("failed to read {path:?}: {e:?}"); 258 + return; 259 + } 260 + }; 247 261 248 - // Left (x=-s): magenta 249 - let c = (220, 60, 220); 250 - add_triangle(&cube, (-s, -s, -s), (-s, -s, s), (-s, s, s), c); 251 - add_triangle(&cube, (-s, -s, -s), (-s, s, s), (-s, s, -s), c); 262 + let scene_key = path.file_stem().map_or_else( 263 + || "imported".to_owned(), 264 + |s| s.to_string_lossy().into_owned(), 265 + ); 252 266 267 + let key = scene_key.clone(); 268 + self.add_editor( 269 + |id| Box::new(MapEditor::new(id, scene_key)), 270 + Some(&move |_id, project| { 271 + let scene = project.scenes().get_or_create(&key); 272 + if let Err(e) = star_rod_interop::import_map_xml(&xml, &scene) { 273 + tracing::error!("import error: {e:?}"); 274 + } 253 275 project.doc().set_next_commit_origin("meta"); 254 276 project.doc().commit(); 255 277 }), 256 278 ); 279 + 280 + tracing::info!("imported map from {path:?}"); 257 281 } 258 282 259 283 /// Returns the [`EditorId`] for the active document, if any. ··· 340 364 341 365 ui.separator(); 342 366 343 - if ui.button("+ Todo").clicked() { 344 - self.add_todo_editor(); 345 - } 346 367 if ui.button("+ Map").clicked() { 347 - self.add_map_editor(); 368 + self.add_map_editor("untitled".to_owned()); 369 + } 370 + if ui.button("Import").clicked() { 371 + self.import_map_from_file(); 348 372 } 349 373 }); 350 374 }); ··· 353 377 fn status_bar_ui(&mut self, ctx: &egui::Context) { 354 378 egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { 355 379 ui.horizontal(|ui| { 356 - // Left zone: left dock tool icons 357 380 self.left_dock.status_bar_icons(ui); 358 381 359 382 ui.separator(); 360 383 361 - // Center zone: bottom dock tool icons 362 384 self.bottom_dock.status_bar_icons(ui); 363 385 364 - // Right zone: right dock tool icons + FPS (right-aligned) 365 386 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 366 387 let fps = 1.0 / ctx.input(|i| i.stable_dt).max(f32::EPSILON); 367 388 ui.weak(format!("{fps:.0} FPS")); ··· 448 469 let Some(old_active) = old_active else { 449 470 continue; 450 471 }; 451 - // Only fix up if the previously active tab was removed 452 472 if tabs.children.contains(old_active) { 453 473 continue; 454 474 } ··· 471 491 } 472 492 473 493 /// Sets the position and color of a CRDT vertex accessor. 474 - fn set_vertex(v: &pm64::model::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i64) { 494 + fn set_vertex(v: &project::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i64) { 475 495 v.set_x(x); 476 496 v.set_y(y); 477 497 v.set_z(z); ··· 484 504 485 505 /// Adds a single colored triangle to a model node. 486 506 fn add_triangle( 487 - node: &pm64::model::ModelNode, 507 + node: &project::ModelNode, 488 508 p0: (f64, f64, f64), 489 509 p1: (f64, f64, f64), 490 510 p2: (f64, f64, f64),
+1 -2
crates/kammy/src/editor.rs
··· 5 5 //! Editor trait, built-in editor implementations, and tile-tree dispatch. 6 6 7 7 pub mod map; 8 - pub mod todo; 9 8 10 9 use std::cell::Cell; 11 10 use std::collections::HashMap; ··· 13 12 14 13 use tracing::debug; 15 14 16 - use crate::Project; 17 15 use crate::gpu::GpuState; 16 + use project::Project; 18 17 19 18 /// Trait for objects that provide property-editing UI in the Inspector panel. 20 19 ///
+16 -11
crates/kammy/src/editor/map.rs
··· 11 11 pub mod camera; 12 12 13 13 use pm64::gbi::{NodeData, TriangleData, VertexData}; 14 - use pm64::model::ModelNode; 14 + use project::{ModelNode, Project}; 15 15 16 16 use super::{Editor, EditorContext, EditorId}; 17 - use crate::Project; 18 17 use crate::widget::n64_viewport::N64Viewport; 19 18 20 19 const FB_WIDTH: u32 = 320; ··· 25 24 /// Editor that renders PM64 map geometry via the N64 RSP + RDP pipeline. 26 25 pub struct MapEditor { 27 26 id: EditorId, 27 + /// Key into `project.scenes()` for the scene this editor displays. 28 + scene_key: String, 28 29 viewport: N64Viewport, 29 30 camera: camera::OrbitCamera, 30 31 } ··· 33 34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 34 35 f.debug_struct("MapEditor") 35 36 .field("id", &self.id) 37 + .field("scene_key", &self.scene_key) 36 38 .finish_non_exhaustive() 37 39 } 38 40 } 39 41 40 42 impl MapEditor { 41 - /// Creates a new map editor with the given stable ID. 42 - pub fn new(id: EditorId) -> Self { 43 + /// Creates a new map editor for the given scene key. 44 + pub fn new(id: EditorId, scene_key: String) -> Self { 43 45 Self { 44 46 id, 47 + scene_key, 45 48 viewport: N64Viewport::new(4 * 1024 * 1024), 46 49 camera: camera::OrbitCamera::default(), 47 50 } ··· 54 57 } 55 58 56 59 fn title(&self) -> String { 57 - "Map".to_owned() 60 + self.scene_key.clone() 58 61 } 59 62 60 63 fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) { 61 - // Handle camera input first so this frame's drag is reflected immediately. 62 64 let interact_rect = ui.available_rect_before_wrap(); 63 65 let interact_response = ui.interact( 64 66 interact_rect, ··· 67 69 ); 68 70 self.camera.handle_input(&interact_response); 69 71 70 - let nodes = extract_nodes(ctx.project); 72 + let nodes = extract_nodes(ctx.project, &self.scene_key); 71 73 #[expect( 72 74 clippy::cast_possible_truncation, 73 75 clippy::as_conversions, ··· 90 92 } 91 93 } 92 94 93 - /// Extracts all model nodes from the project's CRDT tree into plain render data. 94 - fn extract_nodes(project: &Project) -> Vec<NodeData> { 95 - let tree = project.map_model(); 95 + /// Extracts all model nodes from the given scene's model tree into plain render data. 96 + fn extract_nodes(project: &Project, scene_key: &str) -> Vec<NodeData> { 97 + let Some(scene) = project.scenes().get(scene_key) else { 98 + return Vec::new(); 99 + }; 100 + let tree = scene.model_tree(); 96 101 let mut nodes = Vec::new(); 97 102 for root_id in tree.roots() { 98 103 collect_node(&tree, root_id, &mut nodes); ··· 130 135 clippy::as_conversions, 131 136 reason = "vertex coords are small integers that fit in i16/u8" 132 137 )] 133 - fn extract_vertex(v: &pm64::model::Vertex) -> VertexData { 138 + fn extract_vertex(v: &project::Vertex) -> VertexData { 134 139 let c = v.color(); 135 140 VertexData { 136 141 x: v.x() as i16,
-110
crates/kammy/src/editor/todo.rs
··· 1 - // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 - // 3 - // SPDX-License-Identifier: AGPL-3.0-or-later 4 - 5 - //! Todo-list editor. 6 - 7 - use super::{Editor, EditorContext, EditorId}; 8 - 9 - /// A simple todo-list editor. 10 - #[derive(Debug)] 11 - pub struct TodoEditor { 12 - id: EditorId, 13 - new_todo_text: String, 14 - } 15 - 16 - impl TodoEditor { 17 - /// Creates a new empty todo editor with the given stable ID. 18 - pub fn new(id: EditorId) -> Self { 19 - Self { 20 - id, 21 - new_todo_text: String::new(), 22 - } 23 - } 24 - } 25 - 26 - impl Editor for TodoEditor { 27 - fn id(&self) -> EditorId { 28 - self.id 29 - } 30 - 31 - fn title(&self) -> String { 32 - subsecond::call(|| "Todos".to_owned()) 33 - } 34 - 35 - fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) { 36 - let key = self.id.to_string(); 37 - let tab_data = ctx.project.tabs().get_or_create(&key); 38 - 39 - ui.horizontal(|ui| { 40 - let response = ui.text_edit_singleline(&mut self.new_todo_text); 41 - let submitted = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); 42 - if (ui.button("Add").clicked() || submitted) && !self.new_todo_text.is_empty() { 43 - let item = tab_data.items().push_new(); 44 - item.set_title(&self.new_todo_text); 45 - self.new_todo_text.clear(); 46 - response.request_focus(); 47 - } 48 - }); 49 - 50 - ui.separator(); 51 - 52 - let items = tab_data.items(); 53 - let len = items.len(); 54 - 55 - if len == 0 { 56 - ui.vertical_centered(|ui| { 57 - ui.add_space(20.0); 58 - ui.label("No todos yet!"); 59 - }); 60 - return; 61 - } 62 - 63 - let done_count = (0..len) 64 - .filter(|&i| items.get(i).is_some_and(|item| item.done())) 65 - .count(); 66 - ui.label(format!("{done_count}/{len} completed")); 67 - ui.add_space(4.0); 68 - 69 - let mut to_delete = None; 70 - 71 - egui::ScrollArea::vertical().show(ui, |ui| { 72 - for i in 0..len { 73 - if let Some(item) = items.get(i) { 74 - egui::Frame::NONE.inner_margin(4.0).show(ui, |ui| { 75 - ui.horizontal(|ui| { 76 - let mut done = item.done(); 77 - if ui.checkbox(&mut done, "").changed() { 78 - item.set_done(done); 79 - } 80 - 81 - let title = item.title(); 82 - if done { 83 - ui.label( 84 - egui::RichText::new(&title) 85 - .strikethrough() 86 - .color(ui.visuals().weak_text_color()), 87 - ); 88 - } else { 89 - ui.label(&title); 90 - } 91 - 92 - ui.with_layout( 93 - egui::Layout::right_to_left(egui::Align::Center), 94 - |ui| { 95 - if ui.button("\u{1f5d1}").clicked() { 96 - to_delete = Some(i); 97 - } 98 - }, 99 - ); 100 - }); 101 - }); 102 - } 103 - } 104 - }); 105 - 106 - if let Some(i) = to_delete { 107 - items.delete(i, 1); 108 - } 109 - } 110 - }
-25
crates/kammy/src/main.rs
··· 17 17 use std::sync::Arc; 18 18 19 19 use egui::ViewportId; 20 - use loroscope::loroscope; 21 - use pm64::model::ModelNode; 22 20 use tracing_subscriber::EnvFilter; 23 21 use winit::application::ApplicationHandler; 24 22 use winit::event::WindowEvent; 25 23 use winit::event_loop::{ActiveEventLoop, EventLoop}; 26 24 use winit::window::{Window, WindowAttributes, WindowId}; 27 - 28 - /// A single todo item with a title and completion status. 29 - #[loroscope] 30 - #[derive(Debug)] 31 - pub struct TodoItem { 32 - pub title: String, 33 - pub done: bool, 34 - } 35 - 36 - /// Data for a single editor tab (a list of todo items). 37 - #[loroscope] 38 - #[derive(Debug)] 39 - pub struct TabData { 40 - pub items: List<TodoItem>, 41 - } 42 - 43 - /// Root project data, holding all tabs keyed by tile ID. 44 - #[loroscope] 45 - #[derive(Debug)] 46 - pub struct Project { 47 - pub tabs: Map<TabData>, 48 - pub map_model: Tree<ModelNode>, 49 - } 50 25 51 26 /// Application wrapper that implements [`winit::application::ApplicationHandler`]. 52 27 struct WinitApp {
+4 -5
crates/kammy/src/tests/map.rs
··· 58 58 const FB_HEIGHT: u32 = 240; 59 59 const FB_ORIGIN: u32 = 0x0000_0100; 60 60 61 - // Create a Project with a red triangle in the CRDT tree 62 - let project = crate::Project::new(); 63 - let tree = project.map_model(); 61 + // Create a Project with a red triangle in a scene's model tree 62 + let project = project::Project::new(); 63 + let scene = project.scenes().get_or_create("test"); 64 + let tree = scene.model_tree(); 64 65 let (_node_id, node) = tree.create_root(); 65 66 node.set_name("test"); 66 67 ··· 141 142 let (w, h) = (w as usize, h as usize); 142 143 143 144 // Check the center pixel is red-ish (RGBA8). 144 - // The VI filtering and N64 color format conversion mean exact values vary, 145 - // but a red vertex-colored triangle should produce clearly red pixels. 146 145 let idx = (h / 2 * w + w / 2) * 4; 147 146 let (r, g, b) = (buffer[idx], buffer[idx + 1], buffer[idx + 2]); 148 147
+7 -29
crates/kammy/src/tests/undo.rs
··· 6 6 7 7 use egui_kittest::kittest::Queryable; 8 8 9 - /// Helper: type text into the todo input and click Add. 10 - fn add_todo(harness: &mut egui_kittest::Harness<'_>, text: &str) { 11 - harness 12 - .get_by_role(egui::accesskit::Role::TextInput) 13 - .click(); 14 - harness.run(); 15 - harness 16 - .get_by_role(egui::accesskit::Role::TextInput) 17 - .type_text(text); 18 - harness.get_by_label("Add").click(); 19 - harness.run(); 20 - } 21 - 22 9 #[test] 23 - fn single_tab_undo_redo() { 10 + fn undo_redo_buttons_present() { 24 11 let mut harness = super::make_harness(); 25 - 26 - add_todo(&mut harness, "buy milk"); 27 - assert!( 28 - harness.query_by_label("buy milk").is_some(), 29 - "item should exist after adding" 30 - ); 12 + // Use step() instead of run() because the map editor continuously requests repaint 13 + harness.step(); 31 14 32 - harness.get_by_label("⟲ Undo").click(); 33 - harness.run(); 34 15 assert!( 35 - harness.query_by_label("buy milk").is_none(), 36 - "item should be gone after undo" 16 + harness.query_by_label("⟲ Undo").is_some(), 17 + "undo button should be present" 37 18 ); 38 - 39 - harness.get_by_label("⟳ Redo").click(); 40 - harness.run(); 41 19 assert!( 42 - harness.query_by_label("buy milk").is_some(), 43 - "item should reappear after redo" 20 + harness.query_by_label("⟳ Redo").is_some(), 21 + "redo button should be present" 44 22 ); 45 23 }
-5
crates/pm64/Cargo.toml
··· 8 8 edition = "2024" 9 9 10 10 [dependencies] 11 - loroscope = { path = "../loroscope" } 12 11 parallel_rdp = { path = "../parallel_rdp" } 13 12 rsp = { path = "../rsp" } 14 - loro = { workspace = true } 15 - 16 - [dev-dependencies] 17 - loro = { workspace = true } 18 13 19 14 [lints] 20 15 workspace = true
-1
crates/pm64/src/lib.rs
··· 5 5 //! Paper Mario 64 map data structures and rendering pipeline. 6 6 7 7 pub mod gbi; 8 - pub mod model; 9 8 pub mod render;
+46 -9
crates/pm64/src/model.rs crates/project/src/lib.rs
··· 2 2 // 3 3 // SPDX-License-Identifier: AGPL-3.0-or-later 4 4 5 - //! CRDT-backed model data for PM64 map geometry. 5 + //! CRDT-backed document schema for kammy projects. 6 6 //! 7 - //! These structs are backed by Loro via the [`loroscope`] macro, so every 8 - //! field change is a CRDT operation suitable for collaborative editing and 9 - //! undo/redo. 7 + //! All types use the [`loroscope`] macro so that every field change is a CRDT 8 + //! operation suitable for collaborative editing and undo/redo. 10 9 11 10 use loroscope::loroscope; 12 11 ··· 49 48 pub triangles: List<Triangle>, 50 49 } 51 50 51 + /// A map or stage. 52 + #[loroscope] 53 + #[derive(Debug)] 54 + pub struct Scene { 55 + /// Map description (from XML `desc` attribute). 56 + pub display_name: String, 57 + /// Background identifier (from XML `background` attribute). 58 + pub background: String, 59 + /// Texture archive name (from XML `textures` attribute). 60 + pub textures: String, 61 + /// The 3D model hierarchy. 62 + pub model_tree: Tree<ModelNode>, 63 + } 64 + 65 + /// Root project data. 66 + #[loroscope] 67 + #[derive(Debug)] 68 + pub struct Project { 69 + /// Map scenes keyed by map name (e.g. "kmr_20"). 70 + pub scenes: Map<Scene>, 71 + } 72 + 52 73 #[cfg(test)] 53 74 mod tests { 54 75 #![allow(clippy::unwrap_used, clippy::float_cmp)] ··· 59 80 60 81 #[loroscope] 61 82 struct TestRoot { 62 - pub map_model: Tree<ModelNode>, 83 + pub model_tree: Tree<ModelNode>, 63 84 } 64 85 65 86 #[test] 66 87 fn create_model_node_with_triangle() { 67 88 let root = TestRoot::new(); 68 - let tree = root.map_model(); 89 + let tree = root.model_tree(); 69 90 70 91 let (node_id, node) = tree.create_root(); 71 92 node.set_name("test_node"); ··· 88 109 tri.v2().set_y(100.0); 89 110 tri.v2().set_z(0.0); 90 111 91 - // Read back 92 112 let read_node = tree.get(node_id).unwrap(); 93 113 assert_eq!(read_node.name(), "test_node"); 94 114 assert_eq!(read_node.triangles().len(), 1); ··· 103 123 #[test] 104 124 fn multiple_triangles_in_node() { 105 125 let root = TestRoot::new(); 106 - let tree = root.map_model(); 126 + let tree = root.model_tree(); 107 127 108 128 let (_id, node) = tree.create_root(); 109 129 node.set_name("multi"); ··· 124 144 #[test] 125 145 fn tree_hierarchy() { 126 146 let root = TestRoot::new(); 127 - let tree = root.map_model(); 147 + let tree = root.model_tree(); 128 148 129 149 let (parent_id, parent) = tree.create_root(); 130 150 parent.set_name("parent"); ··· 135 155 assert_eq!(tree.children(parent_id).unwrap().len(), 1); 136 156 assert_eq!(tree.children(parent_id).unwrap()[0], child_id); 137 157 assert_eq!(tree.get(child_id).unwrap().name(), "child"); 158 + } 159 + 160 + #[test] 161 + fn scene_metadata() { 162 + let project = Project::new(); 163 + let scene = project.scenes().get_or_create("kmr_20"); 164 + scene.set_display_name("Goomba Village"); 165 + scene.set_background("bg_sky"); 166 + scene.set_textures("kmr_tex"); 167 + 168 + assert_eq!(scene.display_name(), "Goomba Village"); 169 + assert_eq!(scene.background(), "bg_sky"); 170 + assert_eq!(scene.textures(), "kmr_tex"); 171 + 172 + let (_id, node) = scene.model_tree().create_root(); 173 + node.set_name("root"); 174 + assert_eq!(scene.model_tree().roots().len(), 1); 138 175 } 139 176 }
+18
crates/project/Cargo.toml
··· 1 + # SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + # 3 + # SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + [package] 6 + name = "project" 7 + version = "0.1.0" 8 + edition = "2024" 9 + 10 + [dependencies] 11 + loroscope = { path = "../loroscope" } 12 + loro = { workspace = true } 13 + 14 + [dev-dependencies] 15 + loro = { workspace = true } 16 + 17 + [lints] 18 + workspace = true
+21
crates/star_rod_interop/Cargo.toml
··· 1 + # SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + # 3 + # SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + [package] 6 + name = "star_rod_interop" 7 + version = "0.1.0" 8 + edition = "2024" 9 + 10 + [dependencies] 11 + project = { path = "../project" } 12 + loroscope = { path = "../loroscope" } 13 + loro = { workspace = true } 14 + roxmltree = { workspace = true } 15 + thiserror = { workspace = true } 16 + 17 + [dev-dependencies] 18 + loro = { workspace = true } 19 + 20 + [lints] 21 + workspace = true
+541
crates/star_rod_interop/src/lib.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + //! Import Star Rod decomp XML files into the kammy CRDT project. 6 + //! 7 + //! Parses the `<ModelTree>` and `<Models>` sections from a PM64 map geometry 8 + //! XML, building the corresponding node hierarchy and triangle data in a 9 + //! [`project::Scene`]. 10 + 11 + use std::collections::HashMap; 12 + 13 + use project::Scene; 14 + 15 + /// Errors that can occur during map XML import. 16 + #[derive(Debug, thiserror::Error)] 17 + pub enum ImportError { 18 + /// The XML could not be parsed. 19 + #[error("XML parse error: {0}")] 20 + XmlParse(#[from] roxmltree::Error), 21 + 22 + /// A required section is missing from the XML. 23 + #[error("missing required element: {0}")] 24 + MissingElement(&'static str), 25 + 26 + /// An attribute value could not be parsed as the expected type. 27 + #[error("invalid attribute {attr} on <{element}>: {reason}")] 28 + InvalidAttribute { 29 + /// Element tag name. 30 + element: String, 31 + /// Attribute name. 32 + attr: String, 33 + /// Description of what went wrong. 34 + reason: String, 35 + }, 36 + 37 + /// A `<Model>` references a node ID not found in `<ModelTree>`. 38 + #[error("model references unknown node id {0:?}")] 39 + UnknownNodeId(String), 40 + } 41 + 42 + /// Parses a PM64 map XML and populates the given scene's model tree. 43 + /// 44 + /// Sets the scene's name, background, and textures fields from the `<Map>` 45 + /// element attributes, then walks `<ModelTree>` to build the node hierarchy 46 + /// and `<Models>` to fill in triangle data. 47 + /// 48 + /// # Errors 49 + /// 50 + /// Returns [`ImportError`] if the XML is malformed, required sections are 51 + /// missing, or coordinate/color values cannot be parsed. 52 + pub fn import_map_xml(xml: &str, scene: &Scene) -> Result<(), ImportError> { 53 + let doc = roxmltree::Document::parse(xml)?; 54 + let map_el = doc.root_element(); 55 + 56 + // Read <Map> attributes 57 + if let Some(desc) = map_el.attribute("desc") { 58 + scene.set_display_name(desc); 59 + } else if let Some(name) = map_el.attribute("name") { 60 + scene.set_display_name(name); 61 + } 62 + if let Some(bg) = map_el.attribute("background") { 63 + scene.set_background(bg); 64 + } 65 + if let Some(tex) = map_el.attribute("textures") { 66 + scene.set_textures(tex); 67 + } 68 + 69 + // Pass 1: Build node hierarchy from <ModelTree> 70 + let model_tree_el = map_el 71 + .children() 72 + .find(|n| n.has_tag_name("ModelTree")) 73 + .ok_or(ImportError::MissingElement("<ModelTree>"))?; 74 + 75 + // Map hex ID string → CRDT TreeID 76 + let mut id_map: HashMap<String, loro::TreeID> = HashMap::new(); 77 + let crdt_tree = scene.model_tree(); 78 + 79 + for root_node in model_tree_el.children().filter(|n| n.has_tag_name("Node")) { 80 + build_tree_recursive(&root_node, None, &crdt_tree, &mut id_map)?; 81 + } 82 + 83 + // Pass 2: Walk <Models>, link each to its node, parse triangle batches 84 + let models_el = map_el 85 + .children() 86 + .find(|n| n.has_tag_name("Models")) 87 + .ok_or(ImportError::MissingElement("<Models>"))?; 88 + 89 + for model_el in models_el.children().filter(|n| n.has_tag_name("Model")) { 90 + let Some(map_obj) = model_el.children().find(|n| n.has_tag_name("MapObject")) else { 91 + continue; 92 + }; 93 + 94 + let Some(hex_id) = map_obj.attribute("id") else { 95 + continue; 96 + }; 97 + 98 + let Some(&tree_id) = id_map.get(hex_id) else { 99 + continue; 100 + }; 101 + 102 + let Some(node) = crdt_tree.get(tree_id) else { 103 + continue; 104 + }; 105 + 106 + // Find <ShapeMesh> → <DisplayList> → <TriangleBatch>* 107 + let shape_mesh = model_el.children().find(|n| n.has_tag_name("ShapeMesh")); 108 + let display_list = 109 + shape_mesh.and_then(|sm| sm.children().find(|n| n.has_tag_name("DisplayList"))); 110 + 111 + if let Some(dl) = display_list { 112 + for batch in dl.children().filter(|n| n.has_tag_name("TriangleBatch")) { 113 + parse_triangle_batch(&batch, &node)?; 114 + } 115 + } 116 + } 117 + 118 + Ok(()) 119 + } 120 + 121 + /// Recursively builds CRDT tree nodes from `<Node>` elements. 122 + fn build_tree_recursive( 123 + xml_node: &roxmltree::Node<'_, '_>, 124 + parent: Option<loro::TreeID>, 125 + crdt_tree: &loroscope::Tree<project::ModelNode>, 126 + id_map: &mut HashMap<String, loro::TreeID>, 127 + ) -> Result<(), ImportError> { 128 + let name = xml_node.attribute("name").unwrap_or(""); 129 + let hex_id = xml_node.attribute("id").unwrap_or(""); 130 + 131 + let (tree_id, crdt_node) = if let Some(parent_id) = parent { 132 + crdt_tree.create_child(parent_id) 133 + } else { 134 + crdt_tree.create_root() 135 + }; 136 + 137 + crdt_node.set_name(name); 138 + id_map.insert(hex_id.to_owned(), tree_id); 139 + 140 + for child in xml_node.children().filter(|n| n.has_tag_name("Node")) { 141 + build_tree_recursive(&child, Some(tree_id), crdt_tree, id_map)?; 142 + } 143 + 144 + Ok(()) 145 + } 146 + 147 + /// Parses a `<TriangleBatch>` element and adds its triangles to the node. 148 + fn parse_triangle_batch( 149 + batch: &roxmltree::Node<'_, '_>, 150 + node: &project::ModelNode, 151 + ) -> Result<(), ImportError> { 152 + let vertex_table = batch 153 + .children() 154 + .find(|n| n.has_tag_name("VertexTable")) 155 + .ok_or(ImportError::MissingElement("<VertexTable>"))?; 156 + 157 + let triangle_list = batch 158 + .children() 159 + .find(|n| n.has_tag_name("TriangleList")) 160 + .ok_or(ImportError::MissingElement("<TriangleList>"))?; 161 + 162 + // Parse vertices into a temporary buffer 163 + let vertices: Vec<ParsedVertex> = vertex_table 164 + .children() 165 + .filter(|n| n.has_tag_name("Vertex")) 166 + .map(parse_vertex_element) 167 + .collect::<Result<_, _>>()?; 168 + 169 + // Parse triangle indices and write to CRDT 170 + for tri_el in triangle_list 171 + .children() 172 + .filter(|n| n.has_tag_name("Triangle")) 173 + { 174 + let ijk = tri_el 175 + .attribute("ijk") 176 + .ok_or(ImportError::InvalidAttribute { 177 + element: "Triangle".to_owned(), 178 + attr: "ijk".to_owned(), 179 + reason: "missing attribute".to_owned(), 180 + })?; 181 + 182 + let indices = parse_index_triple(ijk)?; 183 + let [i, j, k] = indices; 184 + 185 + let get_vert = |idx: usize| -> Result<&ParsedVertex, ImportError> { 186 + vertices.get(idx).ok_or(ImportError::InvalidAttribute { 187 + element: "Triangle".to_owned(), 188 + attr: "ijk".to_owned(), 189 + reason: format!( 190 + "index {idx} out of range (vertex count: {})", 191 + vertices.len() 192 + ), 193 + }) 194 + }; 195 + 196 + let v0 = get_vert(i)?; 197 + let v1 = get_vert(j)?; 198 + let v2 = get_vert(k)?; 199 + 200 + let tri = node.triangles().push_new(); 201 + write_vertex(&tri.v0(), v0); 202 + write_vertex(&tri.v1(), v1); 203 + write_vertex(&tri.v2(), v2); 204 + } 205 + 206 + Ok(()) 207 + } 208 + 209 + /// Intermediate vertex data parsed from XML. 210 + struct ParsedVertex { 211 + x: f64, 212 + y: f64, 213 + z: f64, 214 + r: i64, 215 + g: i64, 216 + b: i64, 217 + a: i64, 218 + } 219 + 220 + /// Parses a `<Vertex xyz="x,y,z" rgba="r,g,b,a"/>` element. 221 + fn parse_vertex_element(el: roxmltree::Node<'_, '_>) -> Result<ParsedVertex, ImportError> { 222 + let xyz = el.attribute("xyz").ok_or(ImportError::InvalidAttribute { 223 + element: "Vertex".to_owned(), 224 + attr: "xyz".to_owned(), 225 + reason: "missing attribute".to_owned(), 226 + })?; 227 + 228 + let coords = parse_f64_csv(xyz, "Vertex", "xyz")?; 229 + if coords.len() != 3 { 230 + return Err(ImportError::InvalidAttribute { 231 + element: "Vertex".to_owned(), 232 + attr: "xyz".to_owned(), 233 + reason: format!("expected 3 components, got {}", coords.len()), 234 + }); 235 + } 236 + 237 + let (r, g, b, a) = if let Some(rgba) = el.attribute("rgba") { 238 + let c = parse_i64_csv(rgba, "Vertex", "rgba")?; 239 + if c.len() != 4 { 240 + return Err(ImportError::InvalidAttribute { 241 + element: "Vertex".to_owned(), 242 + attr: "rgba".to_owned(), 243 + reason: format!("expected 4 components, got {}", c.len()), 244 + }); 245 + } 246 + (c[0], c[1], c[2], c[3]) 247 + } else { 248 + (255, 255, 255, 255) 249 + }; 250 + 251 + Ok(ParsedVertex { 252 + x: coords[0], 253 + y: coords[1], 254 + z: coords[2], 255 + r, 256 + g, 257 + b, 258 + a, 259 + }) 260 + } 261 + 262 + /// Writes a parsed vertex to a CRDT vertex accessor. 263 + fn write_vertex(crdt: &project::Vertex, v: &ParsedVertex) { 264 + crdt.set_x(v.x); 265 + crdt.set_y(v.y); 266 + crdt.set_z(v.z); 267 + let color = crdt.color(); 268 + color.set_r(v.r); 269 + color.set_g(v.g); 270 + color.set_b(v.b); 271 + color.set_a(v.a); 272 + } 273 + 274 + /// Parses a "i,j,k" index triple string into three `usize` values. 275 + fn parse_index_triple(s: &str) -> Result<[usize; 3], ImportError> { 276 + let parts: Vec<&str> = s.split(',').collect(); 277 + if parts.len() != 3 { 278 + return Err(ImportError::InvalidAttribute { 279 + element: "Triangle".to_owned(), 280 + attr: "ijk".to_owned(), 281 + reason: format!("expected 3 indices, got {}", parts.len()), 282 + }); 283 + } 284 + 285 + let parse = |part: &str| -> Result<usize, ImportError> { 286 + part.trim() 287 + .parse() 288 + .map_err(|e| ImportError::InvalidAttribute { 289 + element: "Triangle".to_owned(), 290 + attr: "ijk".to_owned(), 291 + reason: format!("invalid index {part:?}: {e}"), 292 + }) 293 + }; 294 + 295 + Ok([parse(parts[0])?, parse(parts[1])?, parse(parts[2])?]) 296 + } 297 + 298 + /// Parses a comma-separated string of floats. 299 + fn parse_f64_csv(s: &str, element: &str, attr: &str) -> Result<Vec<f64>, ImportError> { 300 + s.split(',') 301 + .map(|part| { 302 + part.trim() 303 + .parse() 304 + .map_err(|e| ImportError::InvalidAttribute { 305 + element: element.to_owned(), 306 + attr: attr.to_owned(), 307 + reason: format!("invalid number {part:?}: {e}"), 308 + }) 309 + }) 310 + .collect() 311 + } 312 + 313 + /// Parses a comma-separated string of integers. 314 + fn parse_i64_csv(s: &str, element: &str, attr: &str) -> Result<Vec<i64>, ImportError> { 315 + s.split(',') 316 + .map(|part| { 317 + part.trim() 318 + .parse() 319 + .map_err(|e| ImportError::InvalidAttribute { 320 + element: element.to_owned(), 321 + attr: attr.to_owned(), 322 + reason: format!("invalid integer {part:?}: {e}"), 323 + }) 324 + }) 325 + .collect() 326 + } 327 + 328 + #[cfg(test)] 329 + mod tests { 330 + #![allow(clippy::unwrap_used)] 331 + 332 + use super::*; 333 + 334 + fn make_scene() -> Scene { 335 + let project = project::Project::new(); 336 + project.scenes().get_or_create("test") 337 + } 338 + 339 + #[test] 340 + fn minimal_single_node() { 341 + let scene = make_scene(); 342 + let xml = r#"<?xml version="1.0"?> 343 + <Map desc="Test" background="bg_test" textures="tex_test"> 344 + <ModelTree> 345 + <Node name="Root" id="0"/> 346 + </ModelTree> 347 + <Models> 348 + <Model ver="2" type="ROOT" lightset="0"> 349 + <MapObject name="Root" id="0"/> 350 + </Model> 351 + </Models> 352 + </Map>"#; 353 + 354 + import_map_xml(xml, &scene).unwrap(); 355 + assert_eq!(scene.display_name(), "Test"); 356 + assert_eq!(scene.background(), "bg_test"); 357 + assert_eq!(scene.textures(), "tex_test"); 358 + assert_eq!(scene.model_tree().roots().len(), 1); 359 + } 360 + 361 + #[test] 362 + fn node_with_triangles() { 363 + let scene = make_scene(); 364 + let xml = r#"<?xml version="1.0"?> 365 + <Map desc="Tri Test" background="" textures=""> 366 + <ModelTree> 367 + <Node name="Root" id="0"> 368 + <Node name="Mesh" id="1"/> 369 + </Node> 370 + </ModelTree> 371 + <Models> 372 + <Model ver="2" type="ROOT" lightset="0"> 373 + <MapObject name="Root" id="0"/> 374 + </Model> 375 + <Model ver="2" type="MODEL" lightset="0"> 376 + <MapObject name="Mesh" id="1"/> 377 + <ShapeMesh ver="3" texture="test_tex"> 378 + <DisplayList> 379 + <TriangleBatch ver="0"> 380 + <VertexTable> 381 + <Vertex xyz="0,0,0" rgba="255,0,0,255"/> 382 + <Vertex xyz="100,0,0" rgba="0,255,0,255"/> 383 + <Vertex xyz="50,100,0" rgba="0,0,255,255"/> 384 + </VertexTable> 385 + <TriangleList> 386 + <Triangle ijk="0,1,2"/> 387 + </TriangleList> 388 + </TriangleBatch> 389 + </DisplayList> 390 + </ShapeMesh> 391 + </Model> 392 + </Models> 393 + </Map>"#; 394 + 395 + import_map_xml(xml, &scene).unwrap(); 396 + 397 + let tree = scene.model_tree(); 398 + let roots = tree.roots(); 399 + assert_eq!(roots.len(), 1); 400 + 401 + let root_id = roots[0]; 402 + let root = tree.get(root_id).unwrap(); 403 + assert_eq!(root.name(), "Root"); 404 + 405 + let children = tree.children(root_id).unwrap(); 406 + assert_eq!(children.len(), 1); 407 + 408 + let mesh_node = tree.get(children[0]).unwrap(); 409 + assert_eq!(mesh_node.name(), "Mesh"); 410 + assert_eq!(mesh_node.triangles().len(), 1); 411 + 412 + let tri = mesh_node.triangles().get(0).unwrap(); 413 + assert_eq!(tri.v0().x(), 0.0); 414 + assert_eq!(tri.v0().color().r(), 255); 415 + assert_eq!(tri.v1().x(), 100.0); 416 + assert_eq!(tri.v1().color().g(), 255); 417 + assert_eq!(tri.v2().y(), 100.0); 418 + assert_eq!(tri.v2().color().b(), 255); 419 + } 420 + 421 + #[test] 422 + fn multiple_batches() { 423 + let scene = make_scene(); 424 + let xml = r#"<?xml version="1.0"?> 425 + <Map desc="" background="" textures=""> 426 + <ModelTree> 427 + <Node name="Root" id="0"/> 428 + </ModelTree> 429 + <Models> 430 + <Model ver="2" type="MODEL" lightset="0"> 431 + <MapObject name="Root" id="0"/> 432 + <ShapeMesh ver="3" texture="t"> 433 + <DisplayList> 434 + <TriangleBatch ver="0"> 435 + <VertexTable> 436 + <Vertex xyz="0,0,0"/> 437 + <Vertex xyz="1,0,0"/> 438 + <Vertex xyz="0,1,0"/> 439 + </VertexTable> 440 + <TriangleList> 441 + <Triangle ijk="0,1,2"/> 442 + </TriangleList> 443 + </TriangleBatch> 444 + <TriangleBatch ver="0"> 445 + <VertexTable> 446 + <Vertex xyz="10,0,0"/> 447 + <Vertex xyz="11,0,0"/> 448 + <Vertex xyz="10,1,0"/> 449 + </VertexTable> 450 + <TriangleList> 451 + <Triangle ijk="0,1,2"/> 452 + </TriangleList> 453 + </TriangleBatch> 454 + </DisplayList> 455 + </ShapeMesh> 456 + </Model> 457 + </Models> 458 + </Map>"#; 459 + 460 + import_map_xml(xml, &scene).unwrap(); 461 + let tree = scene.model_tree(); 462 + let node = tree.get(tree.roots()[0]).unwrap(); 463 + assert_eq!(node.triangles().len(), 2); 464 + } 465 + 466 + #[test] 467 + fn missing_rgba_defaults_to_white() { 468 + let scene = make_scene(); 469 + let xml = r#"<?xml version="1.0"?> 470 + <Map desc="" background="" textures=""> 471 + <ModelTree> 472 + <Node name="Root" id="0"/> 473 + </ModelTree> 474 + <Models> 475 + <Model ver="2" type="MODEL" lightset="0"> 476 + <MapObject name="Root" id="0"/> 477 + <ShapeMesh ver="3" texture="t"> 478 + <DisplayList> 479 + <TriangleBatch ver="0"> 480 + <VertexTable> 481 + <Vertex xyz="0,0,0"/> 482 + <Vertex xyz="1,0,0"/> 483 + <Vertex xyz="0,1,0"/> 484 + </VertexTable> 485 + <TriangleList> 486 + <Triangle ijk="0,1,2"/> 487 + </TriangleList> 488 + </TriangleBatch> 489 + </DisplayList> 490 + </ShapeMesh> 491 + </Model> 492 + </Models> 493 + </Map>"#; 494 + 495 + import_map_xml(xml, &scene).unwrap(); 496 + let tri = scene 497 + .model_tree() 498 + .get(scene.model_tree().roots()[0]) 499 + .unwrap() 500 + .triangles() 501 + .get(0) 502 + .unwrap(); 503 + assert_eq!(tri.v0().color().r(), 255); 504 + assert_eq!(tri.v0().color().g(), 255); 505 + assert_eq!(tri.v0().color().b(), 255); 506 + assert_eq!(tri.v0().color().a(), 255); 507 + } 508 + 509 + #[test] 510 + fn malformed_xml_returns_error() { 511 + let scene = make_scene(); 512 + let result = import_map_xml("<not valid xml", &scene); 513 + assert!(result.is_err()); 514 + } 515 + 516 + #[test] 517 + fn missing_model_tree_returns_error() { 518 + let scene = make_scene(); 519 + let xml = r#"<?xml version="1.0"?> 520 + <Map desc="" background="" textures=""> 521 + <Models/> 522 + </Map>"#; 523 + let result = import_map_xml(xml, &scene); 524 + assert!(matches!(result, Err(ImportError::MissingElement(_)))); 525 + } 526 + 527 + #[test] 528 + fn name_attribute_fallback() { 529 + let scene = make_scene(); 530 + let xml = r#"<?xml version="1.0"?> 531 + <Map name="kmr_20" background="" textures=""> 532 + <ModelTree> 533 + <Node name="Root" id="0"/> 534 + </ModelTree> 535 + <Models/> 536 + </Map>"#; 537 + 538 + import_map_xml(xml, &scene).unwrap(); 539 + assert_eq!(scene.display_name(), "kmr_20"); 540 + } 541 + }
+167
crates/star_rod_interop/tests/import.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com> 2 + // 3 + // SPDX-License-Identifier: AGPL-3.0-or-later 4 + 5 + //! Integration tests for PM64 map XML import. 6 + 7 + #![allow(clippy::unwrap_used)] 8 + 9 + use project::Project; 10 + use star_rod_interop::import_map_xml; 11 + 12 + fn make_scene(key: &str) -> (Project, project::Scene) { 13 + let project = Project::new(); 14 + let scene = project.scenes().get_or_create(key); 15 + (project, scene) 16 + } 17 + 18 + #[test] 19 + fn import_realistic_map() { 20 + let (_project, scene) = make_scene("realistic"); 21 + 22 + let xml = r#"<?xml version="1.0" encoding="UTF-8"?> 23 + <Map desc="Test Village" background="arn_bg" textures="test_tex"> 24 + <Editor> 25 + <Camera pos="0,200,500" pitch="30" yaw="0"/> 26 + </Editor> 27 + <ModelTree> 28 + <Node name="Root" id="5"> 29 + <Node name="Ground" id="0"/> 30 + <Node name="House" id="1"/> 31 + <Node name="Tree" id="2"> 32 + <Node name="Trunk" id="3"/> 33 + <Node name="Leaves" id="4"/> 34 + </Node> 35 + </Node> 36 + </ModelTree> 37 + <ColliderTree> 38 + <Node name="Root" id="0"/> 39 + </ColliderTree> 40 + <ZoneTree> 41 + <Node name="Root" id="0"/> 42 + </ZoneTree> 43 + <MarkerTree> 44 + <Node name="Root" id="0"/> 45 + </MarkerTree> 46 + <LightSets> 47 + <LightSet name="Lights_None" a="0"/> 48 + </LightSets> 49 + <Models> 50 + <Model ver="2" type="ROOT" lightset="0"> 51 + <MapObject name="Root" id="5"/> 52 + <BoundingBox empty="false" min="-500,-100,-500" max="500,300,500"/> 53 + <PropertyList/> 54 + </Model> 55 + <Model ver="2" type="MODEL" lightset="0"> 56 + <MapObject name="Ground" id="0"/> 57 + <ShapeMesh ver="3" texture="grass_tex"> 58 + <DisplayList> 59 + <F3DEX2 cmd="E7000000,0"/> 60 + <TriangleBatch ver="0"> 61 + <VertexTable> 62 + <Vertex xyz="-200,0,-200" uv="0,0" rgba="80,140,80,255"/> 63 + <Vertex xyz="-200,0,200" uv="0,1024" rgba="80,140,80,255"/> 64 + <Vertex xyz="200,0,200" uv="1024,1024" rgba="80,140,80,255"/> 65 + <Vertex xyz="200,0,-200" uv="1024,0" rgba="80,140,80,255"/> 66 + </VertexTable> 67 + <TriangleList> 68 + <Triangle ijk="0,1,2"/> 69 + <Triangle ijk="0,2,3"/> 70 + </TriangleList> 71 + </TriangleBatch> 72 + </DisplayList> 73 + </ShapeMesh> 74 + </Model> 75 + <Model ver="2" type="MODEL" lightset="0"> 76 + <MapObject name="Trunk" id="3"/> 77 + <ShapeMesh ver="3" texture="bark_tex"> 78 + <DisplayList> 79 + <TriangleBatch ver="0"> 80 + <VertexTable> 81 + <Vertex xyz="90,0,0" rgba="139,90,43,255"/> 82 + <Vertex xyz="110,0,0" rgba="139,90,43,255"/> 83 + <Vertex xyz="100,80,0" rgba="139,90,43,255"/> 84 + </VertexTable> 85 + <TriangleList> 86 + <Triangle ijk="0,1,2"/> 87 + </TriangleList> 88 + </TriangleBatch> 89 + </DisplayList> 90 + </ShapeMesh> 91 + </Model> 92 + </Models> 93 + </Map>"#; 94 + 95 + import_map_xml(xml, &scene).unwrap(); 96 + 97 + // Verify metadata 98 + assert_eq!(scene.display_name(), "Test Village"); 99 + assert_eq!(scene.background(), "arn_bg"); 100 + assert_eq!(scene.textures(), "test_tex"); 101 + 102 + // Verify tree hierarchy: Root -> [Ground, House, Tree -> [Trunk, Leaves]] 103 + let tree = scene.model_tree(); 104 + let roots = tree.roots(); 105 + assert_eq!(roots.len(), 1, "should have exactly one root"); 106 + 107 + let root = tree.get(roots[0]).unwrap(); 108 + assert_eq!(root.name(), "Root"); 109 + 110 + let children = tree.children(roots[0]).unwrap(); 111 + assert_eq!(children.len(), 3, "Root should have 3 children"); 112 + 113 + // Check Ground node has 2 triangles 114 + let ground = tree.get(children[0]).unwrap(); 115 + assert_eq!(ground.name(), "Ground"); 116 + assert_eq!(ground.triangles().len(), 2); 117 + 118 + // Spot-check a vertex 119 + let tri0 = ground.triangles().get(0).unwrap(); 120 + assert_eq!(tri0.v0().x(), -200.0); 121 + assert_eq!(tri0.v0().color().r(), 80); 122 + 123 + // Check tree subtree: Tree -> [Trunk, Leaves] 124 + let tree_node_id = children[2]; 125 + let tree_node = tree.get(tree_node_id).unwrap(); 126 + assert_eq!(tree_node.name(), "Tree"); 127 + 128 + let tree_children = tree.children(tree_node_id).unwrap(); 129 + assert_eq!(tree_children.len(), 2); 130 + 131 + let trunk = tree.get(tree_children[0]).unwrap(); 132 + assert_eq!(trunk.name(), "Trunk"); 133 + assert_eq!(trunk.triangles().len(), 1); 134 + 135 + let leaves = tree.get(tree_children[1]).unwrap(); 136 + assert_eq!(leaves.name(), "Leaves"); 137 + assert_eq!(leaves.triangles().len(), 0); 138 + } 139 + 140 + #[test] 141 + fn models_without_shape_mesh_are_ok() { 142 + let (_project, scene) = make_scene("no_mesh"); 143 + 144 + let xml = r#"<?xml version="1.0"?> 145 + <Map desc="" background="" textures=""> 146 + <ModelTree> 147 + <Node name="Root" id="0"> 148 + <Node name="Empty" id="1"/> 149 + </Node> 150 + </ModelTree> 151 + <Models> 152 + <Model ver="2" type="ROOT" lightset="0"> 153 + <MapObject name="Root" id="0"/> 154 + </Model> 155 + <Model ver="2" type="MODEL" lightset="0"> 156 + <MapObject name="Empty" id="1"/> 157 + </Model> 158 + </Models> 159 + </Map>"#; 160 + 161 + import_map_xml(xml, &scene).unwrap(); 162 + let tree = scene.model_tree(); 163 + let children = tree.children(tree.roots()[0]).unwrap(); 164 + let empty = tree.get(children[0]).unwrap(); 165 + assert_eq!(empty.name(), "Empty"); 166 + assert_eq!(empty.triangles().len(), 0); 167 + }