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:
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();