@@ -13,7 +13,7 @@ use std::{
1313} ;
1414
1515use anyhow:: { Context , Result } ;
16- use image:: { DynamicImage , GrayImage , RgbaImage , imageops} ;
16+ use image:: { DynamicImage , GrayImage , Rgba , RgbaImage , imageops} ;
1717use koharu_core:: {
1818 FontFaceInfo , FontPrediction , FontSource , NodeId , TextDirection , TextShaderEffect ,
1919 TextStrokeStyle , TextStyle , Transform ,
@@ -202,8 +202,15 @@ impl Renderer {
202202 imageops:: overlay ( & mut canvas, & brush. to_rgba8 ( ) , 0 , 0 ) ;
203203 }
204204 for out in & rendered_blocks {
205- let ( x, y) = placement_origin ( find_input ( blocks, out. node_id ) , & out. expanded_transform ) ;
206- imageops:: overlay ( & mut canvas, & out. sprite . to_rgba8 ( ) , x as i64 , y as i64 ) ;
205+ let input = find_input ( blocks, out. node_id ) ;
206+ let is_expanded = out. expanded_transform . is_some ( ) ;
207+ let sprite_transform = out. expanded_transform . as_ref ( ) . unwrap_or ( & input. transform ) ;
208+ overlay_sprite_with_rotation (
209+ & mut canvas,
210+ & out. sprite . to_rgba8 ( ) ,
211+ sprite_transform,
212+ is_expanded,
213+ ) ;
207214 }
208215 Ok ( RenderOutput {
209216 final_render : DynamicImage :: ImageRgba8 ( canvas) ,
@@ -700,12 +707,45 @@ fn sprite_collides_with_bubble_mask(
700707 mask : & GrayImage ,
701708 bubble_id : u8 ,
702709) -> bool {
703- let origin_x = transform. x . round ( ) as i32 ;
704- let origin_y = transform. y . round ( ) as i32 ;
705710 let mask_w = mask. width ( ) as i32 ;
706711 let mask_h = mask. height ( ) as i32 ;
707712
708- for ( x, y, pixel) in sprite. enumerate_pixels ( ) {
713+ let mut rotation_deg = transform. rotation_deg % 360.0 ;
714+ if rotation_deg < 0.0 {
715+ rotation_deg += 360.0 ;
716+ }
717+
718+ // Fast path: no rotation — check source pixels directly, no allocation.
719+ if rotation_deg. abs ( ) < 0.0001 || ( 360.0 - rotation_deg) . abs ( ) < 0.0001 {
720+ let origin_x = transform. x . round ( ) as i32 ;
721+ let origin_y = transform. y . round ( ) as i32 ;
722+ for ( x, y, pixel) in sprite. enumerate_pixels ( ) {
723+ if pixel. 0 [ 3 ] <= MASK_COLLISION_ALPHA_THRESHOLD {
724+ continue ;
725+ }
726+ let mask_x = origin_x + x as i32 ;
727+ let mask_y = origin_y + y as i32 ;
728+ if mask_x < 0 || mask_y < 0 || mask_x >= mask_w || mask_y >= mask_h {
729+ return true ;
730+ }
731+ if mask. get_pixel ( mask_x as u32 , mask_y as u32 ) . 0 [ 0 ] != bubble_id {
732+ return true ;
733+ }
734+ }
735+ return false ;
736+ }
737+
738+ // Rotate the sprite first so the collision check uses the same
739+ // bilinear-resampled pixels as the actual render overlay path.
740+ let ( rotated, _, _) = rotate_sprite_expand_top_left ( sprite, rotation_deg. to_radians ( ) ) ;
741+
742+ // Match the overlay origin computed by overlay_sprite_with_rotation.
743+ let origin_x =
744+ ( transform. x + transform. width * 0.5 - rotated. width ( ) as f32 * 0.5 ) . round ( ) as i32 ;
745+ let origin_y =
746+ ( transform. y + transform. height * 0.5 - rotated. height ( ) as f32 * 0.5 ) . round ( ) as i32 ;
747+
748+ for ( x, y, pixel) in rotated. enumerate_pixels ( ) {
709749 if pixel. 0 [ 3 ] <= MASK_COLLISION_ALPHA_THRESHOLD {
710750 continue ;
711751 }
@@ -1002,12 +1042,199 @@ fn find_input(blocks: &[RenderBlockInput], id: NodeId) -> &RenderBlockInput {
10021042 . expect ( "rendered_block must have matching input" )
10031043}
10041044
1005- fn placement_origin ( input : & RenderBlockInput , expanded : & Option < Transform > ) -> ( f32 , f32 ) {
1006- if let Some ( t) = expanded {
1007- ( t. x . round ( ) , t. y . round ( ) )
1008- } else {
1009- ( input. transform . x , input. transform . y )
1045+ fn overlay_sprite_with_rotation (
1046+ canvas : & mut RgbaImage ,
1047+ sprite : & RgbaImage ,
1048+ transform : & Transform ,
1049+ is_expanded : bool ,
1050+ ) {
1051+ let mut rotation_deg = transform. rotation_deg % 360.0 ;
1052+ if rotation_deg < 0.0 {
1053+ rotation_deg += 360.0 ;
1054+ }
1055+
1056+ if rotation_deg. abs ( ) < 0.0001 || ( 360.0 - rotation_deg) . abs ( ) < 0.0001 {
1057+ // Preserve legacy placement: expanded transforms were rounded,
1058+ // non-expanded (original) transforms were truncated (cast to i64).
1059+ let ( ox, oy) = if is_expanded {
1060+ ( transform. x . round ( ) as i64 , transform. y . round ( ) as i64 )
1061+ } else {
1062+ ( transform. x as i64 , transform. y as i64 )
1063+ } ;
1064+ imageops:: overlay ( canvas, sprite, ox, oy) ;
1065+ return ;
10101066 }
1067+
1068+ let ( rotated, _, _) = rotate_sprite_expand_top_left ( sprite, rotation_deg. to_radians ( ) ) ;
1069+ // The unrotated sprite is centered at (transform.x + w/2, transform.y + h/2).
1070+ // Align the rotated sprite's center to the same point so rotated text
1071+ // stays visually centered within its layout box.
1072+ let origin_x =
1073+ ( transform. x + transform. width * 0.5 - rotated. width ( ) as f32 * 0.5 ) . round ( ) as i64 ;
1074+ let origin_y =
1075+ ( transform. y + transform. height * 0.5 - rotated. height ( ) as f32 * 0.5 ) . round ( ) as i64 ;
1076+ imageops:: overlay ( canvas, & rotated, origin_x, origin_y) ;
1077+ }
1078+
1079+ fn rotate_sprite_expand_top_left ( src : & RgbaImage , angle_rad : f32 ) -> ( RgbaImage , f32 , f32 ) {
1080+ let src_w = src. width ( ) ;
1081+ let src_h = src. height ( ) ;
1082+ if src_w == 0 || src_h == 0 {
1083+ return ( RgbaImage :: new ( 0 , 0 ) , 0.0 , 0.0 ) ;
1084+ }
1085+
1086+ // Fast path: exact 90° multiples — direct integer pixel rearrangement,
1087+ // no resampling. The layout matches the bilinear path exactly.
1088+ let deg = angle_rad. to_degrees ( ) ;
1089+ let remainder = deg % 90.0 ;
1090+ if remainder. abs ( ) < 0.001 || ( 90.0 - remainder. abs ( ) ) . abs ( ) < 0.001 {
1091+ let normalized = ( ( deg % 360.0 ) + 360.0 ) % 360.0 ;
1092+ return match normalized. round ( ) as i32 {
1093+ 0 => ( src. clone ( ) , 0.0 , 0.0 ) ,
1094+ 90 => {
1095+ let mut dst = RgbaImage :: new ( src_h, src_w) ;
1096+ for y in 0 ..src_h {
1097+ for x in 0 ..src_w {
1098+ dst. put_pixel ( src_h - 1 - y, x, * src. get_pixel ( x, y) ) ;
1099+ }
1100+ }
1101+ ( dst, -( src_h as f32 ) , 0.0 )
1102+ }
1103+ 180 => {
1104+ let mut dst = RgbaImage :: new ( src_w, src_h) ;
1105+ for y in 0 ..src_h {
1106+ for x in 0 ..src_w {
1107+ dst. put_pixel ( src_w - 1 - x, src_h - 1 - y, * src. get_pixel ( x, y) ) ;
1108+ }
1109+ }
1110+ ( dst, -( src_w as f32 ) , -( src_h as f32 ) )
1111+ }
1112+ 270 => {
1113+ let mut dst = RgbaImage :: new ( src_h, src_w) ;
1114+ for y in 0 ..src_h {
1115+ for x in 0 ..src_w {
1116+ dst. put_pixel ( y, src_w - 1 - x, * src. get_pixel ( x, y) ) ;
1117+ }
1118+ }
1119+ ( dst, 0.0 , -( src_w as f32 ) )
1120+ }
1121+ _ => unreachable ! ( ) ,
1122+ } ;
1123+ }
1124+
1125+ let cos = angle_rad. cos ( ) ;
1126+ let sin = angle_rad. sin ( ) ;
1127+ let corners = [
1128+ rotate_point_top_left ( 0.0 , 0.0 , cos, sin) ,
1129+ rotate_point_top_left ( src_w as f32 , 0.0 , cos, sin) ,
1130+ rotate_point_top_left ( 0.0 , src_h as f32 , cos, sin) ,
1131+ rotate_point_top_left ( src_w as f32 , src_h as f32 , cos, sin) ,
1132+ ] ;
1133+
1134+ let min_x = corners
1135+ . iter ( )
1136+ . map ( |( x, _) | * x)
1137+ . fold ( f32:: INFINITY , f32:: min) ;
1138+ let max_x = corners
1139+ . iter ( )
1140+ . map ( |( x, _) | * x)
1141+ . fold ( f32:: NEG_INFINITY , f32:: max) ;
1142+ let min_y = corners
1143+ . iter ( )
1144+ . map ( |( _, y) | * y)
1145+ . fold ( f32:: INFINITY , f32:: min) ;
1146+ let max_y = corners
1147+ . iter ( )
1148+ . map ( |( _, y) | * y)
1149+ . fold ( f32:: NEG_INFINITY , f32:: max) ;
1150+
1151+ let dst_w = ( max_x - min_x) . ceil ( ) . max ( 1.0 ) as u32 ;
1152+ let dst_h = ( max_y - min_y) . ceil ( ) . max ( 1.0 ) as u32 ;
1153+
1154+ let mut dst = RgbaImage :: new ( dst_w, dst_h) ;
1155+ for y in 0 ..dst_h {
1156+ for x in 0 ..dst_w {
1157+ let world_x = x as f32 + min_x;
1158+ let world_y = y as f32 + min_y;
1159+ // Inverse rotation R(-θ): map destination pixel back to source.
1160+ // Forward R(θ): x' = cos·x - sin·y, y' = sin·x + cos·y
1161+ // Inverse R(-θ): x = cos·x' + sin·y', y = -sin·x' + cos·y'
1162+ let src_x = cos * world_x + sin * world_y;
1163+ let src_y = -sin * world_x + cos * world_y;
1164+ dst. put_pixel ( x, y, sample_bilinear_rgba ( src, src_x, src_y) ) ;
1165+ }
1166+ }
1167+ ( dst, min_x, min_y)
1168+ }
1169+
1170+ fn rotate_point_top_left ( x : f32 , y : f32 , cos : f32 , sin : f32 ) -> ( f32 , f32 ) {
1171+ // Matches CSS rotate(theta) matrix.
1172+ ( cos * x - sin * y, sin * x + cos * y)
1173+ }
1174+
1175+ /// Bilinear sample of an RGBA sprite.
1176+ ///
1177+ /// Interpolates RGB in premultiplied-alpha space and alpha in straight-alpha
1178+ /// space, then un-premultiplies once at the end. This is mathematically
1179+ /// equivalent to the standard "premultiply all four channels, interpolate,
1180+ /// un-premultiply" approach but avoids premultiplying alpha (which is
1181+ /// invariant under premultiplication) through the interpolation step.
1182+ fn sample_bilinear_rgba ( src : & RgbaImage , x : f32 , y : f32 ) -> Rgba < u8 > {
1183+ let max_x = src. width ( ) as f32 - 1.0 ;
1184+ let max_y = src. height ( ) as f32 - 1.0 ;
1185+ if x < 0.0 || y < 0.0 || x > max_x || y > max_y {
1186+ return Rgba ( [ 0 , 0 , 0 , 0 ] ) ;
1187+ }
1188+
1189+ let x0 = x. floor ( ) ;
1190+ let y0 = y. floor ( ) ;
1191+ let x1 = ( x0 + 1.0 ) . min ( max_x) ;
1192+ let y1 = ( y0 + 1.0 ) . min ( max_y) ;
1193+
1194+ let wx = x - x0;
1195+ let wy = y - y0;
1196+
1197+ let get_premul = |px : & image:: Rgba < u8 > | -> [ f32 ; 4 ] {
1198+ let a = px. 0 [ 3 ] as f32 / 255.0 ;
1199+ [
1200+ px. 0 [ 0 ] as f32 * a,
1201+ px. 0 [ 1 ] as f32 * a,
1202+ px. 0 [ 2 ] as f32 * a,
1203+ px. 0 [ 3 ] as f32 ,
1204+ ]
1205+ } ;
1206+
1207+ let p00 = get_premul ( src. get_pixel ( x0 as u32 , y0 as u32 ) ) ;
1208+ let p10 = get_premul ( src. get_pixel ( x1 as u32 , y0 as u32 ) ) ;
1209+ let p01 = get_premul ( src. get_pixel ( x0 as u32 , y1 as u32 ) ) ;
1210+ let p11 = get_premul ( src. get_pixel ( x1 as u32 , y1 as u32 ) ) ;
1211+
1212+ let lerp = |a : f32 , b : f32 , t : f32 | a + ( b - a) * t;
1213+
1214+ // Interpolate premultiplied RGB in float space (don't round yet).
1215+ let mut rgb_premul = [ 0.0f32 ; 3 ] ;
1216+ for i in 0 ..3 {
1217+ let top = lerp ( p00[ i] , p10[ i] , wx) ;
1218+ let bottom = lerp ( p01[ i] , p11[ i] , wx) ;
1219+ rgb_premul[ i] = lerp ( top, bottom, wy) ;
1220+ }
1221+
1222+ // Alpha is interpolated in straight-alpha space.
1223+ let top_a = lerp ( p00[ 3 ] , p10[ 3 ] , wx) ;
1224+ let bottom_a = lerp ( p01[ 3 ] , p11[ 3 ] , wx) ;
1225+ let alpha = lerp ( top_a, bottom_a, wy) . clamp ( 0.0 , 255.0 ) ;
1226+
1227+ let mut out = [ 0u8 ; 4 ] ;
1228+ let alpha_u8 = alpha. round ( ) as u8 ;
1229+ out[ 3 ] = alpha_u8;
1230+
1231+ // Un-premultiply once, then round — preserves sub-pixel precision.
1232+ if alpha_u8 != 0 {
1233+ for i in 0 ..3 {
1234+ out[ i] = ( rgb_premul[ i] * 255.0 / alpha) . round ( ) . clamp ( 0.0 , 255.0 ) as u8 ;
1235+ }
1236+ }
1237+ Rgba ( out)
10111238}
10121239
10131240// ---------------------------------------------------------------------------
@@ -1335,4 +1562,85 @@ mod tests {
13351562 assert_eq ! ( transform. x, 150.0 ) ;
13361563 assert_eq ! ( transform. y, 125.0 ) ;
13371564 }
1565+
1566+ // ------------------------------------------------------------------
1567+ // Rotation helpers
1568+ // ------------------------------------------------------------------
1569+
1570+ fn make_test_sprite ( w : u32 , h : u32 ) -> RgbaImage {
1571+ let mut img = RgbaImage :: new ( w, h) ;
1572+ // Paint a solid red pixel so we can verify the output is not empty.
1573+ img. put_pixel ( 0 , 0 , Rgba ( [ 255 , 0 , 0 , 255 ] ) ) ;
1574+ img
1575+ }
1576+
1577+ fn make_transform ( x : f32 , y : f32 , rotation_deg : f32 ) -> Transform {
1578+ Transform {
1579+ x,
1580+ y,
1581+ width : 100.0 ,
1582+ height : 50.0 ,
1583+ rotation_deg,
1584+ }
1585+ }
1586+
1587+ #[ test]
1588+ fn overlay_sprite_no_rotation_is_identity ( ) {
1589+ let mut canvas = RgbaImage :: new ( 300 , 300 ) ;
1590+ let sprite = make_test_sprite ( 20 , 20 ) ;
1591+ let transform = make_transform ( 50.0 , 50.0 , 0.0 ) ;
1592+ overlay_sprite_with_rotation ( & mut canvas, & sprite, & transform, false ) ;
1593+ // An unrotated sprite at (50, 50) should leave a red pixel there.
1594+ assert_eq ! ( canvas. get_pixel( 50 , 50 ) , & Rgba ( [ 255 , 0 , 0 , 255 ] ) ) ;
1595+ }
1596+
1597+ #[ test]
1598+ fn overlay_sprite_90deg_rotates ( ) {
1599+ let mut canvas = RgbaImage :: new ( 600 , 600 ) ;
1600+ let sprite = make_test_sprite ( 100 , 100 ) ;
1601+ let transform = make_transform ( 250.0 , 250.0 , 90.0 ) ;
1602+ overlay_sprite_with_rotation ( & mut canvas, & sprite, & transform, false ) ;
1603+ assert ! ( canvas. pixels( ) . any( |p| p. 0 [ 3 ] != 0 ) ) ;
1604+ }
1605+
1606+ #[ test]
1607+ fn overlay_sprite_180deg_rotates ( ) {
1608+ let mut canvas = RgbaImage :: new ( 600 , 600 ) ;
1609+ let sprite = make_test_sprite ( 100 , 100 ) ;
1610+ let transform = make_transform ( 250.0 , 250.0 , 180.0 ) ;
1611+ overlay_sprite_with_rotation ( & mut canvas, & sprite, & transform, false ) ;
1612+ assert ! ( canvas. pixels( ) . any( |p| p. 0 [ 3 ] != 0 ) ) ;
1613+ }
1614+
1615+ #[ test]
1616+ fn rotate_sprite_expand_zero_angle_is_identity ( ) {
1617+ let src = make_test_sprite ( 20 , 10 ) ;
1618+ let ( rotated, min_x, min_y) = rotate_sprite_expand_top_left ( & src, 0.0 ) ;
1619+ assert_eq ! ( rotated. width( ) , src. width( ) ) ;
1620+ assert_eq ! ( rotated. height( ) , src. height( ) ) ;
1621+ assert_eq ! ( min_x, 0.0 ) ;
1622+ assert_eq ! ( min_y, 0.0 ) ;
1623+ // The red pixel at (0,0) should be preserved.
1624+ assert_eq ! ( rotated. get_pixel( 0 , 0 ) , & Rgba ( [ 255 , 0 , 0 , 255 ] ) ) ;
1625+ }
1626+
1627+ #[ test]
1628+ fn rotate_sprite_expand_90deg_swaps_dimensions ( ) {
1629+ let src = make_test_sprite ( 40 , 20 ) ;
1630+ let ( rotated, _min_x, _min_y) =
1631+ rotate_sprite_expand_top_left ( & src, std:: f32:: consts:: FRAC_PI_2 ) ;
1632+ // 90° rotation swaps width and height exactly (fast path, no resampling).
1633+ assert_eq ! (
1634+ rotated. width( ) ,
1635+ 20 ,
1636+ "expected 20 wide, got {}" ,
1637+ rotated. width( )
1638+ ) ;
1639+ assert_eq ! (
1640+ rotated. height( ) ,
1641+ 40 ,
1642+ "expected 40 tall, got {}" ,
1643+ rotated. height( )
1644+ ) ;
1645+ }
13381646}
0 commit comments