commit 26d2b8166e9f19c765ece97c5a3a560fa846f950
parent cc40f0a30aab2d295e393334b71c5ce165999bf7
Author: William Casarin <jb55@jb55.com>
Date: Sun, 22 Feb 2026 14:06:30 -0800
nostrverse: add room editing UI and save-to-nostrdb flow
Add editing panel with room properties (name, dimensions, shape),
object list, object inspector (editable name/position/scale),
add/delete object, and save button that round-trips through
protoverse serialization back to nostrdb.
Key changes:
- build_space(): reverse conversion from Room+objects to Space AST
- RoomObjectType: preserves protoverse cell type through round-trips
- render_editing_panel(): replaces read-only inspection panel
- save_room(): rebuilds Space, serializes, re-ingests as nostr event
- apply_space(): deduplicates room-loading logic
- Dirty guard on poll_room_updates to prevent overwriting unsaved edits
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
4 files changed, 483 insertions(+), 81 deletions(-)
diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs
@@ -1,8 +1,8 @@
//! Convert protoverse Space AST to renderer room state.
-use crate::room_state::{Room, RoomObject, RoomShape};
+use crate::room_state::{Room, RoomObject, RoomObjectType, RoomShape};
use glam::Vec3;
-use protoverse::{CellId, CellType, Shape, Space};
+use protoverse::{Attribute, Cell, CellId, CellType, ObjectType, Shape, Space};
/// Convert a parsed protoverse Space into a Room and its objects.
pub fn convert_space(space: &Space) -> (Room, Vec<RoomObject>) {
@@ -34,10 +34,21 @@ fn extract_room(space: &Space, id: CellId) -> Room {
}
}
+fn object_type_from_cell(obj_type: &ObjectType) -> RoomObjectType {
+ match obj_type {
+ ObjectType::Table => RoomObjectType::Table,
+ ObjectType::Chair => RoomObjectType::Chair,
+ ObjectType::Door => RoomObjectType::Door,
+ ObjectType::Light => RoomObjectType::Light,
+ ObjectType::Custom(s) if s == "prop" => RoomObjectType::Prop,
+ ObjectType::Custom(s) => RoomObjectType::Custom(s.clone()),
+ }
+}
+
fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) {
let cell = space.cell(id);
- if let CellType::Object(_) = &cell.cell_type {
+ if let CellType::Object(ref obj_type) = cell.cell_type {
let obj_id = space.id_str(id).unwrap_or_else(|| "").to_string();
// Generate a fallback id if none specified
@@ -59,7 +70,8 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) {
let model_url = space.model_url(id).map(|s| s.to_string());
- let mut obj = RoomObject::new(obj_id, name, position);
+ let mut obj = RoomObject::new(obj_id, name, position)
+ .with_object_type(object_type_from_cell(obj_type));
if let Some(url) = model_url {
obj = obj.with_model_url(url);
}
@@ -72,6 +84,99 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) {
}
}
+/// Build a protoverse Space from Room and objects (reverse of convert_space).
+///
+/// Produces: (room (name ...) (shape ...) (width ...) (height ...) (depth ...)
+/// (group <objects...>))
+pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space {
+ let mut cells = Vec::new();
+ let mut attributes = Vec::new();
+ let mut child_ids = Vec::new();
+
+ // Room attributes
+ let room_attr_start = attributes.len() as u32;
+ attributes.push(Attribute::Name(room.name.clone()));
+ attributes.push(Attribute::Shape(match room.shape {
+ RoomShape::Rectangle => Shape::Rectangle,
+ RoomShape::Circle => Shape::Circle,
+ RoomShape::Custom => Shape::Rectangle,
+ }));
+ attributes.push(Attribute::Width(room.width as f64));
+ attributes.push(Attribute::Height(room.height as f64));
+ attributes.push(Attribute::Depth(room.depth as f64));
+ let room_attr_count = (attributes.len() as u32 - room_attr_start) as u16;
+
+ // Room cell (index 0), child = group at index 1
+ let room_child_start = child_ids.len() as u32;
+ child_ids.push(CellId(1));
+ cells.push(Cell {
+ cell_type: CellType::Room,
+ first_attr: room_attr_start,
+ attr_count: room_attr_count,
+ first_child: room_child_start,
+ child_count: 1,
+ parent: None,
+ });
+
+ // Group cell (index 1), children = objects at indices 2..
+ let group_child_start = child_ids.len() as u32;
+ for i in 0..objects.len() {
+ child_ids.push(CellId(2 + i as u32));
+ }
+ 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,
+ parent: Some(CellId(0)),
+ });
+
+ // Object cells (indices 2..)
+ for obj in objects {
+ let obj_attr_start = attributes.len() as u32;
+ attributes.push(Attribute::Id(obj.id.clone()));
+ attributes.push(Attribute::Name(obj.name.clone()));
+ if let Some(url) = &obj.model_url {
+ attributes.push(Attribute::ModelUrl(url.clone()));
+ }
+ let pos = obj.position;
+ if pos != Vec3::ZERO {
+ attributes.push(Attribute::Position(
+ pos.x as f64,
+ pos.y as f64,
+ pos.z as f64,
+ ));
+ }
+ let obj_attr_count = (attributes.len() as u32 - obj_attr_start) as u16;
+
+ let obj_type = CellType::Object(match &obj.object_type {
+ RoomObjectType::Table => ObjectType::Table,
+ RoomObjectType::Chair => ObjectType::Chair,
+ RoomObjectType::Door => ObjectType::Door,
+ RoomObjectType::Light => ObjectType::Light,
+ RoomObjectType::Prop => ObjectType::Custom("prop".to_string()),
+ RoomObjectType::Custom(s) => ObjectType::Custom(s.clone()),
+ });
+
+ cells.push(Cell {
+ cell_type: obj_type,
+ first_attr: obj_attr_start,
+ attr_count: obj_attr_count,
+ first_child: child_ids.len() as u32,
+ child_count: 0,
+ parent: Some(CellId(1)),
+ });
+ }
+
+ Space {
+ cells,
+ attributes,
+ child_ids,
+ root: CellId(0),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -100,10 +205,12 @@ mod tests {
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!(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));
}
#[test]
@@ -138,6 +245,53 @@ mod tests {
}
#[test]
+ fn test_build_space_roundtrip() {
+ let room = Room {
+ name: "My Room".to_string(),
+ shape: RoomShape::Rectangle,
+ width: 15.0,
+ height: 10.0,
+ depth: 12.0,
+ };
+ let objects = vec![
+ RoomObject::new(
+ "desk".to_string(),
+ "Office Desk".to_string(),
+ Vec3::new(2.0, 0.0, 3.0),
+ )
+ .with_object_type(RoomObjectType::Table)
+ .with_model_url("/models/desk.glb".to_string()),
+ RoomObject::new("lamp".to_string(), "Floor Lamp".to_string(), Vec3::ZERO)
+ .with_object_type(RoomObjectType::Light),
+ ];
+
+ let space = build_space(&room, &objects);
+
+ // Serialize and re-parse
+ let serialized = protoverse::serialize(&space);
+ let reparsed = parse(&serialized).unwrap();
+
+ // Convert back
+ let (room2, objects2) = convert_space(&reparsed);
+
+ assert_eq!(room2.name, "My Room");
+ assert_eq!(room2.width, 15.0);
+ assert_eq!(room2.height, 10.0);
+ assert_eq!(room2.depth, 12.0);
+
+ 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!(objects2[1].id, "lamp");
+ assert_eq!(objects2[1].name, "Floor Lamp");
+ assert!(matches!(objects2[1].object_type, RoomObjectType::Light));
+ }
+
+ #[test]
fn test_convert_defaults() {
let space = parse("(room)").unwrap();
let (room, objects) = convert_space(&space);
diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs
@@ -13,9 +13,10 @@ mod room_view;
mod subscriptions;
pub use room_state::{
- NostrverseAction, NostrverseState, Room, RoomObject, RoomRef, RoomShape, RoomUser,
+ NostrverseAction, NostrverseState, Room, RoomObject, RoomObjectType, RoomRef, RoomShape,
+ RoomUser,
};
-pub use room_view::{NostrverseResponse, render_inspection_panel, show_room_view};
+pub use room_view::{NostrverseResponse, render_editing_panel, show_room_view};
use enostr::Pubkey;
use glam::Vec3;
@@ -186,12 +187,20 @@ impl NostrverseApp {
self.initialized = true;
}
+ /// Apply a parsed Space to the room state: convert, load models, update state.
+ fn apply_space(&mut self, space: &protoverse::Space) {
+ let (room, mut objects) = convert::convert_space(space);
+ self.state.room = Some(room);
+ self.load_object_models(&mut objects);
+ self.state.objects = objects;
+ self.state.dirty = false;
+ }
+
/// Load room state from a nostrdb query result.
fn load_room_from_ndb(&mut self, ndb: &nostrdb::Ndb, txn: &nostrdb::Transaction) {
let notes = subscriptions::RoomSubscription::query_existing(ndb, txn);
for note in ¬es {
- // Find the room matching our room_ref
let Some(room_id) = nostr_events::get_room_id(note) else {
continue;
};
@@ -204,18 +213,29 @@ impl NostrverseApp {
continue;
};
- let (room, mut objects) = convert::convert_space(&space);
- self.state.room = Some(room);
-
- // Load models and compute placement
- self.load_object_models(&mut objects);
- self.state.objects = objects;
-
+ self.apply_space(&space);
tracing::info!("Loaded room '{}' from nostrdb", room_id);
return;
}
}
+ /// Save current room state: build Space, serialize, ingest as new nostr event.
+ fn save_room(&self, ctx: &mut AppContext<'_>) {
+ let Some(room) = &self.state.room else {
+ tracing::warn!("save_room: no room to save");
+ return;
+ };
+ let Some(kp) = ctx.accounts.selected_filled() else {
+ tracing::warn!("save_room: no keypair available");
+ return;
+ };
+
+ let space = convert::build_space(room, &self.state.objects);
+ let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id);
+ nostr_events::ingest_room_event(builder, ctx.ndb, kp);
+ tracing::info!("Saved room '{}'", self.state.room_ref.id);
+ }
+
/// Load 3D models for objects and handle AABB-based placement.
fn load_object_models(&self, objects: &mut Vec<RoomObject>) {
let renderer = self.renderer.as_ref();
@@ -256,7 +276,11 @@ impl NostrverseApp {
}
/// Poll the room subscription for updates.
+ /// Skips applying updates while the room has unsaved local edits.
fn poll_room_updates(&mut self, ndb: &nostrdb::Ndb) {
+ if self.state.dirty {
+ return;
+ }
let Some(sub) = &self.room_sub else {
return;
};
@@ -275,10 +299,7 @@ impl NostrverseApp {
continue;
};
- let (room, mut objects) = convert::convert_space(&space);
- self.state.room = Some(room);
- self.load_object_models(&mut objects);
- self.state.objects = objects;
+ self.apply_space(&space);
tracing::info!("Room '{}' updated from nostrdb", room_id);
}
}
@@ -381,13 +402,13 @@ impl notedeck::App for NostrverseApp {
// Get available size before layout
let available = ui.available_size();
+ let panel_width = 240.0;
- // Main layout with room view and optional inspection panel
+ // Main layout: 3D view + editing panel
ui.allocate_ui(available, |ui| {
ui.horizontal(|ui| {
- // Reserve space for panel if needed
- let room_width = if self.state.selected_object.is_some() {
- available.x - 200.0
+ let room_width = if self.state.edit_mode {
+ available.x - panel_width
} else {
available.x
};
@@ -396,16 +417,8 @@ impl notedeck::App for NostrverseApp {
if let Some(renderer) = &self.renderer {
let response = show_room_view(ui, &mut self.state, renderer);
- // Handle actions from room view
if let Some(action) = response.action {
- match action {
- NostrverseAction::MoveObject { id, position } => {
- tracing::info!("Object {} moved to {:?}", id, position);
- }
- NostrverseAction::SelectObject(selected) => {
- self.state.selected_object = selected;
- }
- }
+ self.handle_action(action, ctx);
}
} else {
ui.centered_and_justified(|ui| {
@@ -414,13 +427,11 @@ impl notedeck::App for NostrverseApp {
}
});
- // Inspection panel when object selected
- if self.state.selected_object.is_some() {
- ui.allocate_ui(egui::vec2(200.0, available.y), |ui| {
- if let Some(action) = render_inspection_panel(ui, &mut self.state)
- && let NostrverseAction::SelectObject(None) = action
- {
- self.state.selected_object = None;
+ // Editing panel (always visible in edit mode)
+ if self.state.edit_mode {
+ ui.allocate_ui(egui::vec2(panel_width, available.y), |ui| {
+ if let Some(action) = render_editing_panel(ui, &mut self.state) {
+ self.handle_action(action, ctx);
}
});
}
@@ -430,3 +441,34 @@ impl notedeck::App for NostrverseApp {
AppResponse::none()
}
}
+
+impl NostrverseApp {
+ fn handle_action(&mut self, action: NostrverseAction, ctx: &mut AppContext<'_>) {
+ match action {
+ NostrverseAction::MoveObject { id, position } => {
+ if let Some(obj) = self.state.get_object_mut(&id) {
+ obj.position = position;
+ self.state.dirty = true;
+ }
+ }
+ NostrverseAction::SelectObject(selected) => {
+ self.state.selected_object = selected;
+ }
+ NostrverseAction::SaveRoom => {
+ self.save_room(ctx);
+ self.state.dirty = false;
+ }
+ NostrverseAction::AddObject(obj) => {
+ self.state.objects.push(obj);
+ self.state.dirty = true;
+ }
+ NostrverseAction::RemoveObject(id) => {
+ self.state.objects.retain(|o| o.id != id);
+ if self.state.selected_object.as_ref() == Some(&id) {
+ self.state.selected_object = None;
+ }
+ self.state.dirty = true;
+ }
+ }
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs
@@ -11,6 +11,12 @@ pub enum NostrverseAction {
MoveObject { id: String, position: Vec3 },
/// Object was selected
SelectObject(Option<String>),
+ /// Room or object was edited, needs re-ingest
+ SaveRoom,
+ /// A new object was added
+ AddObject(RoomObject),
+ /// An object was removed
+ RemoveObject(String),
}
/// Reference to a nostrverse room
@@ -64,11 +70,25 @@ pub enum RoomShape {
Custom,
}
+/// Protoverse object type, preserved for round-trip serialization
+#[derive(Clone, Debug, Default)]
+pub enum RoomObjectType {
+ Table,
+ Chair,
+ Door,
+ Light,
+ #[default]
+ Prop,
+ Custom(String),
+}
+
/// Object in a room - references a 3D model
#[derive(Clone, Debug)]
pub struct RoomObject {
pub id: String,
pub name: String,
+ /// Protoverse cell type (table, chair, prop, etc.)
+ pub object_type: RoomObjectType,
/// URL to a glTF model (None = use placeholder geometry)
pub model_url: Option<String>,
/// 3D position in world space
@@ -88,6 +108,7 @@ impl RoomObject {
Self {
id,
name,
+ object_type: RoomObjectType::Prop,
model_url: None,
position,
rotation: Quat::IDENTITY,
@@ -97,6 +118,11 @@ impl RoomObject {
}
}
+ pub fn with_object_type(mut self, object_type: RoomObjectType) -> Self {
+ self.object_type = object_type;
+ self
+ }
+
pub fn with_model_url(mut self, url: String) -> Self {
self.model_url = Some(url);
self
@@ -156,6 +182,8 @@ pub struct NostrverseState {
pub edit_mode: bool,
/// Smoothed avatar yaw for lerped rotation
pub smooth_avatar_yaw: f32,
+ /// Room has unsaved edits
+ pub dirty: bool,
}
impl NostrverseState {
@@ -166,8 +194,9 @@ impl NostrverseState {
objects: Vec::new(),
users: Vec::new(),
selected_object: None,
- edit_mode: false,
+ edit_mode: true,
smooth_avatar_yaw: 0.0,
+ dirty: false,
}
}
diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs
@@ -1,8 +1,9 @@
-//! Room 3D rendering for nostrverse via renderbud
+//! Room 3D rendering and editing UI for nostrverse via renderbud
use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui};
+use glam::Vec3;
-use super::room_state::{NostrverseAction, NostrverseState};
+use super::room_state::{NostrverseAction, NostrverseState, RoomObject, RoomShape};
/// Response from rendering the nostrverse view
pub struct NostrverseResponse {
@@ -116,60 +117,236 @@ fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rec
);
}
-/// Render the object inspection panel (side panel when object is selected)
-pub fn render_inspection_panel(
- ui: &mut Ui,
- state: &mut NostrverseState,
-) -> Option<NostrverseAction> {
- let selected_id = state.selected_object.as_ref()?;
- let obj = state.objects.iter().find(|o| &o.id == selected_id)?;
-
+/// Render the side panel with room editing, object list, and object inspector.
+pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<NostrverseAction> {
let mut action = None;
- egui::Frame::default()
+ let panel = egui::Frame::default()
.fill(Color32::from_rgba_unmultiplied(30, 35, 45, 240))
.inner_margin(12.0)
.outer_margin(8.0)
.corner_radius(8.0)
- .stroke(Stroke::new(1.0, Color32::from_rgb(80, 90, 110)))
- .show(ui, |ui| {
- ui.set_min_width(180.0);
-
- ui.horizontal(|ui| {
- ui.strong("Object Inspector");
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- if ui.small_button("X").clicked() {
- action = Some(NostrverseAction::SelectObject(None));
- }
- });
- });
+ .stroke(Stroke::new(1.0, Color32::from_rgb(80, 90, 110)));
- ui.separator();
+ panel.show(ui, |ui| {
+ ui.set_min_width(220.0);
- ui.label(format!("Name: {}", obj.name));
- ui.label(format!(
- "Position: ({:.1}, {:.1}, {:.1})",
- obj.position.x, obj.position.y, obj.position.z
- ));
- ui.label(format!(
- "Scale: ({:.1}, {:.1}, {:.1})",
- obj.scale.x, obj.scale.y, obj.scale.z
- ));
-
- if let Some(url) = &obj.model_url {
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ // --- Room Properties ---
+ if let Some(room) = &mut state.room {
+ ui.strong("Room");
ui.separator();
- ui.small(format!("Model: {}", url));
+
+ let name_changed = ui
+ .horizontal(|ui| {
+ ui.label("Name:");
+ ui.text_edit_singleline(&mut room.name).changed()
+ })
+ .inner;
+
+ let mut width = room.width;
+ let mut height = room.height;
+ let mut depth = room.depth;
+
+ let dims_changed = ui
+ .horizontal(|ui| {
+ ui.label("W:");
+ let w = ui
+ .add(
+ egui::DragValue::new(&mut width)
+ .speed(0.5)
+ .range(1.0..=200.0),
+ )
+ .changed();
+ ui.label("H:");
+ let h = ui
+ .add(
+ egui::DragValue::new(&mut height)
+ .speed(0.5)
+ .range(1.0..=200.0),
+ )
+ .changed();
+ ui.label("D:");
+ let d = ui
+ .add(
+ egui::DragValue::new(&mut depth)
+ .speed(0.5)
+ .range(1.0..=200.0),
+ )
+ .changed();
+ w || h || d
+ })
+ .inner;
+
+ room.width = width;
+ room.height = height;
+ room.depth = depth;
+
+ let shape_changed = ui
+ .horizontal(|ui| {
+ ui.label("Shape:");
+ let mut changed = false;
+ egui::ComboBox::from_id_salt("room_shape")
+ .selected_text(match room.shape {
+ RoomShape::Rectangle => "Rectangle",
+ RoomShape::Circle => "Circle",
+ RoomShape::Custom => "Custom",
+ })
+ .show_ui(ui, |ui| {
+ changed |= ui
+ .selectable_value(
+ &mut room.shape,
+ RoomShape::Rectangle,
+ "Rectangle",
+ )
+ .changed();
+ changed |= ui
+ .selectable_value(&mut room.shape, RoomShape::Circle, "Circle")
+ .changed();
+ });
+ changed
+ })
+ .inner;
+
+ if name_changed || dims_changed || shape_changed {
+ state.dirty = true;
+ }
+
+ ui.add_space(8.0);
}
+ // --- Object List ---
+ ui.strong("Objects");
ui.separator();
- let id_display = if obj.id.len() > 16 {
- format!("{}...", &obj.id[..16])
- } else {
- obj.id.clone()
- };
- ui.small(format!("ID: {}", id_display));
+ let num_objects = state.objects.len();
+ for i in 0..num_objects {
+ let is_selected = state
+ .selected_object
+ .as_ref()
+ .map(|s| s == &state.objects[i].id)
+ .unwrap_or(false);
+
+ let label = format!("{} ({})", state.objects[i].name, state.objects[i].id);
+ if ui.selectable_label(is_selected, label).clicked() {
+ let id = state.objects[i].id.clone();
+ state.selected_object = if is_selected { None } else { Some(id) };
+ }
+ }
+
+ // Add object button
+ ui.add_space(4.0);
+ if ui.button("+ Add Object").clicked() {
+ let new_id = format!("obj-{}", state.objects.len() + 1);
+ let obj = RoomObject::new(new_id.clone(), "New Object".to_string(), Vec3::ZERO);
+ action = Some(NostrverseAction::AddObject(obj));
+ }
+
+ ui.add_space(12.0);
+
+ // --- Object Inspector ---
+ if let Some(selected_id) = state.selected_object.clone() {
+ if let Some(obj) = state.objects.iter_mut().find(|o| o.id == selected_id) {
+ ui.strong("Inspector");
+ ui.separator();
+
+ ui.small(format!("ID: {}", obj.id));
+ ui.add_space(4.0);
+
+ // Editable name
+ let name_changed = ui
+ .horizontal(|ui| {
+ ui.label("Name:");
+ ui.text_edit_singleline(&mut obj.name).changed()
+ })
+ .inner;
+
+ // Editable position
+ let mut px = obj.position.x;
+ let mut py = obj.position.y;
+ let mut pz = obj.position.z;
+ let pos_changed = ui
+ .horizontal(|ui| {
+ ui.label("Pos:");
+ let x = ui
+ .add(egui::DragValue::new(&mut px).speed(0.1).prefix("x:"))
+ .changed();
+ let y = ui
+ .add(egui::DragValue::new(&mut py).speed(0.1).prefix("y:"))
+ .changed();
+ let z = ui
+ .add(egui::DragValue::new(&mut pz).speed(0.1).prefix("z:"))
+ .changed();
+ x || y || z
+ })
+ .inner;
+ obj.position = Vec3::new(px, py, pz);
+
+ // Editable scale (uniform)
+ let mut sx = obj.scale.x;
+ let mut sy = obj.scale.y;
+ let mut sz = obj.scale.z;
+ let scale_changed = ui
+ .horizontal(|ui| {
+ ui.label("Scale:");
+ let x = ui
+ .add(
+ egui::DragValue::new(&mut sx)
+ .speed(0.05)
+ .prefix("x:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ let y = ui
+ .add(
+ egui::DragValue::new(&mut sy)
+ .speed(0.05)
+ .prefix("y:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ let z = ui
+ .add(
+ egui::DragValue::new(&mut sz)
+ .speed(0.05)
+ .prefix("z:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ x || y || z
+ })
+ .inner;
+ obj.scale = Vec3::new(sx, sy, sz);
+
+ // Model URL (read-only for now)
+ if let Some(url) = &obj.model_url {
+ ui.add_space(4.0);
+ ui.small(format!("Model: {}", url));
+ }
+
+ if name_changed || pos_changed || scale_changed {
+ state.dirty = true;
+ }
+
+ ui.add_space(8.0);
+ if ui.button("Delete Object").clicked() {
+ action = Some(NostrverseAction::RemoveObject(selected_id));
+ }
+ }
+ }
+
+ // --- Save button ---
+ ui.add_space(12.0);
+ ui.separator();
+ let save_label = if state.dirty { "Save *" } else { "Save" };
+ if ui
+ .add_enabled(state.dirty, egui::Button::new(save_label))
+ .clicked()
+ {
+ action = Some(NostrverseAction::SaveRoom);
+ }
});
+ });
action
}