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