notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 2ab57faa36ab81a3709253ed0e9b204bcc9cd15a
parent f768a94d3168637e3aaf11dc2896df24892090d7
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 26 Feb 2026 10:08:47 -0800

Merge dave UI polish, nostrverse tilemap support, and Windows fixes

dave:
- Extract process_events() match arms into standalone functions
- Clamp input box height to prevent overflow off-screen
- Cycle permission mode badge through Default/Plan/AcceptEdits
- Fix context fill bar to use input_tokens only
- Keep Done indicator dot on completed sessions
- Check for .exe extension in PATH lookup on Windows

nostrverse:
- Add tilemap support with texture atlas rendering

Diffstat:
MCargo.lock | 1+
Mcrates/notedeck_dave/src/backend/claude.rs | 6++++++
Mcrates/notedeck_dave/src/config.rs | 5+++++
Mcrates/notedeck_dave/src/lib.rs | 3---
Mcrates/notedeck_dave/src/messages.rs | 6++++--
Mcrates/notedeck_dave/src/session.rs | 8++++++++
Mcrates/notedeck_dave/src/ui/dave.rs | 215+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/notedeck_dave/src/ui/keybindings.rs | 8++++----
Mcrates/notedeck_dave/src/ui/mod.rs | 12++++++------
Mcrates/notedeck_dave/src/update.rs | 43++++++-------------------------------------
Mcrates/notedeck_nostrverse/Cargo.toml | 1+
Mcrates/notedeck_nostrverse/src/convert.rs | 236++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/notedeck_nostrverse/src/lib.rs | 54+++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/notedeck_nostrverse/src/room_state.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_nostrverse/src/room_view.rs | 4++++
Acrates/notedeck_nostrverse/src/tilemap.rs | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protoverse/src/ast.rs | 20++++++++++++++++++++
Mcrates/protoverse/src/describe.rs | 1+
Mcrates/protoverse/src/parser.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/protoverse/src/serializer.rs | 25+++++++++++++++++++++++++
Mcrates/renderbud/src/lib.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/renderbud/src/model.rs | 11+++++------
22 files changed, 803 insertions(+), 230 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4722,6 +4722,7 @@ dependencies = [ name = "notedeck_nostrverse" version = "0.7.1" dependencies = [ + "bytemuck", "egui", "egui-wgpu", "ehttp", diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -377,6 +377,12 @@ async fn session_actor( } // Extract usage metrics + tracing::debug!( + "ResultMessage usage: {:?}, total_cost_usd: {:?}, num_turns: {}", + result_msg.usage, + result_msg.total_cost_usd, + result_msg.num_turns + ); let (input_tokens, output_tokens) = result_msg .usage .as_ref() diff --git a/crates/notedeck_dave/src/config.rs b/crates/notedeck_dave/src/config.rs @@ -8,6 +8,11 @@ pub fn has_binary_on_path(binary: &str) -> bool { env::var_os("PATH") .map(|paths| env::split_paths(&paths).any(|dir| dir.join(binary).is_file())) .unwrap_or(false) + || env::var_os("PATH") + .map(|paths| { + env::split_paths(&paths).any(|dir| dir.join(format!("{}.exe", binary)).is_file()) + }) + .unwrap_or(false) } /// Detect which agentic backends are available based on binaries in PATH. diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -2587,9 +2587,6 @@ impl notedeck::App for Dave { notedeck::platform::try_vibrate(); } - // Clear Done indicators whose condition is met - update::clear_done_indicators(&self.session_manager, &mut self.focus_queue); - // Suppress auto-steal while the user is typing (non-empty input) let user_is_typing = self .session_manager diff --git a/crates/notedeck_dave/src/messages.rs b/crates/notedeck_dave/src/messages.rs @@ -341,8 +341,10 @@ pub struct UsageInfo { } impl UsageInfo { - pub fn total_tokens(&self) -> u64 { - self.input_tokens + self.output_tokens + /// Context window fill: only input tokens consume context space. + /// Output tokens are generated from the context, not part of it. + pub fn context_tokens(&self) -> u64 { + self.input_tokens } } diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -500,6 +500,14 @@ impl ChatSession { .is_some_and(|a| a.permission_mode == PermissionMode::Plan) } + /// Get the current permission mode (defaults to Default for non-agentic) + pub fn permission_mode(&self) -> PermissionMode { + self.agentic + .as_ref() + .map(|a| a.permission_mode) + .unwrap_or(PermissionMode::Default) + } + /// Get the working directory (agentic only) pub fn cwd(&self) -> Option<&PathBuf> { self.agentic.as_ref().map(|a| &a.cwd) diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -18,6 +18,7 @@ use crate::{ tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse, ToolResponses}, }; use bitflags::bitflags; +use claude_agent_sdk_rs::PermissionMode; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; use nostrdb::Transaction; use notedeck::{tr, AppContext, Localization, NoteAction, NoteContext}; @@ -34,10 +35,9 @@ bitflags! { const IsWorking = 1 << 2; const InterruptPending = 1 << 3; const HasPendingPerm = 1 << 4; - const PlanModeActive = 1 << 5; - const IsCompacting = 1 << 6; - const AutoStealFocus = 1 << 7; - const IsRemote = 1 << 8; + const IsCompacting = 1 << 5; + const AutoStealFocus = 1 << 6; + const IsRemote = 1 << 7; } } @@ -72,6 +72,8 @@ pub struct DaveUi<'a> { dispatch_state: crate::session::DispatchState, /// Which backend this session uses backend_type: BackendType, + /// Current permission mode (Default, Plan, AcceptEdits) + permission_mode: PermissionMode, } /// The response the app generates. The response contains an optional @@ -149,8 +151,8 @@ pub enum DaveAction { CompactAndApprove { request_id: Uuid, }, - /// Toggle plan mode (clicked PLAN badge) - TogglePlanMode, + /// Cycle permission mode: Default → Plan → AcceptEdits (clicked mode badge) + CyclePermissionMode, /// Toggle auto-steal focus mode (clicked AUTO badge) ToggleAutoSteal, /// Trigger manual context compaction @@ -188,6 +190,7 @@ impl<'a> DaveUi<'a> { context_window: crate::messages::context_window_for_model(None), dispatch_state: crate::session::DispatchState::default(), backend_type: BackendType::Remote, + permission_mode: PermissionMode::Default, } } @@ -241,8 +244,8 @@ impl<'a> DaveUi<'a> { self } - pub fn plan_mode_active(mut self, val: bool) -> Self { - self.flags.set(DaveUiFlags::PlanModeActive, val); + pub fn permission_mode(mut self, mode: PermissionMode) -> Self { + self.permission_mode = mode; self } @@ -349,7 +352,7 @@ impl<'a> DaveUi<'a> { .inner; { - let plan_mode_active = self.flags.contains(DaveUiFlags::PlanModeActive); + let permission_mode = self.permission_mode; let auto_steal_focus = self.flags.contains(DaveUiFlags::AutoStealFocus); let is_agentic = self.ai_mode == AiMode::Agentic; let has_git = self.git_status.is_some(); @@ -377,7 +380,7 @@ impl<'a> DaveUi<'a> { status_bar_ui( self.git_status.as_deref_mut(), is_agentic, - plan_mode_active, + permission_mode, auto_steal_focus, self.usage, self.context_window, @@ -1194,100 +1197,107 @@ impl<'a> DaveUi<'a> { fn inputbox(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse { let i18n = &mut *app_ctx.i18n; - //ui.add_space(Self::chat_margin(ui.ctx()) as f32); - ui.horizontal(|ui| { - ui.with_layout(Layout::right_to_left(Align::Max), |ui| { - let mut dave_response = DaveResponse::none(); - - // Always show Ask button (messages queue while working) - if ui - .add( - egui::Button::new(tr!( - i18n, - "Ask", - "Button to send message to Dave AI assistant" - )) - .min_size(egui::vec2(60.0, 44.0)), - ) - .clicked() - { - dave_response = DaveResponse::send(); - } + // Constrain input height based on line count (min 1, max 8 lines) + let line_count = self.input.lines().count().max(1).clamp(1, 8); + let line_height = 20.0; + let base_height = 44.0; + let input_height = base_height + (line_count as f32 * line_height); + ui.allocate_ui(egui::vec2(ui.available_width(), input_height), |ui| { + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Max), |ui| { + let mut dave_response = DaveResponse::none(); - // Show Stop button alongside Ask for local working sessions - if self.flags.contains(DaveUiFlags::IsWorking) - && !self.flags.contains(DaveUiFlags::IsRemote) - { + // Always show Ask button (messages queue while working) if ui .add( egui::Button::new(tr!( i18n, - "Stop", - "Button to interrupt/stop the AI operation" + "Ask", + "Button to send message to Dave AI assistant" )) .min_size(egui::vec2(60.0, 44.0)), ) .clicked() { - dave_response = DaveResponse::new(DaveAction::Interrupt); + dave_response = DaveResponse::send(); } - // Show "Press Esc again" indicator when interrupt is pending - if self.flags.contains(DaveUiFlags::InterruptPending) { - ui.label( - egui::RichText::new("Press Esc again to stop") - .color(ui.visuals().warn_fg_color), - ); + // Show Stop button alongside Ask for local working sessions + if self.flags.contains(DaveUiFlags::IsWorking) + && !self.flags.contains(DaveUiFlags::IsRemote) + { + if ui + .add( + egui::Button::new(tr!( + i18n, + "Stop", + "Button to interrupt/stop the AI operation" + )) + .min_size(egui::vec2(60.0, 44.0)), + ) + .clicked() + { + dave_response = DaveResponse::new(DaveAction::Interrupt); + } + + // Show "Press Esc again" indicator when interrupt is pending + if self.flags.contains(DaveUiFlags::InterruptPending) { + ui.label( + egui::RichText::new("Press Esc again to stop") + .color(ui.visuals().warn_fg_color), + ); + } } - } - let r = ui.add( - egui::TextEdit::multiline(self.input) - .desired_width(f32::INFINITY) - .return_key(KeyboardShortcut::new( - Modifiers { - shift: true, - ..Default::default() - }, - Key::Enter, - )) - .hint_text( - egui::RichText::new(tr!( - i18n, - "Ask dave anything...", - "Placeholder text for Dave AI input field" + let r = ui.add( + egui::TextEdit::multiline(self.input) + .desired_width(f32::INFINITY) + .return_key(KeyboardShortcut::new( + Modifiers { + shift: true, + ..Default::default() + }, + Key::Enter, )) - .weak(), - ) - .frame(false), - ); - notedeck_ui::context_menu::input_context( - ui, - &r, - app_ctx.clipboard, - self.input, - notedeck_ui::context_menu::PasteBehavior::Append, - ); + .hint_text( + egui::RichText::new(tr!( + i18n, + "Ask dave anything...", + "Placeholder text for Dave AI input field" + )) + .weak(), + ) + .frame(false), + ); + notedeck_ui::context_menu::input_context( + ui, + &r, + app_ctx.clipboard, + self.input, + notedeck_ui::context_menu::PasteBehavior::Append, + ); - // Request focus if flagged (e.g., after spawning a new agent or entering tentative state) - if *self.focus_requested { - r.request_focus(); - *self.focus_requested = false; - } + // Request focus if flagged (e.g., after spawning a new agent or entering tentative state) + if *self.focus_requested { + r.request_focus(); + *self.focus_requested = false; + } - // Unfocus text input when there's a pending permission request - // UNLESS we're in tentative state (user needs to type message) - let in_tentative_state = - self.permission_message_state != PermissionMessageState::None; - if self.flags.contains(DaveUiFlags::HasPendingPerm) && !in_tentative_state { - r.surrender_focus(); - } + // Unfocus text input when there's a pending permission request + // UNLESS we're in tentative state (user needs to type message) + let in_tentative_state = + self.permission_message_state != PermissionMessageState::None; + if self.flags.contains(DaveUiFlags::HasPendingPerm) && !in_tentative_state { + r.surrender_focus(); + } - if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - DaveResponse::send() - } else { - dave_response - } + if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + DaveResponse::send() + } else { + dave_response + } + }) + .inner }) .inner }) @@ -1404,7 +1414,7 @@ fn add_msg_link(ui: &mut egui::Ui, shift_held: bool, action: &mut Option<DaveAct fn status_bar_ui( mut git_status: Option<&mut GitStatusCache>, is_agentic: bool, - plan_mode_active: bool, + permission_mode: PermissionMode, auto_steal_focus: bool, usage: Option<&crate::messages::UsageInfo>, context_window: u64, @@ -1425,7 +1435,7 @@ fn status_bar_ui( // Right-aligned section: usage bar, badges, then refresh ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let badge_action = if is_agentic { - toggle_badges_ui(ui, plan_mode_active, auto_steal_focus) + toggle_badges_ui(ui, permission_mode, auto_steal_focus) } else { None }; @@ -1438,7 +1448,7 @@ fn status_bar_ui( } else if is_agentic { // No git status (remote session) - just show badges and usage ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let badge_action = toggle_badges_ui(ui, plan_mode_active, auto_steal_focus); + let badge_action = toggle_badges_ui(ui, permission_mode, auto_steal_focus); usage_bar_ui(usage, context_window, ui); badge_action }) @@ -1475,7 +1485,7 @@ fn usage_bar_ui( context_window: u64, ui: &mut egui::Ui, ) { - let total = usage.map(|u| u.total_tokens()).unwrap_or(0); + let total = usage.map(|u| u.context_tokens()).unwrap_or(0); if total == 0 { return; } @@ -1530,10 +1540,10 @@ fn usage_bar_ui( painter.rect_filled(fill_rect, 3.0, bar_color); } -/// Render clickable PLAN and AUTO toggle badges. Returns an action if clicked. +/// Render clickable permission mode and AUTO toggle badges. Returns an action if clicked. fn toggle_badges_ui( ui: &mut egui::Ui, - plan_mode_active: bool, + permission_mode: PermissionMode, auto_steal_focus: bool, ) -> Option<DaveAction> { let ctrl_held = ui.input(|i| i.modifiers.ctrl); @@ -1556,21 +1566,22 @@ fn toggle_badges_ui( action = Some(DaveAction::ToggleAutoSteal); } - // PLAN badge - let mut plan_badge = super::badge::StatusBadge::new("PLAN").variant(if plan_mode_active { - super::badge::BadgeVariant::Info - } else { - super::badge::BadgeVariant::Default - }); + // Permission mode badge: cycles Default → Plan → AcceptEdits + let (label, variant) = match permission_mode { + PermissionMode::Plan => ("PLAN", BadgeVariant::Info), + PermissionMode::AcceptEdits => ("AUTO EDIT", BadgeVariant::Warning), + _ => ("PLAN", BadgeVariant::Default), + }; + let mut mode_badge = StatusBadge::new(label).variant(variant); if ctrl_held { - plan_badge = plan_badge.keybind("M"); + mode_badge = mode_badge.keybind("M"); } - if plan_badge + if mode_badge .show(ui) - .on_hover_text("Click or Ctrl+M to toggle plan mode") + .on_hover_text("Click or Ctrl+M to cycle: Default → Plan → Auto Edit") .clicked() { - action = Some(DaveAction::TogglePlanMode); + action = Some(DaveAction::CyclePermissionMode); } // COMPACT badge diff --git a/crates/notedeck_dave/src/ui/keybindings.rs b/crates/notedeck_dave/src/ui/keybindings.rs @@ -26,8 +26,8 @@ pub enum KeyAction { Interrupt, /// Toggle between scene view and classic view ToggleView, - /// Toggle plan mode for the active session (Ctrl+M) - TogglePlanMode, + /// Cycle permission mode: Default → Plan → AcceptEdits (Ctrl+M) + CyclePermissionMode, /// Delete the active session DeleteActiveSession, /// Navigate to next item in focus queue (Ctrl+N) @@ -119,9 +119,9 @@ pub fn check_keybindings( return Some(KeyAction::OpenExternalEditor); } - // Ctrl+M to toggle plan mode - agentic only + // Ctrl+M to cycle permission mode - agentic only if is_agentic && ctx.input(|i| i.modifiers.matches_exact(ctrl) && i.key_pressed(Key::M)) { - return Some(KeyAction::TogglePlanMode); + return Some(KeyAction::CyclePermissionMode); } // Ctrl+D to toggle Done status for current focus queue item - agentic only diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -48,7 +48,7 @@ fn build_dave_ui<'a>( ) -> DaveUi<'a> { let is_working = session.status() == AgentStatus::Working; let has_pending_permission = session.has_pending_permissions(); - let plan_mode_active = session.is_plan_mode(); + let permission_mode = session.permission_mode(); let is_remote = session.is_remote(); let mut ui_builder = DaveUi::new( @@ -62,7 +62,7 @@ fn build_dave_ui<'a>( .is_working(is_working) .interrupt_pending(is_interrupt_pending) .has_pending_permission(has_pending_permission) - .plan_mode_active(plan_mode_active) + .permission_mode(permission_mode) .auto_steal_focus(auto_steal_focus) .is_remote(is_remote) .dispatch_state(session.dispatch_state) @@ -642,8 +642,8 @@ pub fn handle_key_action( KeyAction::CloneAgent => KeyActionResult::CloneAgent, KeyAction::Interrupt => KeyActionResult::HandleInterrupt, KeyAction::ToggleView => KeyActionResult::ToggleView, - KeyAction::TogglePlanMode => { - update::toggle_plan_mode(session_manager, backend, ctx); + KeyAction::CyclePermissionMode => { + update::cycle_permission_mode(session_manager, backend, ctx); if let Some(session) = session_manager.get_active_mut() { session.focus_requested = true; } @@ -826,8 +826,8 @@ pub fn handle_ui_action( UiActionResult::Handled, UiActionResult::PublishPermissionResponse, ), - DaveAction::TogglePlanMode => { - update::toggle_plan_mode(session_manager, backend, ctx); + DaveAction::CyclePermissionMode => { + update::cycle_permission_mode(session_manager, backend, ctx); if let Some(session) = session_manager.get_active_mut() { session.focus_requested = true; } diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs @@ -90,8 +90,8 @@ pub fn check_interrupt_timeout(pending_since: Option<Instant>) -> Option<Instant // Plan Mode // ============================================================================= -/// Toggle plan mode for the active session. -pub fn toggle_plan_mode( +/// Cycle permission mode for the active session: Default → Plan → AcceptEdits → Default. +pub fn cycle_permission_mode( session_manager: &mut SessionManager, backend: &dyn AiBackend, ctx: &egui::Context, @@ -99,8 +99,9 @@ pub fn toggle_plan_mode( if let Some(session) = session_manager.get_active_mut() { if let Some(agentic) = &mut session.agentic { let new_mode = match agentic.permission_mode { - PermissionMode::Plan => PermissionMode::Default, - _ => PermissionMode::Plan, + PermissionMode::Default => PermissionMode::Plan, + PermissionMode::Plan => PermissionMode::AcceptEdits, + _ => PermissionMode::Default, }; agentic.permission_mode = new_mode; @@ -108,7 +109,7 @@ pub fn toggle_plan_mode( backend.set_permission_mode(session_id, new_mode, ctx.clone()); tracing::debug!( - "Toggled plan mode for session {} to {:?}", + "Cycled permission mode for session {} to {:?}", session.id, new_mode ); @@ -533,38 +534,6 @@ pub fn toggle_auto_steal( new_state } -/// Clear Done indicators for sessions whose clearing condition is met. -/// -/// - **Local agentic sessions**: cleared when the git working tree is clean -/// (the user has committed or reverted changes). -/// - **Chat and remote sessions**: cleared when the session is the active one -/// (the user is viewing it). -pub fn clear_done_indicators(session_manager: &SessionManager, focus_queue: &mut FocusQueue) { - let active_id = session_manager.active_id(); - for session in session_manager.iter() { - if focus_queue.get_session_priority(session.id) != Some(FocusPriority::Done) { - continue; - } - let should_clear = if !session.is_remote() { - if let Some(agentic) = &session.agentic { - agentic - .git_status - .current() - .is_some_and(|r| r.as_ref().is_ok_and(|d| d.is_clean())) - } else { - // Chat session: clear when viewing - active_id == Some(session.id) - } - } else { - // Remote session: clear when viewing - active_id == Some(session.id) - }; - if should_clear { - focus_queue.dequeue_done(session.id); - } - } -} - /// Process auto-steal focus logic: switch to focus queue items as needed. /// Returns true if focus was stolen (switched to a NeedsInput or Done session), /// which can be used to raise the OS window. diff --git a/crates/notedeck_nostrverse/Cargo.toml b/crates/notedeck_nostrverse/Cargo.toml @@ -17,3 +17,4 @@ uuid = { workspace = true } ehttp = { workspace = true } sha2 = { workspace = true } poll-promise = { workspace = true } +bytemuck = { workspace = true } diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs @@ -1,20 +1,27 @@ //! Convert protoverse Space AST to renderer space state. -use crate::room_state::{ObjectLocation, RoomObject, RoomObjectType, SpaceInfo}; +use crate::room_state::{ + ObjectLocation, RoomObject, RoomObjectType, SpaceData, SpaceInfo, TilemapData, +}; use glam::{Quat, Vec3}; use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Space}; -/// Convert a parsed protoverse Space into a SpaceInfo and its objects. -pub fn convert_space(space: &Space) -> (SpaceInfo, Vec<RoomObject>) { - let info = extract_space_info(space, space.root); +/// Convert a parsed protoverse Space into a SpaceData (info + objects). +pub fn convert_space(space: &Space) -> SpaceData { + let mut info = extract_space_info(space, space.root); let mut objects = Vec::new(); - collect_objects(space, space.root, &mut objects); - (info, objects) + let mut tilemap = None; + collect_objects(space, space.root, &mut objects, &mut tilemap); + info.tilemap = tilemap; + SpaceData { info, objects } } fn extract_space_info(space: &Space, id: CellId) -> SpaceInfo { let name = space.name(id).unwrap_or("Untitled Space").to_string(); - SpaceInfo { name } + SpaceInfo { + name, + tilemap: None, + } } fn location_from_protoverse(loc: &Location) -> ObjectLocation { @@ -50,10 +57,32 @@ fn object_type_from_cell(obj_type: &ObjectType) -> RoomObjectType { } } -fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { +fn collect_objects( + space: &Space, + id: CellId, + objects: &mut Vec<RoomObject>, + tilemap: &mut Option<TilemapData>, +) { let cell = space.cell(id); - if let CellType::Object(ref obj_type) = cell.cell_type { + if cell.cell_type == CellType::Tilemap { + let width = space.width(id).unwrap_or(10.0) as u32; + let height = space.height(id).unwrap_or(10.0) as u32; + let tileset = space + .tileset(id) + .cloned() + .unwrap_or_else(|| vec!["grass".to_string()]); + let data_str = space.data(id).unwrap_or("0"); + let tiles = TilemapData::decode_data(data_str); + *tilemap = Some(TilemapData { + width, + height, + tileset, + tiles, + scene_object_id: None, + model_handle: None, + }); + } else if let CellType::Object(ref obj_type) = cell.cell_type { let obj_id = space.id_str(id).unwrap_or("").to_string(); // Generate a fallback id if none specified @@ -101,14 +130,15 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) { // Recurse into children for &child_id in space.children(id) { - collect_objects(space, child_id, objects); + collect_objects(space, child_id, objects, tilemap); } } -/// Build a protoverse Space from SpaceInfo and objects (reverse of convert_space). +/// Build a protoverse Space from SpaceInfo and objects. /// -/// Produces: (space (name ...) (group <objects...>)) +/// Produces: (space (name ...) (group [tilemap] <objects...>)) pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { + let tilemap = info.tilemap.as_ref(); let mut cells = Vec::new(); let mut attributes = Vec::new(); let mut child_ids = Vec::new(); @@ -130,21 +160,31 @@ pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { parent: None, }); - // Group cell (index 1), children = objects at indices 2.. + // Group cell (index 1), children start at index 2 + let tilemap_offset: u32 = if tilemap.is_some() { 1 } else { 0 }; let group_child_start = child_ids.len() as u32; + if tilemap.is_some() { + child_ids.push(CellId(2)); + } for i in 0..objects.len() { - child_ids.push(CellId(2 + i as u32)); + child_ids.push(CellId(2 + tilemap_offset + i as u32)); } + let total_children = tilemap_offset as u16 + objects.len() as u16; cells.push(Cell { cell_type: CellType::Group, first_attr: attributes.len() as u32, attr_count: 0, first_child: group_child_start, - child_count: objects.len() as u16, + child_count: total_children, parent: Some(CellId(0)), }); - // Object cells (indices 2..) + // Tilemap cell (index 2, if present) + if let Some(tm) = tilemap { + build_tilemap_cell(tm, &mut cells, &mut attributes, &child_ids); + } + + // Object cells for obj in objects { build_object_cell(obj, &mut cells, &mut attributes, &child_ids); } @@ -157,6 +197,28 @@ pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { } } +fn build_tilemap_cell( + tm: &TilemapData, + cells: &mut Vec<Cell>, + attributes: &mut Vec<Attribute>, + child_ids: &[CellId], +) { + let attr_start = attributes.len() as u32; + attributes.push(Attribute::Width(tm.width as f64)); + attributes.push(Attribute::Height(tm.height as f64)); + attributes.push(Attribute::Tileset(tm.tileset.clone())); + attributes.push(Attribute::Data(tm.encode_data())); + + cells.push(Cell { + cell_type: CellType::Tilemap, + first_attr: attr_start, + attr_count: (attributes.len() as u32 - attr_start) as u16, + first_child: child_ids.len() as u32, + child_count: 0, + parent: Some(CellId(1)), + }); +} + fn object_type_to_cell(obj_type: &RoomObjectType) -> CellType { CellType::Object(match obj_type { RoomObjectType::Table => ObjectType::Table, @@ -234,21 +296,21 @@ mod tests { ) .unwrap(); - let (info, objects) = convert_space(&space); + let data = convert_space(&space); - assert_eq!(info.name, "Test Room"); + assert_eq!(data.info.name, "Test Room"); - assert_eq!(objects.len(), 2); + assert_eq!(data.objects.len(), 2); - assert_eq!(objects[0].id, "desk"); - assert_eq!(objects[0].name, "My Desk"); - assert_eq!(objects[0].position, Vec3::new(1.0, 0.0, 2.0)); - assert!(matches!(objects[0].object_type, RoomObjectType::Table)); + assert_eq!(data.objects[0].id, "desk"); + assert_eq!(data.objects[0].name, "My Desk"); + assert_eq!(data.objects[0].position, Vec3::new(1.0, 0.0, 2.0)); + assert!(matches!(data.objects[0].object_type, RoomObjectType::Table)); - assert_eq!(objects[1].id, "chair1"); - assert_eq!(objects[1].name, "Office Chair"); - assert_eq!(objects[1].position, Vec3::ZERO); - assert!(matches!(objects[1].object_type, RoomObjectType::Chair)); + assert_eq!(data.objects[1].id, "chair1"); + assert_eq!(data.objects[1].name, "Office Chair"); + assert_eq!(data.objects[1].position, Vec3::ZERO); + assert!(matches!(data.objects[1].object_type, RoomObjectType::Chair)); } #[test] @@ -262,9 +324,12 @@ mod tests { ) .unwrap(); - let (_, objects) = convert_space(&space); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].model_url.as_deref(), Some("/models/table.glb")); + let data = convert_space(&space); + assert_eq!(data.objects.len(), 1); + assert_eq!( + data.objects[0].model_url.as_deref(), + Some("/models/table.glb") + ); } #[test] @@ -276,16 +341,17 @@ mod tests { ) .unwrap(); - let (_, objects) = convert_space(&space); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].id, "p1"); - assert_eq!(objects[0].name, "Water Bottle"); + let data = convert_space(&space); + assert_eq!(data.objects.len(), 1); + assert_eq!(data.objects[0].id, "p1"); + assert_eq!(data.objects[0].name, "Water Bottle"); } #[test] fn test_build_space_roundtrip() { let info = SpaceInfo { name: "My Space".to_string(), + tilemap: None, }; let objects = vec![ RoomObject::new( @@ -306,29 +372,32 @@ mod tests { let reparsed = parse(&serialized).unwrap(); // Convert back - let (info2, objects2) = convert_space(&reparsed); + let data = convert_space(&reparsed); - assert_eq!(info2.name, "My Space"); + assert_eq!(data.info.name, "My Space"); - assert_eq!(objects2.len(), 2); - assert_eq!(objects2[0].id, "desk"); - assert_eq!(objects2[0].name, "Office Desk"); - assert_eq!(objects2[0].model_url.as_deref(), Some("/models/desk.glb")); - assert_eq!(objects2[0].position, Vec3::new(2.0, 0.0, 3.0)); - assert!(matches!(objects2[0].object_type, RoomObjectType::Table)); + assert_eq!(data.objects.len(), 2); + assert_eq!(data.objects[0].id, "desk"); + assert_eq!(data.objects[0].name, "Office Desk"); + assert_eq!( + data.objects[0].model_url.as_deref(), + Some("/models/desk.glb") + ); + assert_eq!(data.objects[0].position, Vec3::new(2.0, 0.0, 3.0)); + assert!(matches!(data.objects[0].object_type, RoomObjectType::Table)); - assert_eq!(objects2[1].id, "lamp"); - assert_eq!(objects2[1].name, "Floor Lamp"); - assert!(matches!(objects2[1].object_type, RoomObjectType::Light)); + assert_eq!(data.objects[1].id, "lamp"); + assert_eq!(data.objects[1].name, "Floor Lamp"); + assert!(matches!(data.objects[1].object_type, RoomObjectType::Light)); } #[test] fn test_convert_defaults() { let space = parse("(space)").unwrap(); - let (info, objects) = convert_space(&space); + let data = convert_space(&space); - assert_eq!(info.name, "Untitled Space"); - assert!(objects.is_empty()); + assert_eq!(data.info.name, "Untitled Space"); + assert!(data.objects.is_empty()); } #[test] @@ -340,11 +409,11 @@ mod tests { ) .unwrap(); - let (_, objects) = convert_space(&space); - assert_eq!(objects.len(), 2); - assert_eq!(objects[0].location, None); + let data = convert_space(&space); + assert_eq!(data.objects.len(), 2); + assert_eq!(data.objects[0].location, None); assert_eq!( - objects[1].location, + data.objects[1].location, Some(ObjectLocation::TopOf("obj1".to_string())) ); } @@ -353,6 +422,7 @@ mod tests { fn test_build_space_always_emits_position() { let info = SpaceInfo { name: "Test".to_string(), + tilemap: None, }; let objects = vec![RoomObject::new( "a".to_string(), @@ -371,6 +441,7 @@ mod tests { fn test_build_space_location_roundtrip() { let info = SpaceInfo { name: "Test".to_string(), + tilemap: None, }; let objects = vec![ RoomObject::new("obj1".to_string(), "Table".to_string(), Vec3::ZERO) @@ -386,12 +457,67 @@ mod tests { let space = build_space(&info, &objects); let serialized = protoverse::serialize(&space); let reparsed = parse(&serialized).unwrap(); - let (_, objects2) = convert_space(&reparsed); + let data = convert_space(&reparsed); assert_eq!( - objects2[1].location, + data.objects[1].location, Some(ObjectLocation::TopOf("obj1".to_string())) ); - assert_eq!(objects2[1].position, Vec3::new(0.0, 1.5, 0.0)); + assert_eq!(data.objects[1].position, Vec3::new(0.0, 1.5, 0.0)); + } + + #[test] + fn test_convert_tilemap() { + let space = parse( + r#"(space (name "Test") (group + (tilemap (width 5) (height 5) (tileset "grass" "stone") (data "0")) + (table (id t1) (name "Table") (position 0 0 0))))"#, + ) + .unwrap(); + + let data = convert_space(&space); + assert_eq!(data.info.name, "Test"); + assert_eq!(data.objects.len(), 1); + assert_eq!(data.objects[0].id, "t1"); + + let tm = data.info.tilemap.unwrap(); + assert_eq!(tm.width, 5); + assert_eq!(tm.height, 5); + assert_eq!(tm.tileset, vec!["grass", "stone"]); + assert_eq!(tm.tiles, vec![0]); // fill-all + } + + #[test] + fn test_build_space_tilemap_roundtrip() { + let info = SpaceInfo { + name: "Test".to_string(), + tilemap: Some(TilemapData { + width: 8, + height: 8, + tileset: vec!["grass".to_string(), "water".to_string()], + tiles: vec![0], // fill-all + scene_object_id: None, + model_handle: None, + }), + }; + let objects = vec![RoomObject::new( + "a".to_string(), + "Thing".to_string(), + Vec3::ZERO, + )]; + + let space = build_space(&info, &objects); + let serialized = protoverse::serialize(&space); + let reparsed = parse(&serialized).unwrap(); + let data = convert_space(&reparsed); + + assert_eq!(data.objects.len(), 1); + assert_eq!(data.objects[0].id, "a"); + + let tm = data.info.tilemap.unwrap(); + assert_eq!(tm.width, 8); + assert_eq!(tm.height, 8); + assert_eq!(tm.tileset, vec!["grass", "water"]); + assert_eq!(tm.tiles, vec![0]); } } diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -13,9 +13,11 @@ mod presence; mod room_state; mod room_view; mod subscriptions; +mod tilemap; pub use room_state::{ - NostrverseAction, NostrverseState, RoomObject, RoomObjectType, RoomUser, SpaceInfo, SpaceRef, + NostrverseAction, NostrverseState, RoomObject, RoomObjectType, RoomUser, SpaceData, SpaceInfo, + SpaceRef, }; pub use room_view::{NostrverseResponse, render_editing_panel, show_room_view}; @@ -51,6 +53,9 @@ const MAX_EXTRAPOLATION_DISTANCE: f32 = 10.0; /// Demo space in protoverse .space format const DEMO_SPACE: &str = r#"(space (name "Demo Space") (group + (tilemap (width 10) (height 10) + (tileset "grass") + (data "0")) (table (id obj1) (name "Ironwood Table") (model-url "/home/jb55/var/models/ironwood/ironwood.glb") (position 0 0 0)) @@ -288,31 +293,47 @@ impl NostrverseApp { /// Preserves renderer scene handles for objects that still exist by ID, /// and removes orphaned scene objects from the renderer. fn apply_space(&mut self, space: &protoverse::Space) { - let (info, mut objects) = convert::convert_space(space); - self.state.space = Some(info); + let mut data = convert::convert_space(space); // Transfer scene/model handles from existing objects with matching IDs - for new_obj in &mut objects { + for new_obj in &mut data.objects { if let Some(old_obj) = self.state.objects.iter().find(|o| o.id == new_obj.id) { new_obj.scene_object_id = old_obj.scene_object_id; new_obj.model_handle = old_obj.model_handle; } } + // Transfer tilemap handles before overwriting state + let old_tilemap_handles = self + .state + .tilemap() + .map(|tm| (tm.scene_object_id, tm.model_handle)); + if let (Some(new_tm), Some((scene_id, model_handle))) = + (&mut data.info.tilemap, old_tilemap_handles) + { + new_tm.scene_object_id = scene_id; + new_tm.model_handle = model_handle; + } + // Remove orphaned scene objects (old objects not in the new set) if let Some(renderer) = &self.renderer { let mut r = renderer.renderer.lock().unwrap(); for old_obj in &self.state.objects { if let Some(scene_id) = old_obj.scene_object_id - && !objects.iter().any(|o| o.id == old_obj.id) + && !data.objects.iter().any(|o| o.id == old_obj.id) { r.remove_object(scene_id); } } + // Remove old tilemap scene object if being replaced + if let Some((Some(scene_id), _)) = old_tilemap_handles { + r.remove_object(scene_id); + } } - self.load_object_models(&mut objects); - self.state.objects = objects; + self.load_object_models(&mut data.objects); + self.state.space = Some(data.info); + self.state.objects = data.objects; self.state.dirty = false; } @@ -550,6 +571,25 @@ impl NostrverseApp { sync_objects_to_scene(&mut self.state.objects, &mut r); + // Build + place tilemap if needed + if let Some(tm) = self.state.tilemap_mut() { + if tm.model_handle.is_none() + && let (Some(device), Some(queue)) = (&self.device, &self.queue) + { + tm.model_handle = Some(tilemap::build_tilemap_model(tm, &mut r, device, queue)); + } + if tm.scene_object_id.is_none() + && let Some(model) = tm.model_handle + { + let transform = renderbud::Transform { + translation: glam::Vec3::ZERO, + rotation: glam::Quat::IDENTITY, + scale: glam::Vec3::ONE, + }; + tm.scene_object_id = Some(r.place_object(model, transform)); + } + } + // Update self-user's position from the camera controller if let Some(pos) = r.avatar_position() && let Some(self_user) = self.state.self_user_mut() diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs @@ -43,20 +43,30 @@ impl SpaceRef { } } -/// Parsed space data from event +/// Parsed space definition from event #[derive(Clone, Debug)] pub struct SpaceInfo { pub name: String, + /// Tilemap ground plane (if present) + pub tilemap: Option<TilemapData>, } impl Default for SpaceInfo { fn default() -> Self { Self { name: "Untitled Space".to_string(), + tilemap: None, } } } +/// Converted space data: space info + objects. +/// Used as the return type from convert_space to avoid fragile tuples. +pub struct SpaceData { + pub info: SpaceInfo, + pub objects: Vec<RoomObject>, +} + /// Spatial location relative to the room or another object. /// Mirrors protoverse::Location for decoupling. #[derive(Clone, Debug, PartialEq)] @@ -146,6 +156,62 @@ impl RoomObject { } } +/// Parsed tilemap data — compact tile grid representation. +#[derive(Clone, Debug)] +pub struct TilemapData { + /// Grid width in tiles + pub width: u32, + /// Grid height in tiles + pub height: u32, + /// Tile type names (index 0 = first name, etc.) + pub tileset: Vec<String>, + /// Tile indices, row-major. Length == 1 means fill-all with that value. + pub tiles: Vec<u8>, + /// Runtime: renderbud scene object handle for the tilemap mesh + pub scene_object_id: Option<ObjectId>, + /// Runtime: loaded model handle for the tilemap mesh + pub model_handle: Option<Model>, +} + +impl TilemapData { + /// Get the tile index at grid position (x, y). + pub fn tile_at(&self, x: u32, y: u32) -> u8 { + if self.tiles.len() == 1 { + return self.tiles[0]; + } + let idx = (y * self.width + x) as usize; + self.tiles.get(idx).copied().unwrap_or(0) + } + + /// Encode tiles back to the compact data string. + /// If all tiles are the same value, returns just that value. + pub fn encode_data(&self) -> String { + if self.tiles.len() == 1 { + return self.tiles[0].to_string(); + } + if self.tiles.iter().all(|&t| t == self.tiles[0]) { + return self.tiles[0].to_string(); + } + self.tiles + .iter() + .map(|t| t.to_string()) + .collect::<Vec<_>>() + .join(" ") + } + + /// Parse the compact data string into tile indices. + pub fn decode_data(data: &str) -> Vec<u8> { + let parts: Vec<&str> = data.split_whitespace().collect(); + if parts.len() == 1 { + // Fill-all mode: single value + let val = parts[0].parse::<u8>().unwrap_or(0); + vec![val] + } else { + parts.iter().map(|s| s.parse::<u8>().unwrap_or(0)).collect() + } + } +} + /// A user present in a room (for rendering) #[derive(Clone, Debug)] pub struct RoomUser { @@ -292,6 +358,16 @@ impl NostrverseState { self.objects.iter_mut().find(|o| o.id == id) } + /// Get the tilemap (if present in the space info) + pub fn tilemap(&self) -> Option<&TilemapData> { + self.space.as_ref()?.tilemap.as_ref() + } + + /// Get the tilemap mutably (if present in the space info) + pub fn tilemap_mut(&mut self) -> Option<&mut TilemapData> { + self.space.as_mut()?.tilemap.as_mut() + } + /// Get the local user pub fn self_user(&self) -> Option<&RoomUser> { self.users.iter().find(|u| u.is_self) diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -952,12 +952,16 @@ fn is_sexp_keyword(word: &str) -> bool { matches!( word, "room" + | "space" | "group" | "table" | "chair" | "door" | "light" | "prop" + | "tilemap" + | "tileset" + | "data" | "name" | "id" | "shape" diff --git a/crates/notedeck_nostrverse/src/tilemap.rs b/crates/notedeck_nostrverse/src/tilemap.rs @@ -0,0 +1,187 @@ +//! Tilemap mesh generation — builds a single textured quad mesh from TilemapData. + +use crate::room_state::TilemapData; +use egui_wgpu::wgpu; +use glam::Vec3; +use renderbud::{Aabb, MaterialUniform, Mesh, ModelData, ModelDraw, Vertex}; +use wgpu::util::DeviceExt; + +/// Size of each tile in the atlas, in pixels. +const TILE_PX: u32 = 32; + +/// Generate a deterministic color for a tile name. +fn tile_color(name: &str) -> [u8; 4] { + let mut h: u32 = 5381; + for b in name.bytes() { + h = h.wrapping_mul(33).wrapping_add(b as u32); + } + // Clamp channels to a pleasant range (64..224) so tiles aren't too dark or bright + let r = 64 + (h & 0xFF) as u8 % 160; + let g = 64 + ((h >> 8) & 0xFF) as u8 % 160; + let b = 64 + ((h >> 16) & 0xFF) as u8 % 160; + [r, g, b, 255] +} + +/// Build the atlas RGBA texture data. +/// Atlas is a 1-tile-wide vertical strip (TILE_PX x (TILE_PX * N)). +fn build_atlas(tileset: &[String]) -> (u32, u32, Vec<u8>) { + let n = tileset.len().max(1) as u32; + let atlas_w = TILE_PX; + let atlas_h = TILE_PX * n; + let mut rgba = vec![0u8; (atlas_w * atlas_h * 4) as usize]; + + for (i, name) in tileset.iter().enumerate() { + let color = tile_color(name); + let y_start = i as u32 * TILE_PX; + for y in y_start..y_start + TILE_PX { + for x in 0..TILE_PX { + let offset = ((y * atlas_w + x) * 4) as usize; + rgba[offset..offset + 4].copy_from_slice(&color); + } + } + } + + (atlas_w, atlas_h, rgba) +} + +/// Build a tilemap model (mesh + atlas material) and register it in the renderer. +pub fn build_tilemap_model( + tm: &TilemapData, + renderer: &mut renderbud::Renderer, + device: &wgpu::Device, + queue: &wgpu::Queue, +) -> renderbud::Model { + let w = tm.width; + let h = tm.height; + let n_tiles = (w * h) as usize; + let n_tileset = tm.tileset.len().max(1) as f32; + + // Build atlas texture + let (atlas_w, atlas_h, atlas_rgba) = build_atlas(&tm.tileset); + let atlas_view = renderbud::upload_rgba8_texture_2d( + device, + queue, + atlas_w, + atlas_h, + &atlas_rgba, + wgpu::TextureFormat::Rgba8UnormSrgb, + "tilemap_atlas", + ); + + // Build mesh: one quad per tile + let mut verts: Vec<Vertex> = Vec::with_capacity(n_tiles * 4); + let mut indices: Vec<u32> = Vec::with_capacity(n_tiles * 6); + let mut bounds = Aabb::empty(); + + // Center the tilemap so origin is in the middle + let offset_x = -(w as f32) / 2.0; + let offset_z = -(h as f32) / 2.0; + let y = 0.001_f32; // Just above ground to avoid z-fighting with grid + + let normal = [0.0_f32, 1.0, 0.0]; // Facing up + let tangent = [1.0_f32, 0.0, 0.0, 1.0]; // Tangent along +X + + for ty in 0..h { + for tx in 0..w { + let tile_idx = tm.tile_at(tx, ty) as f32; + let base_vert = verts.len() as u32; + + // Quad corners in world space + let x0 = offset_x + tx as f32; + let x1 = x0 + 1.0; + let z0 = offset_z + ty as f32; + let z1 = z0 + 1.0; + + // UV coords: map to the tile's strip in the atlas + let v0 = tile_idx / n_tileset; + let v1 = (tile_idx + 1.0) / n_tileset; + + verts.push(Vertex { + pos: [x0, y, z0], + normal, + uv: [0.0, v0], + tangent, + }); + verts.push(Vertex { + pos: [x1, y, z0], + normal, + uv: [1.0, v0], + tangent, + }); + verts.push(Vertex { + pos: [x1, y, z1], + normal, + uv: [1.0, v1], + tangent, + }); + verts.push(Vertex { + pos: [x0, y, z1], + normal, + uv: [0.0, v1], + tangent, + }); + + // Two triangles (CCW winding when viewed from above) + indices.push(base_vert); + indices.push(base_vert + 2); + indices.push(base_vert + 1); + indices.push(base_vert); + indices.push(base_vert + 3); + indices.push(base_vert + 2); + + bounds.include_point(Vec3::new(x0, y, z0)); + bounds.include_point(Vec3::new(x1, y, z1)); + } + } + + // Upload buffers + let vert_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("tilemap_verts"), + contents: bytemuck::cast_slice(&verts), + usage: wgpu::BufferUsages::VERTEX, + }); + let ind_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("tilemap_indices"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }); + + // Create material with Nearest filtering for crisp tiles + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("tilemap_sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + let material = renderer.create_material( + device, + queue, + &sampler, + &atlas_view, + MaterialUniform { + base_color_factor: glam::Vec4::ONE, + metallic_factor: 0.0, + roughness_factor: 1.0, + ao_strength: 1.0, + _pad0: 0.0, + }, + ); + + let model_data = ModelData { + draws: vec![ModelDraw { + mesh: Mesh { + num_indices: indices.len() as u32, + vert_buf, + ind_buf, + }, + material_index: 0, + }], + materials: vec![material], + bounds, + }; + + renderer.insert_model(model_data) +} diff --git a/crates/protoverse/src/ast.rs b/crates/protoverse/src/ast.rs @@ -39,6 +39,7 @@ pub enum CellType { Room, Space, Group, + Tilemap, Object(ObjectType), } @@ -68,6 +69,10 @@ pub enum Attribute { /// Euler rotation in degrees (X, Y, Z), applied in YXZ order. Rotation(f64, f64, f64), ModelUrl(String), + /// Tilemap tile type names, e.g. ["grass", "stone", "water"]. + Tileset(Vec<String>), + /// Compact tile data string, e.g. "0" (fill-all) or "0 0 1 1 0 0". + Data(String), } /// Spatial location relative to the room or another object. @@ -121,6 +126,7 @@ impl fmt::Display for CellType { CellType::Room => write!(f, "room"), CellType::Space => write!(f, "space"), CellType::Group => write!(f, "group"), + CellType::Tilemap => write!(f, "tilemap"), CellType::Object(o) => write!(f, "{}", o), } } @@ -257,4 +263,18 @@ impl Space { _ => None, }) } + + pub fn tileset(&self, id: CellId) -> Option<&Vec<String>> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Tileset(v) => Some(v), + _ => None, + }) + } + + pub fn data(&self, id: CellId) -> Option<&str> { + self.attrs(id).iter().find_map(|a| match a { + Attribute::Data(s) => Some(s.as_str()), + _ => None, + }) + } } diff --git a/crates/protoverse/src/describe.rs b/crates/protoverse/src/describe.rs @@ -43,6 +43,7 @@ fn describe_cell(space: &Space, id: CellId, buf: &mut String) -> bool { CellType::Room => describe_area(space, id, "room", buf), CellType::Space => describe_area(space, id, "space", buf), CellType::Group => describe_group(space, id, buf), + CellType::Tilemap => false, CellType::Object(_) => false, // unimplemented in C reference } } diff --git a/crates/protoverse/src/parser.rs b/crates/protoverse/src/parser.rs @@ -228,6 +228,18 @@ impl<'a> Parser<'a> { "model-url" => self .eat_string() .map(|s| Attribute::ModelUrl(s.to_string())), + "tileset" => { + let mut names = Vec::new(); + while let Some(s) = self.eat_string() { + names.push(s.to_string()); + } + if names.is_empty() { + None + } else { + Some(Attribute::Tileset(names)) + } + } + "data" => self.eat_string().map(|s| Attribute::Data(s.to_string())), _ => None, }; @@ -364,6 +376,10 @@ impl<'a> Parser<'a> { Some(id) } + fn try_parse_tilemap(&mut self) -> Option<CellId> { + self.try_parse_named_cell("tilemap", CellType::Tilemap) + } + fn try_parse_object(&mut self) -> Option<CellId> { let cp = self.checkpoint(); @@ -398,6 +414,7 @@ impl<'a> Parser<'a> { .try_parse_group() .or_else(|| self.try_parse_room()) .or_else(|| self.try_parse_space()) + .or_else(|| self.try_parse_tilemap()) .or_else(|| self.try_parse_object()); match id { @@ -509,6 +526,35 @@ mod tests { } #[test] + fn test_parse_tilemap() { + let input = r#"(tilemap (width 10) (height 10) (tileset "grass" "stone") (data "0"))"#; + let space = parse(input).unwrap(); + let root = space.cell(space.root); + assert_eq!(root.cell_type, CellType::Tilemap); + assert_eq!(space.width(space.root), Some(10.0)); + assert_eq!(space.height(space.root), Some(10.0)); + assert_eq!( + space.tileset(space.root), + Some(&vec!["grass".to_string(), "stone".to_string()]) + ); + assert_eq!(space.data(space.root), Some("0")); + } + + #[test] + fn test_parse_tilemap_in_group() { + let input = r#"(space (name "Test") (group (tilemap (width 5) (height 5) (tileset "grass") (data "0")) (table (id t1))))"#; + let space = parse(input).unwrap(); + let group_id = space.children(space.root)[0]; + let children = space.children(group_id); + assert_eq!(children.len(), 2); + assert_eq!(space.cell(children[0]).cell_type, CellType::Tilemap); + assert_eq!( + space.cell(children[1]).cell_type, + CellType::Object(ObjectType::Table) + ); + } + + #[test] fn test_location_roundtrip() { use crate::serializer::serialize; diff --git a/crates/protoverse/src/serializer.rs b/crates/protoverse/src/serializer.rs @@ -116,6 +116,16 @@ fn write_attr(attr: &Attribute, out: &mut String) { Attribute::ModelUrl(s) => { let _ = write!(out, "(model-url \"{}\")", s); } + Attribute::Tileset(names) => { + out.push_str("(tileset"); + for name in names { + let _ = write!(out, " \"{}\"", name); + } + out.push(')'); + } + Attribute::Data(s) => { + let _ = write!(out, "(data \"{}\")", s); + } } } @@ -134,6 +144,21 @@ mod tests { } #[test] + fn test_tilemap_roundtrip() { + let input = + r#"(tilemap (width 10) (height 10) (tileset "grass" "stone") (data "0 0 1 1"))"#; + let space = parse(input).unwrap(); + let serialized = serialize(&space); + let reparsed = parse(&serialized).unwrap(); + assert_eq!(reparsed.cell(reparsed.root).cell_type, CellType::Tilemap); + assert_eq!( + reparsed.tileset(reparsed.root), + Some(&vec!["grass".to_string(), "stone".to_string()]) + ); + assert_eq!(reparsed.data(reparsed.root), Some("0 0 1 1")); + } + + #[test] fn test_format_number_strips_float_noise() { // Integers assert_eq!(format_number(10.0), "10"); diff --git a/crates/renderbud/src/lib.rs b/crates/renderbud/src/lib.rs @@ -1,8 +1,6 @@ use glam::{Mat4, Vec2, Vec3, Vec4}; -use crate::material::{MaterialUniform, make_material_gpudata}; -use crate::model::ModelData; -use crate::model::Vertex; +use crate::material::make_material_gpudata; use std::collections::HashMap; use std::num::NonZeroU64; @@ -17,7 +15,9 @@ mod world; pub mod egui; pub use camera::{ArcballController, Camera, FlyController, ThirdPersonController}; -pub use model::{Aabb, Model}; +pub use material::{MaterialGpu, MaterialUniform}; +pub use model::{Aabb, Mesh, Model, ModelData, ModelDraw, Vertex}; +pub use texture::upload_rgba8_texture_2d; pub use world::{Node, NodeId, ObjectId, Transform, World}; /// Active camera controller mode. @@ -703,6 +703,55 @@ impl Renderer { Ok(id) } + /// Register a procedurally-generated model. Returns a handle that can + /// be placed in the scene with [`place_object`]. + pub fn insert_model(&mut self, model_data: ModelData) -> Model { + self.model_ids += 1; + let id = Model { id: self.model_ids }; + self.models.insert(id, model_data); + id + } + + /// Create a PBR material from a base color texture view. + /// Used for procedural geometry (tilemaps, etc.). + pub fn create_material( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + sampler: &wgpu::Sampler, + basecolor: &wgpu::TextureView, + uniform: MaterialUniform, + ) -> MaterialGpu { + let default_mr = texture::upload_rgba8_texture_2d( + device, + queue, + 1, + 1, + &[0, 255, 0, 255], + wgpu::TextureFormat::Rgba8Unorm, + "tilemap_mr", + ); + let default_normal = texture::upload_rgba8_texture_2d( + device, + queue, + 1, + 1, + &[128, 128, 255, 255], + wgpu::TextureFormat::Rgba8Unorm, + "tilemap_normal", + ); + model::make_material_gpu( + device, + queue, + &self.material_bgl, + sampler, + basecolor, + &default_mr, + &default_normal, + uniform, + ) + } + /// Place a loaded model in the scene with the given transform. pub fn place_object(&mut self, model: Model, transform: Transform) -> ObjectId { self.world.add_object(model, transform) @@ -988,7 +1037,7 @@ impl Renderer { for d in &model_data.draws { shadow_pass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..)); - shadow_pass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16); + shadow_pass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint32); shadow_pass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); } } @@ -1063,7 +1112,7 @@ impl Renderer { for d in &model_data.draws { rpass.set_bind_group(2, &model_data.materials[d.material_index].bindgroup, &[]); rpass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..)); - rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16); + rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint32); rpass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); } } @@ -1086,7 +1135,7 @@ impl Renderer { for d in &model_data.draws { rpass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..)); - rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16); + rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint32); rpass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); } } diff --git a/crates/renderbud/src/model.rs b/crates/renderbud/src/model.rs @@ -345,11 +345,10 @@ pub fn load_gltf_model( .map(|tc| tc.into_f32().collect()) .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); - // TODO(jb55): switch to u32 indices - let indices: Vec<u16> = if let Some(read) = reader.read_indices() { - read.into_u32().map(|i| i as u16).collect() + let indices: Vec<u32> = if let Some(read) = reader.read_indices() { + read.into_u32().collect() } else { - (0..positions.len() as u16).collect() + (0..positions.len() as u32).collect() }; /* @@ -464,7 +463,7 @@ fn map_mag_filter(f: Option<gltf::texture::MagFilter>) -> wgpu::FilterMode { } #[allow(clippy::too_many_arguments)] -fn make_material_gpu( +pub(crate) fn make_material_gpu( device: &wgpu::Device, queue: &wgpu::Queue, material_bgl: &wgpu::BindGroupLayout, @@ -518,7 +517,7 @@ fn make_material_gpu( } } -fn compute_tangents(verts: &mut [Vertex], indices: &[u16]) { +fn compute_tangents(verts: &mut [Vertex], indices: &[u32]) { use glam::{Vec2, Vec3}; let n = verts.len();