notedeck

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

commit 9ed92216cecd6fc7c2b066311b3eab11bfe07297
parent f84c97686e50e2059cac31d4849faf7cc23c13c9
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 25 Feb 2026 14:20:21 -0800

nostrverse: break down build_space and sync_scene into focused helpers

Extract build_object_cell() and object_type_to_cell() from build_space()
in convert.rs. Break down sync_scene() in lib.rs into four standalone
functions: sync_objects_to_scene, lerp_yaw, update_remote_user_positions,
and sync_users_to_scene.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_nostrverse/src/convert.rs | 110++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/notedeck_nostrverse/src/lib.rs | 213++++++++++++++++++++++++++++++++++++++++++-------------------------------------
2 files changed, 176 insertions(+), 147 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs @@ -146,54 +146,7 @@ pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { // 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())); - } - if let Some(loc) = &obj.location { - attributes.push(Attribute::Location(location_to_protoverse(loc))); - } - // When the object has a resolved location base, save the offset - // from the base so that position remains relative to the location. - let pos = match obj.location_base { - Some(base) => obj.position - base, - None => obj.position, - }; - attributes.push(Attribute::Position( - pos.x as f64, - pos.y as f64, - pos.z as f64, - )); - // Only emit rotation when non-identity to keep output clean - if obj.rotation.angle_between(Quat::IDENTITY) > 1e-4 { - let (y, x, z) = obj.rotation.to_euler(glam::EulerRot::YXZ); - attributes.push(Attribute::Rotation( - x.to_degrees() as f64, - y.to_degrees() as f64, - z.to_degrees() 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)), - }); + build_object_cell(obj, &mut cells, &mut attributes, &mut child_ids); } Space { @@ -204,6 +157,67 @@ pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space { } } +fn object_type_to_cell(obj_type: &RoomObjectType) -> CellType { + CellType::Object(match obj_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()), + }) +} + +/// Build a single object Cell with its attributes and append to the Space vectors. +fn build_object_cell( + obj: &RoomObject, + cells: &mut Vec<Cell>, + attributes: &mut Vec<Attribute>, + child_ids: &[CellId], +) { + 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())); + } + if let Some(loc) = &obj.location { + attributes.push(Attribute::Location(location_to_protoverse(loc))); + } + + // When the object has a resolved location base, save the offset + // from the base so that position remains relative to the location. + let pos = match obj.location_base { + Some(base) => obj.position - base, + None => obj.position, + }; + attributes.push(Attribute::Position( + pos.x as f64, + pos.y as f64, + pos.z as f64, + )); + + // Only emit rotation when non-identity to keep output clean + if obj.rotation.angle_between(Quat::IDENTITY) > 1e-4 { + let (y, x, z) = obj.rotation.to_euler(glam::EulerRot::YXZ); + attributes.push(Attribute::Rotation( + x.to_degrees() as f64, + y.to_degrees() as f64, + z.to_degrees() as f64, + )); + } + + cells.push(Cell { + cell_type: object_type_to_cell(&obj.object_type), + first_attr: obj_attr_start, + attr_count: (attributes.len() as u32 - obj_attr_start) as u16, + first_child: child_ids.len() as u32, + child_count: 0, + parent: Some(CellId(1)), + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -548,115 +548,40 @@ impl NostrverseApp { }; let mut r = renderer.renderer.lock().unwrap(); - // Build map of object string ID -> scene ObjectId for parenting lookups - let mut id_to_scene: std::collections::HashMap<String, renderbud::ObjectId> = self - .state - .objects - .iter() - .filter_map(|obj| Some((obj.id.clone(), obj.scene_object_id?))) - .collect(); - - // Sync room objects to the scene graph - for obj in &mut self.state.objects { - let transform = Transform { - translation: obj.position, - rotation: obj.rotation, - scale: obj.scale, - }; + sync_objects_to_scene(&mut self.state.objects, &mut r); - if let Some(scene_id) = obj.scene_object_id { - r.update_object_transform(scene_id, transform); - } else if let Some(model) = obj.model_handle { - // Find parent scene node for objects with location references - let parent_scene_id = obj.location.as_ref().and_then(|loc| match loc { - room_state::ObjectLocation::TopOf(target_id) - | room_state::ObjectLocation::Near(target_id) => { - id_to_scene.get(target_id).copied() - } - _ => None, - }); - - let scene_id = if let Some(parent_id) = parent_scene_id { - r.place_object_with_parent(model, transform, parent_id) - } else { - r.place_object(model, transform) - }; - - obj.scene_object_id = Some(scene_id); - id_to_scene.insert(obj.id.clone(), scene_id); - } - } - - // Read avatar position/yaw from the third-person controller - let avatar_pos = r.avatar_position(); - let avatar_yaw = r.avatar_yaw(); - - // Update self-user's position from the controller - if let Some(pos) = avatar_pos + // 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() { self_user.position = pos; self_user.display_position = pos; } - // Sync all user avatars to the scene - let avatar_half_h = self - .avatar_bounds - .map(|b| (b.max.y - b.min.y) * 0.5) - .unwrap_or(0.0); - let avatar_y_offset = avatar_half_h * AVATAR_SCALE; - let now = self.start_time.elapsed().as_secs_f64(); + // Smoothly lerp avatar yaw toward controller target let dt = 1.0 / 60.0_f32; - - // Smoothly lerp avatar yaw toward target - if let Some(target_yaw) = avatar_yaw { - let current = self.state.smooth_avatar_yaw; - let mut diff = target_yaw - current; - diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - - std::f32::consts::PI; - let t = (AVATAR_YAW_LERP_SPEED * dt).min(1.0); - self.state.smooth_avatar_yaw = current + diff * t; + if let Some(target_yaw) = r.avatar_yaw() { + self.state.smooth_avatar_yaw = lerp_yaw( + self.state.smooth_avatar_yaw, + target_yaw, + AVATAR_YAW_LERP_SPEED * dt, + ); } - for user in &mut self.state.users { - // Dead reckoning for remote users - if !user.is_self { - let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32; - let extrapolated = user.position + user.velocity * time_since_update; - - // Clamp extrapolation distance to prevent runaway drift - let offset = extrapolated - user.position; - let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE { - user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE - } else { - extrapolated - }; - - // Smooth lerp display_position toward the extrapolated target - let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0); - user.display_position = user.display_position.lerp(target, t); - } - - let render_pos = user.display_position; - let yaw = if user.is_self { - self.state.smooth_avatar_yaw - } else { - 0.0 - }; - - let transform = Transform { - translation: render_pos + Vec3::new(0.0, avatar_y_offset, 0.0), - rotation: glam::Quat::from_rotation_y(yaw), - scale: Vec3::splat(AVATAR_SCALE), - }; - - if let Some(scene_id) = user.scene_object_id { - r.update_object_transform(scene_id, transform); - } else if let Some(model) = user.model_handle { - let scene_id = r.place_object(model, transform); - user.scene_object_id = Some(scene_id); - } - } + let now = self.start_time.elapsed().as_secs_f64(); + let avatar_y_offset = self + .avatar_bounds + .map(|b| (b.max.y - b.min.y) * 0.5) + .unwrap_or(0.0) + * AVATAR_SCALE; + + update_remote_user_positions(&mut self.state.users, dt, now); + sync_users_to_scene( + &mut self.state.users, + self.state.smooth_avatar_yaw, + avatar_y_offset, + &mut r, + ); } /// Get the current state @@ -818,6 +743,96 @@ impl NostrverseApp { } } +/// Sync room objects to the renderbud scene graph. +/// Updates transforms for existing objects and places new ones. +fn sync_objects_to_scene(objects: &mut [RoomObject], r: &mut renderbud::Renderer) { + let mut id_to_scene: std::collections::HashMap<String, renderbud::ObjectId> = objects + .iter() + .filter_map(|obj| Some((obj.id.clone(), obj.scene_object_id?))) + .collect(); + + for obj in objects.iter_mut() { + let transform = Transform { + translation: obj.position, + rotation: obj.rotation, + scale: obj.scale, + }; + + if let Some(scene_id) = obj.scene_object_id { + r.update_object_transform(scene_id, transform); + } else if let Some(model) = obj.model_handle { + let parent_scene_id = obj.location.as_ref().and_then(|loc| match loc { + room_state::ObjectLocation::TopOf(target_id) + | room_state::ObjectLocation::Near(target_id) => { + id_to_scene.get(target_id).copied() + } + _ => None, + }); + + let scene_id = if let Some(parent_id) = parent_scene_id { + r.place_object_with_parent(model, transform, parent_id) + } else { + r.place_object(model, transform) + }; + + obj.scene_object_id = Some(scene_id); + id_to_scene.insert(obj.id.clone(), scene_id); + } + } +} + +/// Smoothly interpolate between two yaw angles, wrapping around TAU. +fn lerp_yaw(current: f32, target: f32, speed: f32) -> f32 { + let mut diff = target - current; + diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; + current + diff * speed.min(1.0) +} + +/// Apply dead reckoning to remote users, smoothing their display positions. +fn update_remote_user_positions(users: &mut [RoomUser], dt: f32, now: f64) { + for user in users.iter_mut() { + if user.is_self { + continue; + } + let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32; + let extrapolated = user.position + user.velocity * time_since_update; + + let offset = extrapolated - user.position; + let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE { + user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE + } else { + extrapolated + }; + + let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0); + user.display_position = user.display_position.lerp(target, t); + } +} + +/// Sync user avatars to the renderbud scene with proper transforms. +fn sync_users_to_scene( + users: &mut [RoomUser], + smooth_yaw: f32, + avatar_y_offset: f32, + r: &mut renderbud::Renderer, +) { + for user in users.iter_mut() { + let yaw = if user.is_self { smooth_yaw } else { 0.0 }; + + let transform = Transform { + translation: user.display_position + Vec3::new(0.0, avatar_y_offset, 0.0), + rotation: glam::Quat::from_rotation_y(yaw), + scale: Vec3::splat(AVATAR_SCALE), + }; + + if let Some(scene_id) = user.scene_object_id { + r.update_object_transform(scene_id, transform); + } else if let Some(model) = user.model_handle { + user.scene_object_id = Some(r.place_object(model, transform)); + } + } +} + /// Resolve semantic locations (top-of, near, floor) to concrete positions /// using the provided AABB bounds map. fn resolve_locations(