commit 566efef3c45414a100c684ab82e4e11d94847d65
parent 990f5d48134e4eda9a45640d5696e8ca67cdd54f
Author: William Casarin <jb55@jb55.com>
Date: Tue, 24 Feb 2026 11:34:25 -0800
nostrverse: persist rotation as human-readable Euler degrees
Store rotation in protoverse format as (rotation x y z) in degrees
with YXZ Euler order, so (rotation 0 90 0) reads as "90° around Y".
Conversion to/from Quat happens in the convert layer. Identity
rotations are omitted using angle_between check.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
5 files changed, 51 insertions(+), 1 deletion(-)
diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs
@@ -1,7 +1,7 @@
//! Convert protoverse Space AST to renderer room state.
use crate::room_state::{ObjectLocation, Room, RoomObject, RoomObjectType, RoomShape};
-use glam::Vec3;
+use glam::{Quat, Vec3};
use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Shape, Space};
/// Convert a parsed protoverse Space into a Room and its objects.
@@ -92,6 +92,17 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) {
let model_url = space.model_url(id).map(|s| s.to_string());
let location = space.location(id).map(location_from_protoverse);
+ let rotation = space
+ .rotation(id)
+ .map(|(x, y, z)| {
+ Quat::from_euler(
+ glam::EulerRot::YXZ,
+ (y as f32).to_radians(),
+ (x as f32).to_radians(),
+ (z as f32).to_radians(),
+ )
+ })
+ .unwrap_or(Quat::IDENTITY);
let mut obj = RoomObject::new(obj_id, name, position)
.with_object_type(object_type_from_cell(obj_type));
@@ -101,6 +112,7 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) {
if let Some(loc) = location {
obj = obj.with_location(loc);
}
+ obj.rotation = rotation;
objects.push(obj);
}
@@ -180,6 +192,15 @@ pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space {
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 {
diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs
@@ -914,6 +914,7 @@ fn is_sexp_keyword(word: &str) -> bool {
| "height"
| "depth"
| "position"
+ | "rotation"
| "location"
| "model-url"
| "material"
diff --git a/crates/protoverse/src/ast.rs b/crates/protoverse/src/ast.rs
@@ -65,6 +65,8 @@ pub enum Attribute {
Location(Location),
State(CellState),
Position(f64, f64, f64),
+ /// Euler rotation in degrees (X, Y, Z), applied in YXZ order.
+ Rotation(f64, f64, f64),
ModelUrl(String),
}
@@ -206,6 +208,14 @@ impl Space {
})
}
+ /// Euler rotation in degrees (X, Y, Z).
+ pub fn rotation(&self, id: CellId) -> Option<(f64, f64, f64)> {
+ self.attrs(id).iter().find_map(|a| match a {
+ Attribute::Rotation(x, y, z) => Some((*x, *y, *z)),
+ _ => None,
+ })
+ }
+
pub fn model_url(&self, id: CellId) -> Option<&str> {
self.attrs(id).iter().find_map(|a| match a {
Attribute::ModelUrl(s) => Some(s.as_str()),
diff --git a/crates/protoverse/src/parser.rs b/crates/protoverse/src/parser.rs
@@ -216,6 +216,15 @@ impl<'a> Parser<'a> {
_ => None,
}
}
+ "rotation" => {
+ let x = self.eat_number();
+ let y = self.eat_number();
+ let z = self.eat_number();
+ match (x, y, z) {
+ (Some(x), Some(y), Some(z)) => Some(Attribute::Rotation(x, y, z)),
+ _ => None,
+ }
+ }
"model-url" => self
.eat_string()
.map(|s| Attribute::ModelUrl(s.to_string())),
diff --git a/crates/protoverse/src/serializer.rs b/crates/protoverse/src/serializer.rs
@@ -104,6 +104,15 @@ fn write_attr(attr: &Attribute, out: &mut String) {
format_number(*z)
);
}
+ Attribute::Rotation(x, y, z) => {
+ let _ = write!(
+ out,
+ "(rotation {} {} {})",
+ format_number(*x),
+ format_number(*y),
+ format_number(*z)
+ );
+ }
Attribute::ModelUrl(s) => {
let _ = write!(out, "(model-url \"{}\")", s);
}