From a4cf8d3df48d783ec3d5fa4529628c26b1f73d3620698af84f22a2d8bf36974b Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Mon, 26 Jan 2026 23:30:32 -0600 Subject: [PATCH] add transform signal to reduce 4 signals into one responsibility --- .../src/components/avatar.rs | 75 +++++++++++++------ .../src/components/loose_prop_canvas.rs | 19 ++--- .../src/components/scene_viewer.rs | 51 ++++++------- .../src/components/scene_viewer/overlays.rs | 24 ++---- 4 files changed, 83 insertions(+), 86 deletions(-) diff --git a/crates/chattyness-user-ui/src/components/avatar.rs b/crates/chattyness-user-ui/src/components/avatar.rs index 2f7d3d2..369297b 100644 --- a/crates/chattyness-user-ui/src/components/avatar.rs +++ b/crates/chattyness-user-ui/src/components/avatar.rs @@ -75,6 +75,47 @@ impl ScreenBounds { } } +// ============================================================================= +// Scene Transform - Exported for use by other components +// ============================================================================= + +/// Transform parameters for converting scene coordinates to screen coordinates. +/// +/// Used by Avatar and LoosePropCanvas to position elements on screen. +#[derive(Clone, Copy, Debug)] +pub struct SceneTransform { + /// X scale factor (screen pixels per scene unit) + pub scale_x: f64, + /// Y scale factor (screen pixels per scene unit) + pub scale_y: f64, + /// X offset - screen pixel position of scene origin (0,0) + pub offset_x: f64, + /// Y offset - screen pixel position of scene origin (0,0) + pub offset_y: f64, +} + +impl SceneTransform { + /// Convert scene coordinates to screen pixel coordinates. + pub fn to_screen(&self, scene_x: f64, scene_y: f64) -> (f64, f64) { + ( + scene_x * self.scale_x + self.offset_x, + scene_y * self.scale_y + self.offset_y, + ) + } + + /// Convert screen pixel coordinates to scene coordinates. + /// Returns None if scale is zero (invalid transform). + pub fn to_scene(&self, screen_x: f64, screen_y: f64) -> Option<(f64, f64)> { + if self.scale_x == 0.0 || self.scale_y == 0.0 { + return None; + } + Some(( + (screen_x - self.offset_x) / self.scale_x, + (screen_y - self.offset_y) / self.scale_y, + )) + } +} + /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4 const BASE_TEXT_SCALE: f64 = 1.4; @@ -336,14 +377,8 @@ enum BubblePosition { pub fn Avatar( /// The member data for this avatar (as a signal for reactive updates). member: Signal, - /// X scale factor for coordinate conversion. - scale_x: Signal, - /// Y scale factor for coordinate conversion. - scale_y: Signal, - /// X offset for coordinate conversion. - offset_x: Signal, - /// Y offset for coordinate conversion. - offset_y: Signal, + /// Transform for converting scene coordinates to screen coordinates. + transform: Signal, /// Size of the avatar in pixels. prop_size: Signal, /// Z-index for stacking order (higher = on top). @@ -379,10 +414,7 @@ pub fn Avatar( let compute_layout = move || { let m = member.get(); let ps = prop_size.get(); - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); + let t = transform.get(); let te = text_em_size.get(); // Calculate content bounds for centering on actual content @@ -393,10 +425,10 @@ pub fn Avatar( &m.avatar.emotion_layer, ); - // Use passed-in screen bounds (computed once at scene level) + // Use passed-in screen bounds and transform (computed once at scene level) let boundaries = screen_bounds.get(); - let avatar_screen_x = m.member.position_x * sx + ox; - let avatar_screen_y = m.member.position_y * sy + oy; + let (avatar_screen_x, avatar_screen_y) = + t.to_screen(m.member.position_x, m.member.position_y); // Create unified layout - all calculations happen in one place CanvasLayout::new( @@ -505,16 +537,11 @@ pub fn Avatar( &m.avatar.emotion_layer, ); - // Get transform parameters for computing avatar screen position - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); - - // Use passed-in screen bounds (computed once at scene level) + // Use passed-in transform and screen bounds (computed once at scene level) + let t = transform.get(); let boundaries = screen_bounds.get(); - let avatar_screen_x = m.member.position_x * sx + ox; - let avatar_screen_y = m.member.position_y * sy + oy; + let (avatar_screen_x, avatar_screen_y) = + t.to_screen(m.member.position_x, m.member.position_y); let layout = CanvasLayout::new( &content_bounds, diff --git a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs index 536b00b..446b741 100644 --- a/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs +++ b/crates/chattyness-user-ui/src/components/loose_prop_canvas.rs @@ -8,6 +8,7 @@ use uuid::Uuid; use chattyness_db::models::LooseProp; +use super::avatar::SceneTransform; #[cfg(feature = "hydrate")] pub use super::canvas_utils::hit_test_canvas; #[cfg(feature = "hydrate")] @@ -29,14 +30,8 @@ pub fn loose_prop_key(p: &LooseProp) -> Uuid { pub fn LoosePropCanvas( /// The prop data (as a signal for reactive updates). prop: Signal, - /// X scale factor for coordinate conversion. - scale_x: Signal, - /// Y scale factor for coordinate conversion. - scale_y: Signal, - /// X offset for coordinate conversion. - offset_x: Signal, - /// Y offset for coordinate conversion. - offset_y: Signal, + /// Transform for converting scene coordinates to screen coordinates. + transform: Signal, /// Base prop size in screen pixels (already includes viewport scaling). base_prop_size: Signal, /// Z-index for stacking order. @@ -47,10 +42,7 @@ pub fn LoosePropCanvas( // Reactive style for CSS positioning (GPU-accelerated transforms) let style = move || { let p = prop.get(); - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); + let t = transform.get(); let base_size = base_prop_size.get(); // Calculate rendered prop size @@ -58,8 +50,7 @@ pub fn LoosePropCanvas( let prop_size = base_size * prop_scale_ratio * p.scale as f64; // Screen position (center of prop) - let screen_x = p.position_x * sx + ox; - let screen_y = p.position_y * sy + oy; + let (screen_x, screen_y) = t.to_screen(p.position_x, p.position_y); // Canvas positioned at top-left corner let canvas_x = screen_x - prop_size / 2.0; diff --git a/crates/chattyness-user-ui/src/components/scene_viewer.rs b/crates/chattyness-user-ui/src/components/scene_viewer.rs index 0adb53a..bcf1e6a 100644 --- a/crates/chattyness-user-ui/src/components/scene_viewer.rs +++ b/crates/chattyness-user-ui/src/components/scene_viewer.rs @@ -22,7 +22,7 @@ use uuid::Uuid; use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; -use super::avatar::{Avatar, ScreenBounds, member_key}; +use super::avatar::{Avatar, ScreenBounds, SceneTransform, member_key}; #[cfg(feature = "hydrate")] use super::canvas_utils::hit_test_canvas; use super::chat_types::ActiveBubble; @@ -443,22 +443,25 @@ pub fn RealmSceneViewer( }); let text_em_size = Signal::derive(move || settings.get().text_em_size); - let scale_x_signal = Signal::derive(move || scale_x.get()); - let scale_y_signal = Signal::derive(move || scale_y.get()); - let offset_x_signal = Signal::derive(move || offset_x.get()); - let offset_y_signal = Signal::derive(move || offset_y.get()); - let scene_width_signal = Signal::derive(move || scene_width_f); - let scene_height_signal = Signal::derive(move || scene_height_f); - // Compute ScreenBounds once for all avatars (instead of each avatar computing it) + // Compute SceneTransform once for all avatars and props + let scene_transform = Signal::derive(move || SceneTransform { + scale_x: scale_x.get(), + scale_y: scale_y.get(), + offset_x: offset_x.get(), + offset_y: offset_y.get(), + }); + + // Compute ScreenBounds once for all avatars (for clamping) let screen_bounds = Signal::derive(move || { + let t = scene_transform.get(); ScreenBounds::from_transform( - scene_width_signal.get(), - scene_height_signal.get(), - scale_x_signal.get(), - scale_y_signal.get(), - offset_x_signal.get(), - offset_y_signal.get(), + scene_width_f, + scene_height_f, + t.scale_x, + t.scale_y, + t.offset_x, + t.offset_y, ) }); @@ -494,10 +497,7 @@ pub fn RealmSceneViewer( view! { @@ -525,10 +525,7 @@ pub fn RealmSceneViewer( view! { , #[prop(into)] loose_props: Signal>, #[prop(into)] prop_size: Signal, - #[prop(into)] scale_x: Signal, - #[prop(into)] scale_y: Signal, - #[prop(into)] offset_x: Signal, - #[prop(into)] offset_y: Signal, + #[prop(into)] transform: Signal, #[prop(optional)] on_apply: Option>, #[prop(optional)] on_cancel: Option>, ) -> impl IntoView { @@ -189,14 +187,8 @@ pub fn MoveOverlay( let viewer_x = mouse_x - rect.left(); let viewer_y = mouse_y - rect.top(); - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); - - if sx > 0.0 && sy > 0.0 { - let scene_x = (viewer_x - ox) / sx; - let scene_y = (viewer_y - oy) / sy; + let t = transform.get(); + if let Some((scene_x, scene_y)) = t.to_scene(viewer_x, viewer_y) { preview_position.set((scene_x, scene_y)); } } @@ -241,10 +233,7 @@ pub fn MoveOverlay( { if let Some(ref prop) = prop_data { let (preview_x, preview_y) = preview_position.get(); - let sx = scale_x.get(); - let sy = scale_y.get(); - let ox = offset_x.get(); - let oy = offset_y.get(); + let t = transform.get(); // Get scene canvas position in viewport (inner container with actual scene) let document = web_sys::window().unwrap().document().unwrap(); @@ -259,8 +248,7 @@ pub fn MoveOverlay( .unwrap_or((0.0, 0.0)); // Convert scene coords to viewport coords - let viewer_x = preview_x * sx + ox; - let viewer_y = preview_y * sy + oy; + let (viewer_x, viewer_y) = t.to_screen(preview_x, preview_y); let viewport_x = viewer_x + viewer_offset.0; let viewport_y = viewer_y + viewer_offset.1;