add transform signal to reduce 4 signals into one responsibility

This commit is contained in:
Evan Carroll 2026-01-26 23:30:32 -06:00
parent 30722bed8f
commit a4cf8d3df4
4 changed files with 83 additions and 86 deletions

View file

@ -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 /// Base text size multiplier. Text at 100% slider = base_sizes * 1.4
const BASE_TEXT_SCALE: f64 = 1.4; const BASE_TEXT_SCALE: f64 = 1.4;
@ -336,14 +377,8 @@ enum BubblePosition {
pub fn Avatar( pub fn Avatar(
/// The member data for this avatar (as a signal for reactive updates). /// The member data for this avatar (as a signal for reactive updates).
member: Signal<ChannelMemberWithAvatar>, member: Signal<ChannelMemberWithAvatar>,
/// X scale factor for coordinate conversion. /// Transform for converting scene coordinates to screen coordinates.
scale_x: Signal<f64>, transform: Signal<SceneTransform>,
/// Y scale factor for coordinate conversion.
scale_y: Signal<f64>,
/// X offset for coordinate conversion.
offset_x: Signal<f64>,
/// Y offset for coordinate conversion.
offset_y: Signal<f64>,
/// Size of the avatar in pixels. /// Size of the avatar in pixels.
prop_size: Signal<f64>, prop_size: Signal<f64>,
/// Z-index for stacking order (higher = on top). /// Z-index for stacking order (higher = on top).
@ -379,10 +414,7 @@ pub fn Avatar(
let compute_layout = move || { let compute_layout = move || {
let m = member.get(); let m = member.get();
let ps = prop_size.get(); let ps = prop_size.get();
let sx = scale_x.get(); let t = transform.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let te = text_em_size.get(); let te = text_em_size.get();
// Calculate content bounds for centering on actual content // Calculate content bounds for centering on actual content
@ -393,10 +425,10 @@ pub fn Avatar(
&m.avatar.emotion_layer, &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 boundaries = screen_bounds.get();
let avatar_screen_x = m.member.position_x * sx + ox; let (avatar_screen_x, avatar_screen_y) =
let avatar_screen_y = m.member.position_y * sy + oy; t.to_screen(m.member.position_x, m.member.position_y);
// Create unified layout - all calculations happen in one place // Create unified layout - all calculations happen in one place
CanvasLayout::new( CanvasLayout::new(
@ -505,16 +537,11 @@ pub fn Avatar(
&m.avatar.emotion_layer, &m.avatar.emotion_layer,
); );
// Get transform parameters for computing avatar screen position // Use passed-in transform and screen bounds (computed once at scene level)
let sx = scale_x.get(); let t = transform.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)
let boundaries = screen_bounds.get(); let boundaries = screen_bounds.get();
let avatar_screen_x = m.member.position_x * sx + ox; let (avatar_screen_x, avatar_screen_y) =
let avatar_screen_y = m.member.position_y * sy + oy; t.to_screen(m.member.position_x, m.member.position_y);
let layout = CanvasLayout::new( let layout = CanvasLayout::new(
&content_bounds, &content_bounds,

View file

@ -8,6 +8,7 @@ use uuid::Uuid;
use chattyness_db::models::LooseProp; use chattyness_db::models::LooseProp;
use super::avatar::SceneTransform;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
pub use super::canvas_utils::hit_test_canvas; pub use super::canvas_utils::hit_test_canvas;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
@ -29,14 +30,8 @@ pub fn loose_prop_key(p: &LooseProp) -> Uuid {
pub fn LoosePropCanvas( pub fn LoosePropCanvas(
/// The prop data (as a signal for reactive updates). /// The prop data (as a signal for reactive updates).
prop: Signal<LooseProp>, prop: Signal<LooseProp>,
/// X scale factor for coordinate conversion. /// Transform for converting scene coordinates to screen coordinates.
scale_x: Signal<f64>, transform: Signal<SceneTransform>,
/// Y scale factor for coordinate conversion.
scale_y: Signal<f64>,
/// X offset for coordinate conversion.
offset_x: Signal<f64>,
/// Y offset for coordinate conversion.
offset_y: Signal<f64>,
/// Base prop size in screen pixels (already includes viewport scaling). /// Base prop size in screen pixels (already includes viewport scaling).
base_prop_size: Signal<f64>, base_prop_size: Signal<f64>,
/// Z-index for stacking order. /// Z-index for stacking order.
@ -47,10 +42,7 @@ pub fn LoosePropCanvas(
// Reactive style for CSS positioning (GPU-accelerated transforms) // Reactive style for CSS positioning (GPU-accelerated transforms)
let style = move || { let style = move || {
let p = prop.get(); let p = prop.get();
let sx = scale_x.get(); let t = transform.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
let base_size = base_prop_size.get(); let base_size = base_prop_size.get();
// Calculate rendered prop size // Calculate rendered prop size
@ -58,8 +50,7 @@ pub fn LoosePropCanvas(
let prop_size = base_size * prop_scale_ratio * p.scale as f64; let prop_size = base_size * prop_scale_ratio * p.scale as f64;
// Screen position (center of prop) // Screen position (center of prop)
let screen_x = p.position_x * sx + ox; let (screen_x, screen_y) = t.to_screen(p.position_x, p.position_y);
let screen_y = p.position_y * sy + oy;
// Canvas positioned at top-left corner // Canvas positioned at top-left corner
let canvas_x = screen_x - prop_size / 2.0; let canvas_x = screen_x - prop_size / 2.0;

View file

@ -22,7 +22,7 @@ use uuid::Uuid;
use chattyness_db::models::{ChannelMemberWithAvatar, LooseProp, Scene}; 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")] #[cfg(feature = "hydrate")]
use super::canvas_utils::hit_test_canvas; use super::canvas_utils::hit_test_canvas;
use super::chat_types::ActiveBubble; 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 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 screen_bounds = Signal::derive(move || {
let t = scene_transform.get();
ScreenBounds::from_transform( ScreenBounds::from_transform(
scene_width_signal.get(), scene_width_f,
scene_height_signal.get(), scene_height_f,
scale_x_signal.get(), t.scale_x,
scale_y_signal.get(), t.scale_y,
offset_x_signal.get(), t.offset_x,
offset_y_signal.get(), t.offset_y,
) )
}); });
@ -494,10 +497,7 @@ pub fn RealmSceneViewer(
view! { view! {
<LoosePropCanvas <LoosePropCanvas
prop=prop_signal prop=prop_signal
scale_x=scale_x_signal transform=scene_transform
scale_y=scale_y_signal
offset_x=offset_x_signal
offset_y=offset_y_signal
base_prop_size=prop_size base_prop_size=prop_size
z_index=5 z_index=5
/> />
@ -525,10 +525,7 @@ pub fn RealmSceneViewer(
view! { view! {
<Avatar <Avatar
member=member_signal member=member_signal
scale_x=scale_x_signal transform=scene_transform
scale_y=scale_y_signal
offset_x=offset_x_signal
offset_y=offset_y_signal
prop_size=prop_size prop_size=prop_size
z_index=z z_index=z
text_em_size=text_em_size text_em_size=text_em_size
@ -556,10 +553,7 @@ pub fn RealmSceneViewer(
Some(view! { Some(view! {
<Avatar <Avatar
member=member_signal member=member_signal
scale_x=scale_x_signal transform=scene_transform
scale_y=scale_y_signal
offset_x=offset_x_signal
offset_y=offset_y_signal
prop_size=prop_size prop_size=prop_size
z_index=5 z_index=5
text_em_size=text_em_size text_em_size=text_em_size
@ -736,10 +730,7 @@ pub fn RealmSceneViewer(
prop_scale=Signal::derive(move || move_mode_prop_scale.get()) prop_scale=Signal::derive(move || move_mode_prop_scale.get())
loose_props=loose_props loose_props=loose_props
prop_size=prop_size prop_size=prop_size
scale_x=scale_x_signal transform=scene_transform
scale_y=scale_y_signal
offset_x=offset_x_signal
offset_y=offset_y_signal
on_apply=on_prop_move.unwrap_or_else(|| Callback::new(|_| {})) on_apply=on_prop_move.unwrap_or_else(|| Callback::new(|_| {}))
on_cancel=Callback::new(move |_: ()| { on_cancel=Callback::new(move |_: ()| {
set_move_mode_active.set(false); set_move_mode_active.set(false);

View file

@ -8,6 +8,7 @@ use uuid::Uuid;
use chattyness_db::models::LooseProp; use chattyness_db::models::LooseProp;
use super::super::avatar::SceneTransform;
use super::super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE}; use super::super::settings::{BASE_AVATAR_SCALE, BASE_PROP_SCALE};
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use super::super::canvas_utils::normalize_asset_path; use super::super::canvas_utils::normalize_asset_path;
@ -146,10 +147,7 @@ pub fn MoveOverlay(
#[prop(into)] prop_scale: Signal<f32>, #[prop(into)] prop_scale: Signal<f32>,
#[prop(into)] loose_props: Signal<Vec<LooseProp>>, #[prop(into)] loose_props: Signal<Vec<LooseProp>>,
#[prop(into)] prop_size: Signal<f64>, #[prop(into)] prop_size: Signal<f64>,
#[prop(into)] scale_x: Signal<f64>, #[prop(into)] transform: Signal<SceneTransform>,
#[prop(into)] scale_y: Signal<f64>,
#[prop(into)] offset_x: Signal<f64>,
#[prop(into)] offset_y: Signal<f64>,
#[prop(optional)] on_apply: Option<Callback<(Uuid, f64, f64)>>, #[prop(optional)] on_apply: Option<Callback<(Uuid, f64, f64)>>,
#[prop(optional)] on_cancel: Option<Callback<()>>, #[prop(optional)] on_cancel: Option<Callback<()>>,
) -> impl IntoView { ) -> impl IntoView {
@ -189,14 +187,8 @@ pub fn MoveOverlay(
let viewer_x = mouse_x - rect.left(); let viewer_x = mouse_x - rect.left();
let viewer_y = mouse_y - rect.top(); let viewer_y = mouse_y - rect.top();
let sx = scale_x.get(); let t = transform.get();
let sy = scale_y.get(); if let Some((scene_x, scene_y)) = t.to_scene(viewer_x, viewer_y) {
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;
preview_position.set((scene_x, scene_y)); preview_position.set((scene_x, scene_y));
} }
} }
@ -241,10 +233,7 @@ pub fn MoveOverlay(
{ {
if let Some(ref prop) = prop_data { if let Some(ref prop) = prop_data {
let (preview_x, preview_y) = preview_position.get(); let (preview_x, preview_y) = preview_position.get();
let sx = scale_x.get(); let t = transform.get();
let sy = scale_y.get();
let ox = offset_x.get();
let oy = offset_y.get();
// Get scene canvas position in viewport (inner container with actual scene) // Get scene canvas position in viewport (inner container with actual scene)
let document = web_sys::window().unwrap().document().unwrap(); let document = web_sys::window().unwrap().document().unwrap();
@ -259,8 +248,7 @@ pub fn MoveOverlay(
.unwrap_or((0.0, 0.0)); .unwrap_or((0.0, 0.0));
// Convert scene coords to viewport coords // Convert scene coords to viewport coords
let viewer_x = preview_x * sx + ox; let (viewer_x, viewer_y) = t.to_screen(preview_x, preview_y);
let viewer_y = preview_y * sy + oy;
let viewport_x = viewer_x + viewer_offset.0; let viewport_x = viewer_x + viewer_offset.0;
let viewport_y = viewer_y + viewer_offset.1; let viewport_y = viewer_y + viewer_offset.1;