···77use std::cell::Cell;
88use std::collections::HashMap;
991010-use crate::Project;
1110use crate::dock::{Dock, DockPosition};
1211use crate::editor::map::MapEditor;
1313-use crate::editor::todo::TodoEditor;
1412use crate::editor::{Editor, EditorId, Inspect, TileBehavior, UndoBehavior};
1513use crate::gpu::GpuState;
1614use crate::tool::ToolContext;
1715use crate::tool::assets::AssetsTool;
1816use crate::tool::hierarchy::HierarchyTool;
1917use crate::tool::inspector::InspectorTool;
1818+use project::Project;
20192120/// Callback for initialising CRDT data when a new editor is added.
2221type SetupCrdt = dyn Fn(EditorId, &Project);
···5049 id
5150 }
52515353- /// Creates the app with a todo editor and tool docks.
5252+ /// Creates the app with a demo map editor and tool docks.
5453 pub fn new() -> Self {
5554 let project = Project::new();
5656- let todo_editor_id = EditorId(0);
5555+ let map_editor_id = EditorId(0);
5656+ let scene_key = "demo".to_owned();
5757 let next_editor_id = 1u64;
58585959 let mut tiles = egui_tiles::Tiles::default();
6060- let todo_tile = tiles.insert_pane({
6161- let editor: Box<dyn Editor> = Box::new(TodoEditor::new(todo_editor_id));
6060+ let map_tile = tiles.insert_pane({
6161+ let editor: Box<dyn Editor> =
6262+ Box::new(MapEditor::new(map_editor_id, scene_key.clone()));
6263 editor
6364 });
6464- let editor_tabs = tiles.insert_tab_tile(vec![todo_tile]);
6565+ let editor_tabs = tiles.insert_tab_tile(vec![map_tile]);
6566 let tree = egui_tiles::Tree::new("kammy_editors", editor_tabs, tiles);
66676767- // Create CRDT data for the initial editor
6868- let key = todo_editor_id.to_string();
6969- let initial = project.tabs().get_or_create(&key);
7070- let _ = initial.items();
6868+ // Create demo scene with ground plane and cube
6969+ let scene = project.scenes().get_or_create(&scene_key);
7070+ let model_tree = scene.model_tree();
7171+7272+ let (_ground_id, ground) = model_tree.create_root();
7373+ ground.set_name("Ground");
7474+ let green = (80, 140, 80);
7575+ add_triangle(
7676+ &ground,
7777+ (-300.0, 0.0, -300.0),
7878+ (-300.0, 0.0, 300.0),
7979+ (300.0, 0.0, 300.0),
8080+ green,
8181+ );
8282+ add_triangle(
8383+ &ground,
8484+ (-300.0, 0.0, -300.0),
8585+ (300.0, 0.0, 300.0),
8686+ (300.0, 0.0, -300.0),
8787+ green,
8888+ );
8989+9090+ let (_cube_id, cube) = model_tree.create_root();
9191+ cube.set_name("Cube");
9292+ let s = 75.0;
9393+9494+ // Front (z=+s): red
9595+ let c = (220, 60, 60);
9696+ add_triangle(&cube, (-s, -s, s), (s, -s, s), (s, s, s), c);
9797+ add_triangle(&cube, (-s, -s, s), (s, s, s), (-s, s, s), c);
9898+9999+ // Back (z=-s): green
100100+ let c = (60, 180, 60);
101101+ add_triangle(&cube, (s, -s, -s), (-s, -s, -s), (-s, s, -s), c);
102102+ add_triangle(&cube, (s, -s, -s), (-s, s, -s), (s, s, -s), c);
103103+104104+ // Top (y=+s): blue
105105+ let c = (60, 60, 220);
106106+ add_triangle(&cube, (-s, s, s), (s, s, s), (s, s, -s), c);
107107+ add_triangle(&cube, (-s, s, s), (s, s, -s), (-s, s, -s), c);
108108+109109+ // Bottom (y=-s): yellow
110110+ let c = (220, 220, 60);
111111+ add_triangle(&cube, (-s, -s, -s), (s, -s, -s), (s, -s, s), c);
112112+ add_triangle(&cube, (-s, -s, -s), (s, -s, s), (-s, -s, s), c);
113113+114114+ // Right (x=+s): cyan
115115+ let c = (60, 220, 220);
116116+ add_triangle(&cube, (s, -s, s), (s, -s, -s), (s, s, -s), c);
117117+ add_triangle(&cube, (s, -s, s), (s, s, -s), (s, s, s), c);
118118+119119+ // Left (x=-s): magenta
120120+ let c = (220, 60, 220);
121121+ add_triangle(&cube, (-s, -s, -s), (-s, -s, s), (-s, s, s), c);
122122+ add_triangle(&cube, (-s, -s, -s), (-s, s, s), (-s, s, -s), c);
123123+71124 project.doc().set_next_commit_origin("meta");
72125 project.doc().commit();
731267474- let origin = format!("e{}/", todo_editor_id.0);
127127+ let origin = format!("e{}/", map_editor_id.0);
75128 let mut undo_manager = loro::UndoManager::new(&project.doc());
76129 undo_manager.add_exclude_origin_prefix("meta");
7713078131 let mut undo_behaviors = HashMap::new();
79132 undo_behaviors.insert(
8080- todo_editor_id,
133133+ map_editor_id,
81134 UndoBehavior::Own {
82135 undo_manager,
83136 origin,
···108161 project,
109162 tree,
110163 undo_behaviors,
111111- active_document: Some(todo_tile),
112112- active_editor_id: Some(todo_editor_id),
164164+ active_document: Some(map_tile),
165165+ active_editor_id: Some(map_editor_id),
113166 inspect: None,
114167 next_editor_id,
115168 left_dock,
···177230 }
178231 }
179232180180- fn add_todo_editor(&mut self) {
233233+ fn add_map_editor(&mut self, scene_key: String) {
234234+ let key = scene_key.clone();
181235 self.add_editor(
182182- |id| Box::new(TodoEditor::new(id)),
183183- Some(&|id, project| {
184184- let key = id.to_string();
185185- let data = project.tabs().get_or_create(&key);
186186- let _ = data.items();
236236+ |id| Box::new(MapEditor::new(id, scene_key)),
237237+ Some(&move |_id, project| {
238238+ let scene = project.scenes().get_or_create(&key);
239239+ let _ = scene.model_tree();
187240 project.doc().set_next_commit_origin("meta");
188241 project.doc().commit();
189242 }),
190243 );
191244 }
192245193193- fn add_map_editor(&mut self) {
194194- self.add_editor(
195195- |id| Box::new(MapEditor::new(id)),
196196- Some(&|_id, project| {
197197- let tree = project.map_model();
198198-199199- // Ground plane: two green triangles at y=0
200200- let (_ground_id, ground) = tree.create_root();
201201- ground.set_name("Ground");
202202- let green = (80, 140, 80);
203203- add_triangle(
204204- &ground,
205205- (-300.0, 0.0, -300.0),
206206- (-300.0, 0.0, 300.0),
207207- (300.0, 0.0, 300.0),
208208- green,
209209- );
210210- add_triangle(
211211- &ground,
212212- (-300.0, 0.0, -300.0),
213213- (300.0, 0.0, 300.0),
214214- (300.0, 0.0, -300.0),
215215- green,
216216- );
217217-218218- // Colored cube: 12 triangles (2 per face), half-size = 75
219219- let (_cube_id, cube) = tree.create_root();
220220- cube.set_name("Cube");
221221- let s = 75.0;
222222-223223- // Front (z=+s): red
224224- let c = (220, 60, 60);
225225- add_triangle(&cube, (-s, -s, s), (s, -s, s), (s, s, s), c);
226226- add_triangle(&cube, (-s, -s, s), (s, s, s), (-s, s, s), c);
227227-228228- // Back (z=-s): green
229229- let c = (60, 180, 60);
230230- add_triangle(&cube, (s, -s, -s), (-s, -s, -s), (-s, s, -s), c);
231231- add_triangle(&cube, (s, -s, -s), (-s, s, -s), (s, s, -s), c);
232232-233233- // Top (y=+s): blue
234234- let c = (60, 60, 220);
235235- add_triangle(&cube, (-s, s, s), (s, s, s), (s, s, -s), c);
236236- add_triangle(&cube, (-s, s, s), (s, s, -s), (-s, s, -s), c);
237237-238238- // Bottom (y=-s): yellow
239239- let c = (220, 220, 60);
240240- add_triangle(&cube, (-s, -s, -s), (s, -s, -s), (s, -s, s), c);
241241- add_triangle(&cube, (-s, -s, -s), (s, -s, s), (-s, -s, s), c);
246246+ fn import_map_from_file(&mut self) {
247247+ let Some(path) = rfd::FileDialog::new()
248248+ .add_filter("PM64 Map XML", &["xml"])
249249+ .pick_file()
250250+ else {
251251+ return;
252252+ };
242253243243- // Right (x=+s): cyan
244244- let c = (60, 220, 220);
245245- add_triangle(&cube, (s, -s, s), (s, -s, -s), (s, s, -s), c);
246246- add_triangle(&cube, (s, -s, s), (s, s, -s), (s, s, s), c);
254254+ let xml = match std::fs::read_to_string(&path) {
255255+ Ok(s) => s,
256256+ Err(e) => {
257257+ tracing::error!("failed to read {path:?}: {e:?}");
258258+ return;
259259+ }
260260+ };
247261248248- // Left (x=-s): magenta
249249- let c = (220, 60, 220);
250250- add_triangle(&cube, (-s, -s, -s), (-s, -s, s), (-s, s, s), c);
251251- add_triangle(&cube, (-s, -s, -s), (-s, s, s), (-s, s, -s), c);
262262+ let scene_key = path.file_stem().map_or_else(
263263+ || "imported".to_owned(),
264264+ |s| s.to_string_lossy().into_owned(),
265265+ );
252266267267+ let key = scene_key.clone();
268268+ self.add_editor(
269269+ |id| Box::new(MapEditor::new(id, scene_key)),
270270+ Some(&move |_id, project| {
271271+ let scene = project.scenes().get_or_create(&key);
272272+ if let Err(e) = star_rod_interop::import_map_xml(&xml, &scene) {
273273+ tracing::error!("import error: {e:?}");
274274+ }
253275 project.doc().set_next_commit_origin("meta");
254276 project.doc().commit();
255277 }),
256278 );
279279+280280+ tracing::info!("imported map from {path:?}");
257281 }
258282259283 /// Returns the [`EditorId`] for the active document, if any.
···340364341365 ui.separator();
342366343343- if ui.button("+ Todo").clicked() {
344344- self.add_todo_editor();
345345- }
346367 if ui.button("+ Map").clicked() {
347347- self.add_map_editor();
368368+ self.add_map_editor("untitled".to_owned());
369369+ }
370370+ if ui.button("Import").clicked() {
371371+ self.import_map_from_file();
348372 }
349373 });
350374 });
···353377 fn status_bar_ui(&mut self, ctx: &egui::Context) {
354378 egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
355379 ui.horizontal(|ui| {
356356- // Left zone: left dock tool icons
357380 self.left_dock.status_bar_icons(ui);
358381359382 ui.separator();
360383361361- // Center zone: bottom dock tool icons
362384 self.bottom_dock.status_bar_icons(ui);
363385364364- // Right zone: right dock tool icons + FPS (right-aligned)
365386 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
366387 let fps = 1.0 / ctx.input(|i| i.stable_dt).max(f32::EPSILON);
367388 ui.weak(format!("{fps:.0} FPS"));
···448469 let Some(old_active) = old_active else {
449470 continue;
450471 };
451451- // Only fix up if the previously active tab was removed
452472 if tabs.children.contains(old_active) {
453473 continue;
454474 }
···471491}
472492473493/// Sets the position and color of a CRDT vertex accessor.
474474-fn set_vertex(v: &pm64::model::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i64) {
494494+fn set_vertex(v: &project::Vertex, x: f64, y: f64, z: f64, r: i64, g: i64, b: i64) {
475495 v.set_x(x);
476496 v.set_y(y);
477497 v.set_z(z);
···484504485505/// Adds a single colored triangle to a model node.
486506fn add_triangle(
487487- node: &pm64::model::ModelNode,
507507+ node: &project::ModelNode,
488508 p0: (f64, f64, f64),
489509 p1: (f64, f64, f64),
490510 p2: (f64, f64, f64),
+1-2
crates/kammy/src/editor.rs
···55//! Editor trait, built-in editor implementations, and tile-tree dispatch.
6677pub mod map;
88-pub mod todo;
98109use std::cell::Cell;
1110use std::collections::HashMap;
···13121413use tracing::debug;
15141616-use crate::Project;
1715use crate::gpu::GpuState;
1616+use project::Project;
18171918/// Trait for objects that provide property-editing UI in the Inspector panel.
2019///
+16-11
crates/kammy/src/editor/map.rs
···1111pub mod camera;
12121313use pm64::gbi::{NodeData, TriangleData, VertexData};
1414-use pm64::model::ModelNode;
1414+use project::{ModelNode, Project};
15151616use super::{Editor, EditorContext, EditorId};
1717-use crate::Project;
1817use crate::widget::n64_viewport::N64Viewport;
19182019const FB_WIDTH: u32 = 320;
···2524/// Editor that renders PM64 map geometry via the N64 RSP + RDP pipeline.
2625pub struct MapEditor {
2726 id: EditorId,
2727+ /// Key into `project.scenes()` for the scene this editor displays.
2828+ scene_key: String,
2829 viewport: N64Viewport,
2930 camera: camera::OrbitCamera,
3031}
···3334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3435 f.debug_struct("MapEditor")
3536 .field("id", &self.id)
3737+ .field("scene_key", &self.scene_key)
3638 .finish_non_exhaustive()
3739 }
3840}
39414042impl MapEditor {
4141- /// Creates a new map editor with the given stable ID.
4242- pub fn new(id: EditorId) -> Self {
4343+ /// Creates a new map editor for the given scene key.
4444+ pub fn new(id: EditorId, scene_key: String) -> Self {
4345 Self {
4446 id,
4747+ scene_key,
4548 viewport: N64Viewport::new(4 * 1024 * 1024),
4649 camera: camera::OrbitCamera::default(),
4750 }
···5457 }
55585659 fn title(&self) -> String {
5757- "Map".to_owned()
6060+ self.scene_key.clone()
5861 }
59626063 fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) {
6161- // Handle camera input first so this frame's drag is reflected immediately.
6264 let interact_rect = ui.available_rect_before_wrap();
6365 let interact_response = ui.interact(
6466 interact_rect,
···6769 );
6870 self.camera.handle_input(&interact_response);
69717070- let nodes = extract_nodes(ctx.project);
7272+ let nodes = extract_nodes(ctx.project, &self.scene_key);
7173 #[expect(
7274 clippy::cast_possible_truncation,
7375 clippy::as_conversions,
···9092 }
9193}
92949393-/// Extracts all model nodes from the project's CRDT tree into plain render data.
9494-fn extract_nodes(project: &Project) -> Vec<NodeData> {
9595- let tree = project.map_model();
9595+/// Extracts all model nodes from the given scene's model tree into plain render data.
9696+fn extract_nodes(project: &Project, scene_key: &str) -> Vec<NodeData> {
9797+ let Some(scene) = project.scenes().get(scene_key) else {
9898+ return Vec::new();
9999+ };
100100+ let tree = scene.model_tree();
96101 let mut nodes = Vec::new();
97102 for root_id in tree.roots() {
98103 collect_node(&tree, root_id, &mut nodes);
···130135 clippy::as_conversions,
131136 reason = "vertex coords are small integers that fit in i16/u8"
132137)]
133133-fn extract_vertex(v: &pm64::model::Vertex) -> VertexData {
138138+fn extract_vertex(v: &project::Vertex) -> VertexData {
134139 let c = v.color();
135140 VertexData {
136141 x: v.x() as i16,
-110
crates/kammy/src/editor/todo.rs
···11-// SPDX-FileCopyrightText: 2026 Alex Bates <alex@bates64.com>
22-//
33-// SPDX-License-Identifier: AGPL-3.0-or-later
44-55-//! Todo-list editor.
66-77-use super::{Editor, EditorContext, EditorId};
88-99-/// A simple todo-list editor.
1010-#[derive(Debug)]
1111-pub struct TodoEditor {
1212- id: EditorId,
1313- new_todo_text: String,
1414-}
1515-1616-impl TodoEditor {
1717- /// Creates a new empty todo editor with the given stable ID.
1818- pub fn new(id: EditorId) -> Self {
1919- Self {
2020- id,
2121- new_todo_text: String::new(),
2222- }
2323- }
2424-}
2525-2626-impl Editor for TodoEditor {
2727- fn id(&self) -> EditorId {
2828- self.id
2929- }
3030-3131- fn title(&self) -> String {
3232- subsecond::call(|| "Todos".to_owned())
3333- }
3434-3535- fn ui(&mut self, ui: &mut egui::Ui, ctx: &mut EditorContext) {
3636- let key = self.id.to_string();
3737- let tab_data = ctx.project.tabs().get_or_create(&key);
3838-3939- ui.horizontal(|ui| {
4040- let response = ui.text_edit_singleline(&mut self.new_todo_text);
4141- let submitted = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
4242- if (ui.button("Add").clicked() || submitted) && !self.new_todo_text.is_empty() {
4343- let item = tab_data.items().push_new();
4444- item.set_title(&self.new_todo_text);
4545- self.new_todo_text.clear();
4646- response.request_focus();
4747- }
4848- });
4949-5050- ui.separator();
5151-5252- let items = tab_data.items();
5353- let len = items.len();
5454-5555- if len == 0 {
5656- ui.vertical_centered(|ui| {
5757- ui.add_space(20.0);
5858- ui.label("No todos yet!");
5959- });
6060- return;
6161- }
6262-6363- let done_count = (0..len)
6464- .filter(|&i| items.get(i).is_some_and(|item| item.done()))
6565- .count();
6666- ui.label(format!("{done_count}/{len} completed"));
6767- ui.add_space(4.0);
6868-6969- let mut to_delete = None;
7070-7171- egui::ScrollArea::vertical().show(ui, |ui| {
7272- for i in 0..len {
7373- if let Some(item) = items.get(i) {
7474- egui::Frame::NONE.inner_margin(4.0).show(ui, |ui| {
7575- ui.horizontal(|ui| {
7676- let mut done = item.done();
7777- if ui.checkbox(&mut done, "").changed() {
7878- item.set_done(done);
7979- }
8080-8181- let title = item.title();
8282- if done {
8383- ui.label(
8484- egui::RichText::new(&title)
8585- .strikethrough()
8686- .color(ui.visuals().weak_text_color()),
8787- );
8888- } else {
8989- ui.label(&title);
9090- }
9191-9292- ui.with_layout(
9393- egui::Layout::right_to_left(egui::Align::Center),
9494- |ui| {
9595- if ui.button("\u{1f5d1}").clicked() {
9696- to_delete = Some(i);
9797- }
9898- },
9999- );
100100- });
101101- });
102102- }
103103- }
104104- });
105105-106106- if let Some(i) = to_delete {
107107- items.delete(i, 1);
108108- }
109109- }
110110-}
-25
crates/kammy/src/main.rs
···1717use std::sync::Arc;
18181919use egui::ViewportId;
2020-use loroscope::loroscope;
2121-use pm64::model::ModelNode;
2220use tracing_subscriber::EnvFilter;
2321use winit::application::ApplicationHandler;
2422use winit::event::WindowEvent;
2523use winit::event_loop::{ActiveEventLoop, EventLoop};
2624use winit::window::{Window, WindowAttributes, WindowId};
2727-2828-/// A single todo item with a title and completion status.
2929-#[loroscope]
3030-#[derive(Debug)]
3131-pub struct TodoItem {
3232- pub title: String,
3333- pub done: bool,
3434-}
3535-3636-/// Data for a single editor tab (a list of todo items).
3737-#[loroscope]
3838-#[derive(Debug)]
3939-pub struct TabData {
4040- pub items: List<TodoItem>,
4141-}
4242-4343-/// Root project data, holding all tabs keyed by tile ID.
4444-#[loroscope]
4545-#[derive(Debug)]
4646-pub struct Project {
4747- pub tabs: Map<TabData>,
4848- pub map_model: Tree<ModelNode>,
4949-}
50255126/// Application wrapper that implements [`winit::application::ApplicationHandler`].
5227struct WinitApp {
+4-5
crates/kammy/src/tests/map.rs
···5858 const FB_HEIGHT: u32 = 240;
5959 const FB_ORIGIN: u32 = 0x0000_0100;
60606161- // Create a Project with a red triangle in the CRDT tree
6262- let project = crate::Project::new();
6363- let tree = project.map_model();
6161+ // Create a Project with a red triangle in a scene's model tree
6262+ let project = project::Project::new();
6363+ let scene = project.scenes().get_or_create("test");
6464+ let tree = scene.model_tree();
6465 let (_node_id, node) = tree.create_root();
6566 node.set_name("test");
6667···141142 let (w, h) = (w as usize, h as usize);
142143143144 // Check the center pixel is red-ish (RGBA8).
144144- // The VI filtering and N64 color format conversion mean exact values vary,
145145- // but a red vertex-colored triangle should produce clearly red pixels.
146145 let idx = (h / 2 * w + w / 2) * 4;
147146 let (r, g, b) = (buffer[idx], buffer[idx + 1], buffer[idx + 2]);
148147
+7-29
crates/kammy/src/tests/undo.rs
···6677use egui_kittest::kittest::Queryable;
8899-/// Helper: type text into the todo input and click Add.
1010-fn add_todo(harness: &mut egui_kittest::Harness<'_>, text: &str) {
1111- harness
1212- .get_by_role(egui::accesskit::Role::TextInput)
1313- .click();
1414- harness.run();
1515- harness
1616- .get_by_role(egui::accesskit::Role::TextInput)
1717- .type_text(text);
1818- harness.get_by_label("Add").click();
1919- harness.run();
2020-}
2121-229#[test]
2323-fn single_tab_undo_redo() {
1010+fn undo_redo_buttons_present() {
2411 let mut harness = super::make_harness();
2525-2626- add_todo(&mut harness, "buy milk");
2727- assert!(
2828- harness.query_by_label("buy milk").is_some(),
2929- "item should exist after adding"
3030- );
1212+ // Use step() instead of run() because the map editor continuously requests repaint
1313+ harness.step();
31143232- harness.get_by_label("⟲ Undo").click();
3333- harness.run();
3415 assert!(
3535- harness.query_by_label("buy milk").is_none(),
3636- "item should be gone after undo"
1616+ harness.query_by_label("⟲ Undo").is_some(),
1717+ "undo button should be present"
3718 );
3838-3939- harness.get_by_label("⟳ Redo").click();
4040- harness.run();
4119 assert!(
4242- harness.query_by_label("buy milk").is_some(),
4343- "item should reappear after redo"
2020+ harness.query_by_label("⟳ Redo").is_some(),
2121+ "redo button should be present"
4422 );
4523}