@@ -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) ,
@@ -1002,12 +1009,137 @@ fn find_input(blocks: &[RenderBlockInput], id: NodeId) -> &RenderBlockInput {
10021009 . expect ( "rendered_block must have matching input" )
10031010}
10041011
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 )
1012+ fn overlay_sprite_with_rotation (
1013+ canvas : & mut RgbaImage ,
1014+ sprite : & RgbaImage ,
1015+ transform : & Transform ,
1016+ is_expanded : bool ,
1017+ ) {
1018+ let mut rotation_deg = transform. rotation_deg % 360.0 ;
1019+ if rotation_deg < 0.0 {
1020+ rotation_deg += 360.0 ;
1021+ }
1022+
1023+ if rotation_deg. abs ( ) < 0.0001 || ( 360.0 - rotation_deg) . abs ( ) < 0.0001 {
1024+ // Preserve legacy placement: expanded transforms were rounded,
1025+ // non-expanded (original) transforms were truncated (cast to i64).
1026+ let ( ox, oy) = if is_expanded {
1027+ ( transform. x . round ( ) as i64 , transform. y . round ( ) as i64 )
1028+ } else {
1029+ ( transform. x as i64 , transform. y as i64 )
1030+ } ;
1031+ imageops:: overlay ( canvas, sprite, ox, oy) ;
1032+ return ;
1033+ }
1034+
1035+ let ( rotated, min_x, min_y) = rotate_sprite_expand_top_left ( sprite, rotation_deg. to_radians ( ) ) ;
1036+ let origin_x = ( transform. x + min_x) . round ( ) as i64 ;
1037+ let origin_y = ( transform. y + min_y) . round ( ) as i64 ;
1038+ imageops:: overlay ( canvas, & rotated, origin_x, origin_y) ;
1039+ }
1040+
1041+ fn rotate_sprite_expand_top_left ( src : & RgbaImage , angle_rad : f32 ) -> ( RgbaImage , f32 , f32 ) {
1042+ let src_w = src. width ( ) ;
1043+ let src_h = src. height ( ) ;
1044+ if src_w == 0 || src_h == 0 {
1045+ return ( src. clone ( ) , 0.0 , 0.0 ) ;
1046+ }
1047+
1048+ let cos = angle_rad. cos ( ) ;
1049+ let sin = angle_rad. sin ( ) ;
1050+ let corners = [
1051+ rotate_point_top_left ( 0.0 , 0.0 , cos, sin) ,
1052+ rotate_point_top_left ( src_w as f32 , 0.0 , cos, sin) ,
1053+ rotate_point_top_left ( 0.0 , src_h as f32 , cos, sin) ,
1054+ rotate_point_top_left ( src_w as f32 , src_h as f32 , cos, sin) ,
1055+ ] ;
1056+
1057+ let min_x = corners
1058+ . iter ( )
1059+ . map ( |( x, _) | * x)
1060+ . fold ( f32:: INFINITY , f32:: min) ;
1061+ let max_x = corners
1062+ . iter ( )
1063+ . map ( |( x, _) | * x)
1064+ . fold ( f32:: NEG_INFINITY , f32:: max) ;
1065+ let min_y = corners
1066+ . iter ( )
1067+ . map ( |( _, y) | * y)
1068+ . fold ( f32:: INFINITY , f32:: min) ;
1069+ let max_y = corners
1070+ . iter ( )
1071+ . map ( |( _, y) | * y)
1072+ . fold ( f32:: NEG_INFINITY , f32:: max) ;
1073+
1074+ let dst_w = ( max_x - min_x) . ceil ( ) . max ( 1.0 ) as u32 ;
1075+ let dst_h = ( max_y - min_y) . ceil ( ) . max ( 1.0 ) as u32 ;
1076+
1077+ let mut dst = RgbaImage :: new ( dst_w, dst_h) ;
1078+ for y in 0 ..dst_h {
1079+ for x in 0 ..dst_w {
1080+ let world_x = x as f32 + min_x;
1081+ let world_y = y as f32 + min_y;
1082+ // Inverse of CSS-like rotate(theta):
1083+ // x' = cos*x - sin*y
1084+ // y' = sin*x + cos*y
1085+ let src_x = cos * world_x + sin * world_y;
1086+ let src_y = -sin * world_x + cos * world_y;
1087+ dst. put_pixel ( x, y, sample_bilinear_rgba ( src, src_x, src_y) ) ;
1088+ }
1089+ }
1090+ ( dst, min_x, min_y)
1091+ }
1092+
1093+ fn rotate_point_top_left ( x : f32 , y : f32 , cos : f32 , sin : f32 ) -> ( f32 , f32 ) {
1094+ // Matches CSS rotate(theta) matrix.
1095+ ( cos * x - sin * y, sin * x + cos * y)
1096+ }
1097+
1098+ fn sample_bilinear_rgba ( src : & RgbaImage , x : f32 , y : f32 ) -> Rgba < u8 > {
1099+ let max_x = src. width ( ) as f32 - 1.0 ;
1100+ let max_y = src. height ( ) as f32 - 1.0 ;
1101+ if x < 0.0 || y < 0.0 || x > max_x || y > max_y {
1102+ return Rgba ( [ 0 , 0 , 0 , 0 ] ) ;
1103+ }
1104+
1105+ let x0 = x. floor ( ) ;
1106+ let y0 = y. floor ( ) ;
1107+ let x1 = ( x0 + 1.0 ) . min ( max_x) ;
1108+ let y1 = ( y0 + 1.0 ) . min ( max_y) ;
1109+
1110+ let wx = x - x0;
1111+ let wy = y - y0;
1112+
1113+ let get_premul = |px : & image:: Rgba < u8 > | -> [ f32 ; 4 ] {
1114+ let a = px. 0 [ 3 ] as f32 / 255.0 ;
1115+ [ px. 0 [ 0 ] as f32 * a, px. 0 [ 1 ] as f32 * a, px. 0 [ 2 ] as f32 * a, px. 0 [ 3 ] as f32 ]
1116+ } ;
1117+
1118+ let p00 = get_premul ( src. get_pixel ( x0 as u32 , y0 as u32 ) ) ;
1119+ let p10 = get_premul ( src. get_pixel ( x1 as u32 , y0 as u32 ) ) ;
1120+ let p01 = get_premul ( src. get_pixel ( x0 as u32 , y1 as u32 ) ) ;
1121+ let p11 = get_premul ( src. get_pixel ( x1 as u32 , y1 as u32 ) ) ;
1122+
1123+ let lerp = |a : f32 , b : f32 , t : f32 | a + ( b - a) * t;
1124+ let mut out = [ 0u8 ; 4 ] ;
1125+ for i in 0 ..3 {
1126+ let top = lerp ( p00[ i] , p10[ i] , wx) ;
1127+ let bottom = lerp ( p01[ i] , p11[ i] , wx) ;
1128+ out[ i] = lerp ( top, bottom, wy) . round ( ) . clamp ( 0.0 , 255.0 ) as u8 ;
1129+ }
1130+ // Alpha is interpolated in straight-alpha space (premultiplied alpha = original alpha).
1131+ let top_a = lerp ( p00[ 3 ] , p10[ 3 ] , wx) ;
1132+ let bottom_a = lerp ( p01[ 3 ] , p11[ 3 ] , wx) ;
1133+ let alpha = lerp ( top_a, bottom_a, wy) . round ( ) . clamp ( 0.0 , 255.0 ) ;
1134+ out[ 3 ] = alpha as u8 ;
1135+
1136+ // Un-premultiply: divide R, G, B by alpha (unless fully transparent).
1137+ if alpha > 0.0 {
1138+ for i in 0 ..3 {
1139+ out[ i] = ( ( out[ i] as f32 ) * 255.0 / alpha) . round ( ) . clamp ( 0.0 , 255.0 ) as u8 ;
1140+ }
10101141 }
1142+ Rgba ( out)
10111143}
10121144
10131145// ---------------------------------------------------------------------------
0 commit comments