Skip to content

Commit d01b760

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 d01b760

13 files changed

Lines changed: 338 additions & 44 deletions

File tree

crates/koharu-app/src/renderer.rs

Lines changed: 145 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,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,142 @@ 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+
[
1116+
px.0[0] as f32 * a,
1117+
px.0[1] as f32 * a,
1118+
px.0[2] as f32 * a,
1119+
px.0[3] as f32,
1120+
]
1121+
};
1122+
1123+
let p00 = get_premul(src.get_pixel(x0 as u32, y0 as u32));
1124+
let p10 = get_premul(src.get_pixel(x1 as u32, y0 as u32));
1125+
let p01 = get_premul(src.get_pixel(x0 as u32, y1 as u32));
1126+
let p11 = get_premul(src.get_pixel(x1 as u32, y1 as u32));
1127+
1128+
let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
1129+
let mut out = [0u8; 4];
1130+
for i in 0..3 {
1131+
let top = lerp(p00[i], p10[i], wx);
1132+
let bottom = lerp(p01[i], p11[i], wx);
1133+
out[i] = lerp(top, bottom, wy).round().clamp(0.0, 255.0) as u8;
1134+
}
1135+
// Alpha is interpolated in straight-alpha space (premultiplied alpha = original alpha).
1136+
let top_a = lerp(p00[3], p10[3], wx);
1137+
let bottom_a = lerp(p01[3], p11[3], wx);
1138+
let alpha = lerp(top_a, bottom_a, wy).round().clamp(0.0, 255.0);
1139+
out[3] = alpha as u8;
1140+
1141+
// Un-premultiply: divide R, G, B by alpha (unless fully transparent).
1142+
if alpha > 0.0 {
1143+
for i in 0..3 {
1144+
out[i] = ((out[i] as f32) * 255.0 / alpha).round().clamp(0.0, 255.0) as u8;
1145+
}
10101146
}
1147+
Rgba(out)
10111148
}
10121149

10131150
// ---------------------------------------------------------------------------

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)