Skip to content

Commit 4f3a747

Browse files
zachelnetDeepSeek V4
andcommitted
feat: implemented text block rotation
- Rotation slider in Render panel (-180°..180°) with range, ±1° stepper buttons, and direct numeric input — rotation is committed via the existing Op::UpdateNode pipeline and auto-rendered. - Rust renderer compositing: overlay_sprite_with_rotation rotates rendered sprites via bilinear sampling. Zero-cost fast-path when rotation ≈ 0° (direct imageops::overlay). - Rotation-aware resize: pointer deltas are projected to box-local axes (cos/sin) so edge handles behave intuitively on rotated blocks. - Block indicator and sprite apply rotation via CSS transform. - Resize-handle cursors follow the visual edge orientation at 45° steps (ns↔ew, nwse↔nesw). - Badge number counter-rotates to stay horizontal at any angle. - Panel layout: Render panel uses natural content height (no fixed h-60) so all sections fit without forced scrolling. - i18n: rotationLabel added to all 9 locales with translations. Co-authored-by: DeepSeek V4 <deepseek@v4.ai>
1 parent 2107843 commit 4f3a747

13 files changed

Lines changed: 303 additions & 44 deletions

File tree

crates/koharu-app/src/renderer.rs

Lines changed: 109 additions & 8 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,9 @@ 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 sprite_transform = out.expanded_transform.as_ref().unwrap_or(&input.transform);
207+
overlay_sprite_with_rotation(&mut canvas, &out.sprite.to_rgba8(), sprite_transform);
207208
}
208209
Ok(RenderOutput {
209210
final_render: DynamicImage::ImageRgba8(canvas),
@@ -1002,12 +1003,112 @@ fn find_input(blocks: &[RenderBlockInput], id: NodeId) -> &RenderBlockInput {
10021003
.expect("rendered_block must have matching input")
10031004
}
10041005

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)
1006+
fn overlay_sprite_with_rotation(canvas: &mut RgbaImage, sprite: &RgbaImage, transform: &Transform) {
1007+
let mut rotation_deg = transform.rotation_deg % 360.0;
1008+
if rotation_deg < 0.0 {
1009+
rotation_deg += 360.0;
1010+
}
1011+
1012+
if rotation_deg.abs() < 0.0001 || (360.0 - rotation_deg).abs() < 0.0001 {
1013+
imageops::overlay(
1014+
canvas,
1015+
sprite,
1016+
transform.x.round() as i64,
1017+
transform.y.round() as i64,
1018+
);
1019+
return;
1020+
}
1021+
1022+
let (rotated, min_x, min_y) = rotate_sprite_expand_top_left(sprite, rotation_deg.to_radians());
1023+
let origin_x = (transform.x + min_x).round() as i64;
1024+
let origin_y = (transform.y + min_y).round() as i64;
1025+
imageops::overlay(canvas, &rotated, origin_x, origin_y);
1026+
}
1027+
1028+
fn rotate_sprite_expand_top_left(src: &RgbaImage, angle_rad: f32) -> (RgbaImage, f32, f32) {
1029+
let src_w = src.width();
1030+
let src_h = src.height();
1031+
if src_w == 0 || src_h == 0 {
1032+
return (src.clone(), 0.0, 0.0);
1033+
}
1034+
1035+
let cos = angle_rad.cos();
1036+
let sin = angle_rad.sin();
1037+
let corners = [
1038+
rotate_point_top_left(0.0, 0.0, cos, sin),
1039+
rotate_point_top_left(src_w as f32, 0.0, cos, sin),
1040+
rotate_point_top_left(0.0, src_h as f32, cos, sin),
1041+
rotate_point_top_left(src_w as f32, src_h as f32, cos, sin),
1042+
];
1043+
1044+
let min_x = corners
1045+
.iter()
1046+
.map(|(x, _)| *x)
1047+
.fold(f32::INFINITY, f32::min);
1048+
let max_x = corners
1049+
.iter()
1050+
.map(|(x, _)| *x)
1051+
.fold(f32::NEG_INFINITY, f32::max);
1052+
let min_y = corners
1053+
.iter()
1054+
.map(|(_, y)| *y)
1055+
.fold(f32::INFINITY, f32::min);
1056+
let max_y = corners
1057+
.iter()
1058+
.map(|(_, y)| *y)
1059+
.fold(f32::NEG_INFINITY, f32::max);
1060+
1061+
let dst_w = (max_x - min_x).ceil().max(1.0) as u32;
1062+
let dst_h = (max_y - min_y).ceil().max(1.0) as u32;
1063+
1064+
let mut dst = RgbaImage::new(dst_w, dst_h);
1065+
for y in 0..dst_h {
1066+
for x in 0..dst_w {
1067+
let world_x = x as f32 + min_x;
1068+
let world_y = y as f32 + min_y;
1069+
// Inverse of CSS-like rotate(theta):
1070+
// x' = cos*x - sin*y
1071+
// y' = sin*x + cos*y
1072+
let src_x = cos * world_x + sin * world_y;
1073+
let src_y = -sin * world_x + cos * world_y;
1074+
dst.put_pixel(x, y, sample_bilinear_rgba(src, src_x, src_y));
1075+
}
1076+
}
1077+
(dst, min_x, min_y)
1078+
}
1079+
1080+
fn rotate_point_top_left(x: f32, y: f32, cos: f32, sin: f32) -> (f32, f32) {
1081+
// Matches CSS rotate(theta) matrix.
1082+
(cos * x - sin * y, sin * x + cos * y)
1083+
}
1084+
1085+
fn sample_bilinear_rgba(src: &RgbaImage, x: f32, y: f32) -> Rgba<u8> {
1086+
let max_x = src.width() as f32 - 1.0;
1087+
let max_y = src.height() as f32 - 1.0;
1088+
if x < 0.0 || y < 0.0 || x > max_x || y > max_y {
1089+
return Rgba([0, 0, 0, 0]);
1090+
}
1091+
1092+
let x0 = x.floor();
1093+
let y0 = y.floor();
1094+
let x1 = (x0 + 1.0).min(max_x);
1095+
let y1 = (y0 + 1.0).min(max_y);
1096+
1097+
let wx = x - x0;
1098+
let wy = y - y0;
1099+
1100+
let p00 = src.get_pixel(x0 as u32, y0 as u32).0;
1101+
let p10 = src.get_pixel(x1 as u32, y0 as u32).0;
1102+
let p01 = src.get_pixel(x0 as u32, y1 as u32).0;
1103+
let p11 = src.get_pixel(x1 as u32, y1 as u32).0;
1104+
1105+
let mut out = [0u8; 4];
1106+
for i in 0..4 {
1107+
let top = p00[i] as f32 * (1.0 - wx) + p10[i] as f32 * wx;
1108+
let bottom = p01[i] as f32 * (1.0 - wx) + p11[i] as f32 * wx;
1109+
out[i] = (top * (1.0 - wy) + bottom * wy).round().clamp(0.0, 255.0) as u8;
10101110
}
1111+
Rgba(out)
10111112
}
10121113

10131114
// ---------------------------------------------------------------------------

ui/components/Panels.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function Panels() {
2626
<div className='flex h-full min-h-0 w-full flex-col border-l bg-muted/50'>
2727
<Tabs
2828
defaultValue='layers'
29-
className='h-60 shrink-0 gap-0 border-b border-border'
29+
className='shrink-0 gap-0 border-b border-border'
3030
data-testid='panels-settings-tabs'
3131
>
3232
<TabsList className='m-2 mb-0 grid w-[calc(100%-1rem)] grid-cols-2 bg-muted/70'>
@@ -46,24 +46,20 @@ export function Panels() {
4646

4747
<TabsContent
4848
value='layers'
49-
className='min-h-0 flex-1 px-1 pb-2 data-[state=inactive]:hidden'
49+
className='overflow-y-auto max-h-60 px-1 pb-2 data-[state=inactive]:hidden'
5050
data-testid='panels-layers'
5151
>
52-
<ScrollArea className='h-full' viewportClassName='pr-1'>
53-
<LayersPanel />
54-
</ScrollArea>
52+
<LayersPanel />
5553
</TabsContent>
5654

5755
<TabsContent
5856
value='layout'
59-
className='min-h-0 flex-1 px-2 pb-2 data-[state=inactive]:hidden'
57+
className='overflow-y-auto px-2 pb-2 data-[state=inactive]:hidden'
6058
data-testid='panels-layout'
6159
>
62-
<ScrollArea className='h-full' viewportClassName='pr-1 [&>div]:!block'>
63-
<div className='pt-1'>
64-
<RenderControlsPanel />
65-
</div>
66-
</ScrollArea>
60+
<div className='pt-1'>
61+
<RenderControlsPanel />
62+
</div>
6763
</TabsContent>
6864
</Tabs>
6965

0 commit comments

Comments
 (0)