Skip to content

Commit 2d0a7f4

Browse files
zachelnetDeepSeek V4
andcommitted
feat: add text block rotation support
Add rotation controls and rendering for text blocks across the full stack — Rust renderer, React canvas overlay, and settings panel. Renderer (koharu-app): - overlay_sprite_with_rotation: CSS top-left rotation model with bilinear resampling; 90/180/270° integer fast paths - sample_bilinear_rgba: premultiplied-alpha interpolation with float precision to avoid edge halos - sprite_collides_with_bubble_mask: rotation-aware collision check using rotated sprite matching render overlay - centred_sprite_transform: keeps rotated text visually centered UI (TextBlockLayer): - Rotated resize with local-coordinate projection - Rotated resize cursors via ROTATE_CURSOR_MAP (45° steps) - BlockSprite overlay matches renderer (center rotation origin) UI (RenderControlsPanel): - Range slider + number input + ± buttons - Draft-state pattern for smooth keyboard/slider interaction - Batch rotation across multi-selected nodes via ops.batch - Escape clears draft; commits on pointerUp/blur/keyUp Panels: max-h-60 constraint on layout tab. i18n: rotationLabel added to all 9 locales. Tests: 5 Rust rotation tests + 1 UI rotation test. Co-authored-by: DeepSeek V4 <deepseek@v4.ai>
1 parent 2107843 commit 2d0a7f4

14 files changed

Lines changed: 589 additions & 57 deletions

File tree

crates/koharu-app/src/renderer.rs

Lines changed: 319 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use std::{
1313
};
1414

1515
use anyhow::{Context, Result};
16-
use image::{DynamicImage, GrayImage, RgbaImage, imageops};
16+
use image::{DynamicImage, GrayImage, Rgba, RgbaImage, imageops};
1717
use 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

Comments
 (0)