From 2d0a7f4f904a1533b78cd31cdfbcde867bbac2c4 Mon Sep 17 00:00:00 2001 From: zachelnet Date: Thu, 16 Jul 2026 07:50:40 +0200 Subject: [PATCH 1/6] feat: add text block rotation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/koharu-app/src/renderer.rs | 330 +++++++++++++++++- ui/components/Panels.tsx | 18 +- ui/components/canvas/TextBlockLayer.tsx | 149 ++++++-- ui/components/panels/RenderControlsPanel.tsx | 126 +++++++ ui/public/locales/en-US/translation.json | 1 + ui/public/locales/es-ES/translation.json | 1 + ui/public/locales/ja-JP/translation.json | 1 + ui/public/locales/ko-KR/translation.json | 1 + ui/public/locales/pt-BR/translation.json | 1 + ui/public/locales/ru-RU/translation.json | 1 + ui/public/locales/tr-TR/translation.json | 1 + ui/public/locales/zh-CN/translation.json | 1 + ui/public/locales/zh-TW/translation.json | 1 + .../components/RenderControlsPanel.test.tsx | 14 + 14 files changed, 589 insertions(+), 57 deletions(-) diff --git a/crates/koharu-app/src/renderer.rs b/crates/koharu-app/src/renderer.rs index 0c9d1a92a..1ca401ec5 100644 --- a/crates/koharu-app/src/renderer.rs +++ b/crates/koharu-app/src/renderer.rs @@ -13,7 +13,7 @@ use std::{ }; use anyhow::{Context, Result}; -use image::{DynamicImage, GrayImage, RgbaImage, imageops}; +use image::{DynamicImage, GrayImage, Rgba, RgbaImage, imageops}; use koharu_core::{ FontFaceInfo, FontPrediction, FontSource, NodeId, TextDirection, TextShaderEffect, TextStrokeStyle, TextStyle, Transform, @@ -202,8 +202,15 @@ impl Renderer { imageops::overlay(&mut canvas, &brush.to_rgba8(), 0, 0); } for out in &rendered_blocks { - let (x, y) = placement_origin(find_input(blocks, out.node_id), &out.expanded_transform); - imageops::overlay(&mut canvas, &out.sprite.to_rgba8(), x as i64, y as i64); + let input = find_input(blocks, out.node_id); + let is_expanded = out.expanded_transform.is_some(); + let sprite_transform = out.expanded_transform.as_ref().unwrap_or(&input.transform); + overlay_sprite_with_rotation( + &mut canvas, + &out.sprite.to_rgba8(), + sprite_transform, + is_expanded, + ); } Ok(RenderOutput { final_render: DynamicImage::ImageRgba8(canvas), @@ -700,12 +707,45 @@ fn sprite_collides_with_bubble_mask( mask: &GrayImage, bubble_id: u8, ) -> bool { - let origin_x = transform.x.round() as i32; - let origin_y = transform.y.round() as i32; let mask_w = mask.width() as i32; let mask_h = mask.height() as i32; - for (x, y, pixel) in sprite.enumerate_pixels() { + let mut rotation_deg = transform.rotation_deg % 360.0; + if rotation_deg < 0.0 { + rotation_deg += 360.0; + } + + // Fast path: no rotation — check source pixels directly, no allocation. + if rotation_deg.abs() < 0.0001 || (360.0 - rotation_deg).abs() < 0.0001 { + let origin_x = transform.x.round() as i32; + let origin_y = transform.y.round() as i32; + for (x, y, pixel) in sprite.enumerate_pixels() { + if pixel.0[3] <= MASK_COLLISION_ALPHA_THRESHOLD { + continue; + } + let mask_x = origin_x + x as i32; + let mask_y = origin_y + y as i32; + if mask_x < 0 || mask_y < 0 || mask_x >= mask_w || mask_y >= mask_h { + return true; + } + if mask.get_pixel(mask_x as u32, mask_y as u32).0[0] != bubble_id { + return true; + } + } + return false; + } + + // Rotate the sprite first so the collision check uses the same + // bilinear-resampled pixels as the actual render overlay path. + let (rotated, _, _) = rotate_sprite_expand_top_left(sprite, rotation_deg.to_radians()); + + // Match the overlay origin computed by overlay_sprite_with_rotation. + let origin_x = + (transform.x + transform.width * 0.5 - rotated.width() as f32 * 0.5).round() as i32; + let origin_y = + (transform.y + transform.height * 0.5 - rotated.height() as f32 * 0.5).round() as i32; + + for (x, y, pixel) in rotated.enumerate_pixels() { if pixel.0[3] <= MASK_COLLISION_ALPHA_THRESHOLD { continue; } @@ -1002,12 +1042,199 @@ fn find_input(blocks: &[RenderBlockInput], id: NodeId) -> &RenderBlockInput { .expect("rendered_block must have matching input") } -fn placement_origin(input: &RenderBlockInput, expanded: &Option) -> (f32, f32) { - if let Some(t) = expanded { - (t.x.round(), t.y.round()) - } else { - (input.transform.x, input.transform.y) +fn overlay_sprite_with_rotation( + canvas: &mut RgbaImage, + sprite: &RgbaImage, + transform: &Transform, + is_expanded: bool, +) { + let mut rotation_deg = transform.rotation_deg % 360.0; + if rotation_deg < 0.0 { + rotation_deg += 360.0; + } + + if rotation_deg.abs() < 0.0001 || (360.0 - rotation_deg).abs() < 0.0001 { + // Preserve legacy placement: expanded transforms were rounded, + // non-expanded (original) transforms were truncated (cast to i64). + let (ox, oy) = if is_expanded { + (transform.x.round() as i64, transform.y.round() as i64) + } else { + (transform.x as i64, transform.y as i64) + }; + imageops::overlay(canvas, sprite, ox, oy); + return; } + + let (rotated, _, _) = rotate_sprite_expand_top_left(sprite, rotation_deg.to_radians()); + // The unrotated sprite is centered at (transform.x + w/2, transform.y + h/2). + // Align the rotated sprite's center to the same point so rotated text + // stays visually centered within its layout box. + let origin_x = + (transform.x + transform.width * 0.5 - rotated.width() as f32 * 0.5).round() as i64; + let origin_y = + (transform.y + transform.height * 0.5 - rotated.height() as f32 * 0.5).round() as i64; + imageops::overlay(canvas, &rotated, origin_x, origin_y); +} + +fn rotate_sprite_expand_top_left(src: &RgbaImage, angle_rad: f32) -> (RgbaImage, f32, f32) { + let src_w = src.width(); + let src_h = src.height(); + if src_w == 0 || src_h == 0 { + return (RgbaImage::new(0, 0), 0.0, 0.0); + } + + // Fast path: exact 90° multiples — direct integer pixel rearrangement, + // no resampling. The layout matches the bilinear path exactly. + let deg = angle_rad.to_degrees(); + let remainder = deg % 90.0; + if remainder.abs() < 0.001 || (90.0 - remainder.abs()).abs() < 0.001 { + let normalized = ((deg % 360.0) + 360.0) % 360.0; + return match normalized.round() as i32 { + 0 => (src.clone(), 0.0, 0.0), + 90 => { + let mut dst = RgbaImage::new(src_h, src_w); + for y in 0..src_h { + for x in 0..src_w { + dst.put_pixel(src_h - 1 - y, x, *src.get_pixel(x, y)); + } + } + (dst, -(src_h as f32), 0.0) + } + 180 => { + let mut dst = RgbaImage::new(src_w, src_h); + for y in 0..src_h { + for x in 0..src_w { + dst.put_pixel(src_w - 1 - x, src_h - 1 - y, *src.get_pixel(x, y)); + } + } + (dst, -(src_w as f32), -(src_h as f32)) + } + 270 => { + let mut dst = RgbaImage::new(src_h, src_w); + for y in 0..src_h { + for x in 0..src_w { + dst.put_pixel(y, src_w - 1 - x, *src.get_pixel(x, y)); + } + } + (dst, 0.0, -(src_w as f32)) + } + _ => unreachable!(), + }; + } + + let cos = angle_rad.cos(); + let sin = angle_rad.sin(); + let corners = [ + rotate_point_top_left(0.0, 0.0, cos, sin), + rotate_point_top_left(src_w as f32, 0.0, cos, sin), + rotate_point_top_left(0.0, src_h as f32, cos, sin), + rotate_point_top_left(src_w as f32, src_h as f32, cos, sin), + ]; + + let min_x = corners + .iter() + .map(|(x, _)| *x) + .fold(f32::INFINITY, f32::min); + let max_x = corners + .iter() + .map(|(x, _)| *x) + .fold(f32::NEG_INFINITY, f32::max); + let min_y = corners + .iter() + .map(|(_, y)| *y) + .fold(f32::INFINITY, f32::min); + let max_y = corners + .iter() + .map(|(_, y)| *y) + .fold(f32::NEG_INFINITY, f32::max); + + let dst_w = (max_x - min_x).ceil().max(1.0) as u32; + let dst_h = (max_y - min_y).ceil().max(1.0) as u32; + + let mut dst = RgbaImage::new(dst_w, dst_h); + for y in 0..dst_h { + for x in 0..dst_w { + let world_x = x as f32 + min_x; + let world_y = y as f32 + min_y; + // Inverse rotation R(-θ): map destination pixel back to source. + // Forward R(θ): x' = cos·x - sin·y, y' = sin·x + cos·y + // Inverse R(-θ): x = cos·x' + sin·y', y = -sin·x' + cos·y' + let src_x = cos * world_x + sin * world_y; + let src_y = -sin * world_x + cos * world_y; + dst.put_pixel(x, y, sample_bilinear_rgba(src, src_x, src_y)); + } + } + (dst, min_x, min_y) +} + +fn rotate_point_top_left(x: f32, y: f32, cos: f32, sin: f32) -> (f32, f32) { + // Matches CSS rotate(theta) matrix. + (cos * x - sin * y, sin * x + cos * y) +} + +/// Bilinear sample of an RGBA sprite. +/// +/// Interpolates RGB in premultiplied-alpha space and alpha in straight-alpha +/// space, then un-premultiplies once at the end. This is mathematically +/// equivalent to the standard "premultiply all four channels, interpolate, +/// un-premultiply" approach but avoids premultiplying alpha (which is +/// invariant under premultiplication) through the interpolation step. +fn sample_bilinear_rgba(src: &RgbaImage, x: f32, y: f32) -> Rgba { + let max_x = src.width() as f32 - 1.0; + let max_y = src.height() as f32 - 1.0; + if x < 0.0 || y < 0.0 || x > max_x || y > max_y { + return Rgba([0, 0, 0, 0]); + } + + let x0 = x.floor(); + let y0 = y.floor(); + let x1 = (x0 + 1.0).min(max_x); + let y1 = (y0 + 1.0).min(max_y); + + let wx = x - x0; + let wy = y - y0; + + let get_premul = |px: &image::Rgba| -> [f32; 4] { + let a = px.0[3] as f32 / 255.0; + [ + px.0[0] as f32 * a, + px.0[1] as f32 * a, + px.0[2] as f32 * a, + px.0[3] as f32, + ] + }; + + let p00 = get_premul(src.get_pixel(x0 as u32, y0 as u32)); + let p10 = get_premul(src.get_pixel(x1 as u32, y0 as u32)); + let p01 = get_premul(src.get_pixel(x0 as u32, y1 as u32)); + let p11 = get_premul(src.get_pixel(x1 as u32, y1 as u32)); + + let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t; + + // Interpolate premultiplied RGB in float space (don't round yet). + let mut rgb_premul = [0.0f32; 3]; + for i in 0..3 { + let top = lerp(p00[i], p10[i], wx); + let bottom = lerp(p01[i], p11[i], wx); + rgb_premul[i] = lerp(top, bottom, wy); + } + + // Alpha is interpolated in straight-alpha space. + let top_a = lerp(p00[3], p10[3], wx); + let bottom_a = lerp(p01[3], p11[3], wx); + let alpha = lerp(top_a, bottom_a, wy).clamp(0.0, 255.0); + + let mut out = [0u8; 4]; + let alpha_u8 = alpha.round() as u8; + out[3] = alpha_u8; + + // Un-premultiply once, then round — preserves sub-pixel precision. + if alpha_u8 != 0 { + for i in 0..3 { + out[i] = (rgb_premul[i] * 255.0 / alpha).round().clamp(0.0, 255.0) as u8; + } + } + Rgba(out) } // --------------------------------------------------------------------------- @@ -1335,4 +1562,85 @@ mod tests { assert_eq!(transform.x, 150.0); assert_eq!(transform.y, 125.0); } + + // ------------------------------------------------------------------ + // Rotation helpers + // ------------------------------------------------------------------ + + fn make_test_sprite(w: u32, h: u32) -> RgbaImage { + let mut img = RgbaImage::new(w, h); + // Paint a solid red pixel so we can verify the output is not empty. + img.put_pixel(0, 0, Rgba([255, 0, 0, 255])); + img + } + + fn make_transform(x: f32, y: f32, rotation_deg: f32) -> Transform { + Transform { + x, + y, + width: 100.0, + height: 50.0, + rotation_deg, + } + } + + #[test] + fn overlay_sprite_no_rotation_is_identity() { + let mut canvas = RgbaImage::new(300, 300); + let sprite = make_test_sprite(20, 20); + let transform = make_transform(50.0, 50.0, 0.0); + overlay_sprite_with_rotation(&mut canvas, &sprite, &transform, false); + // An unrotated sprite at (50, 50) should leave a red pixel there. + assert_eq!(canvas.get_pixel(50, 50), &Rgba([255, 0, 0, 255])); + } + + #[test] + fn overlay_sprite_90deg_rotates() { + let mut canvas = RgbaImage::new(600, 600); + let sprite = make_test_sprite(100, 100); + let transform = make_transform(250.0, 250.0, 90.0); + overlay_sprite_with_rotation(&mut canvas, &sprite, &transform, false); + assert!(canvas.pixels().any(|p| p.0[3] != 0)); + } + + #[test] + fn overlay_sprite_180deg_rotates() { + let mut canvas = RgbaImage::new(600, 600); + let sprite = make_test_sprite(100, 100); + let transform = make_transform(250.0, 250.0, 180.0); + overlay_sprite_with_rotation(&mut canvas, &sprite, &transform, false); + assert!(canvas.pixels().any(|p| p.0[3] != 0)); + } + + #[test] + fn rotate_sprite_expand_zero_angle_is_identity() { + let src = make_test_sprite(20, 10); + let (rotated, min_x, min_y) = rotate_sprite_expand_top_left(&src, 0.0); + assert_eq!(rotated.width(), src.width()); + assert_eq!(rotated.height(), src.height()); + assert_eq!(min_x, 0.0); + assert_eq!(min_y, 0.0); + // The red pixel at (0,0) should be preserved. + assert_eq!(rotated.get_pixel(0, 0), &Rgba([255, 0, 0, 255])); + } + + #[test] + fn rotate_sprite_expand_90deg_swaps_dimensions() { + let src = make_test_sprite(40, 20); + let (rotated, _min_x, _min_y) = + rotate_sprite_expand_top_left(&src, std::f32::consts::FRAC_PI_2); + // 90° rotation swaps width and height exactly (fast path, no resampling). + assert_eq!( + rotated.width(), + 20, + "expected 20 wide, got {}", + rotated.width() + ); + assert_eq!( + rotated.height(), + 40, + "expected 40 tall, got {}", + rotated.height() + ); + } } diff --git a/ui/components/Panels.tsx b/ui/components/Panels.tsx index bafc0039e..66ca5c6bd 100644 --- a/ui/components/Panels.tsx +++ b/ui/components/Panels.tsx @@ -26,7 +26,7 @@ export function Panels() {
@@ -46,24 +46,20 @@ export function Panels() { - - - + - -
- -
-
+
+ +
diff --git a/ui/components/canvas/TextBlockLayer.tsx b/ui/components/canvas/TextBlockLayer.tsx index 77b97acd1..af9afb5a4 100644 --- a/ui/components/canvas/TextBlockLayer.tsx +++ b/ui/components/canvas/TextBlockLayer.tsx @@ -1,7 +1,7 @@ 'use client' import { useDrag } from '@use-gesture/react' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { useBlobImage } from '@/hooks/useBlobData' import { useCurrentPage, useTextNodes, type TextNodeEntry } from '@/hooks/useCurrentPage' @@ -17,6 +17,37 @@ type TextBlockLayerProps = { style?: React.CSSProperties } +type BoxGeometry = { + x: number + y: number + width: number + height: number +} + +/** + * Maps CSS resize cursors through 45° rotation steps. + * + * Each entry is a ring of 8 cursor names at 0°, 45°, 90°, …, 315°. + * - Index 0: 0° (identity) + * - Index 1: 45° + * - Index 2: 90° + * - … + * - Index 7: 315° + * + * Unknown cursors fall back to the original name (see `rotateCursor`). + */ +const ROTATE_CURSOR_MAP: Record = { + 'ns-resize': ['ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize'], + 'ew-resize': ['ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize'], + 'nwse-resize': ['nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize'], + 'nesw-resize': ['nesw-resize', 'ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize', 'ns-resize'], +} + +const rotateCursor = (cursor: string, deg: number): string => { + const steps = Math.round(((deg % 360) + 360) % 360 / 45) % 8 + return ROTATE_CURSOR_MAP[cursor]?.[steps] ?? cursor +} + /** * Overlay for the active page's Text nodes. Each rectangle is draggable / * resizable; commits dispatch `Op::UpdateNode { transform }` through @@ -30,16 +61,27 @@ export function TextBlockLayer({ showSprites, scale, style }: TextBlockLayerProp const mode = useEditorUiStore((s) => s.mode) const interactive = mode === 'select' || mode === 'block' - const updateTransform = async (id: string, t: Transform) => { - if (!page) return - const data: NodeDataPatch = { - text: { - lockLayoutBox: true, - }, - } - await applyOp(ops.updateNode(page.id, id, { transform: t, data })) - queueAutoRender(page.id) - } + const updateTransform = useCallback( + async (id: string, geometry: BoxGeometry) => { + if (!page) return + const rotationDeg = page.nodes[id]?.transform?.rotationDeg ?? 0 + const next: Transform = { + x: geometry.x, + y: geometry.y, + width: geometry.width, + height: geometry.height, + rotationDeg, + } + const data: NodeDataPatch = { + text: { + lockLayoutBox: true, + }, + } + await applyOp(ops.updateNode(page.id, id, { transform: next, data })) + queueAutoRender(page.id) + }, + [page, applyOp, queueAutoRender], + ) return (
select(id, additive)} - onCommit={(t) => void updateTransform(n.id, t)} + onCommit={(geometry) => void updateTransform(n.id, geometry)} /> ))}
@@ -78,7 +120,7 @@ type TextBlockItemProps = { selected: boolean interactive: boolean onSelect: (id: string, additive: boolean) => void - onCommit: (transform: Transform) => void + onCommit: (geometry: BoxGeometry) => void } const isAdditiveEvent = (event: unknown): boolean => { @@ -114,7 +156,7 @@ function TextBlockItem({ const setBox = (x: number, y: number, w: number, h: number) => { const el = boxRef.current if (!el) return - el.style.transform = `translate(${x}px, ${y}px)` + el.style.transform = `translate(${x}px, ${y}px) rotate(${t.rotationDeg ?? 0}deg)` el.style.width = `${w}px` el.style.height = `${h}px` } @@ -146,34 +188,48 @@ function TextBlockItem({ const { x: sx, y: sy, w: sw, h: sh } = dragStart.current const edge = edgeRef.current if (isResizeRef.current && edge) { - let dx = 0 - let dy = 0 + const rotationRad = ((t.rotationDeg ?? 0) * Math.PI) / 180 + const cos = Math.cos(rotationRad) + const sin = Math.sin(rotationRad) + + // Project pointer movement to box-local axes so resize directions + // remain intuitive even when the box is rotated. + const localDx = mx * cos + my * sin + const localDy = -mx * sin + my * cos + + let moveLocalX = 0 + let moveLocalY = 0 let w = sw let h = sh - if (edge.right) w += mx + if (edge.right) w += localDx if (edge.left) { - w -= mx - dx = mx + w -= localDx + moveLocalX = localDx } - if (edge.bottom) h += my + if (edge.bottom) h += localDy if (edge.top) { - h -= my - dy = my + h -= localDy + moveLocalY = localDy } w = Math.max(4 * scale, w) h = Math.max(4 * scale, h) - if (edge.left && w === 4 * scale) dx = sw - 4 * scale - if (edge.top && h === 4 * scale) dy = sh - 4 * scale - setBox(sx + dx, sy + dy, w, h) + if (edge.left && w === 4 * scale) moveLocalX = sw - 4 * scale + if (edge.top && h === 4 * scale) moveLocalY = sh - 4 * scale + + const worldDx = moveLocalX * cos - moveLocalY * sin + const worldDy = moveLocalX * sin + moveLocalY * cos + const nextX = sx + worldDx + const nextY = sy + worldDy + + setBox(nextX, nextY, w, h) if (last) { isResizeRef.current = false edgeRef.current = null onCommit({ - x: Math.round((sx + dx) / scale), - y: Math.round((sy + dy) / scale), + x: Math.round(nextX / scale), + y: Math.round(nextY / scale), width: Math.max(4, Math.round(w / scale)), height: Math.max(4, Math.round(h / scale)), - rotationDeg: t.rotationDeg ?? 0, }) } } else { @@ -184,7 +240,6 @@ function TextBlockItem({ y: Math.round((sy + my) / scale), width: t.width, height: t.height, - rotationDeg: t.rotationDeg ?? 0, }) } } @@ -215,7 +270,8 @@ function TextBlockItem({ position: 'absolute', top: 0, left: 0, - transform: `translate(${t.x * scale}px, ${t.y * scale}px)`, + transform: `translate(${t.x * scale}px, ${t.y * scale}px) rotate(${t.rotationDeg ?? 0}deg)`, + transformOrigin: 'top left', width: w, height: h, pointerEvents: interactive ? 'auto' : 'none', @@ -236,10 +292,16 @@ function TextBlockItem({ className={`pointer-events-none absolute -top-1.5 -left-1.5 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-semibold text-white shadow ${ selected ? 'bg-primary' : 'bg-rose-400' }`} + style={{ transform: `rotate(${t.rotationDeg ? -t.rotationDeg : 0}deg)` }} > {index + 1}
- {selected && interactive && } + {selected && interactive && ( + + )} ) } @@ -251,6 +313,13 @@ function BlockSprite({ node, scale }: { node: TextNodeEntry; scale: number }) { const spriteT = node.data.spriteTransform const x = (spriteT?.x ?? node.transform.x) * scale const y = (spriteT?.y ?? node.transform.y) * scale + const rotation = spriteT?.rotationDeg ?? node.transform.rotationDeg ?? 0 + // Renderer centers the rotated sprite; match that model by rotating + // around the sprite's center rather than top-left. + const sw = (spriteT?.width ?? node.transform.width) * scale + const sh = (spriteT?.height ?? node.transform.height) * scale + const cx = x + sw * 0.5 + const cy = y + sh * 0.5 return ( ) } -function ResizeHandles({ onEdgePointerDown }: { onEdgePointerDown: (edge: ResizeEdge) => void }) { +function ResizeHandles({ + onEdgePointerDown, + rotationDeg, +}: { + onEdgePointerDown: (edge: ResizeEdge) => void + rotationDeg: number +}) { const s = RESIZE_HANDLE_SIZE const half = s / 2 @@ -320,7 +394,12 @@ function ResizeHandles({ onEdgePointerDown }: { onEdgePointerDown: (edge: Resize
onEdgePointerDown(e.edge)} - style={{ position: 'absolute', ...e.style, cursor: e.cursor, zIndex: 30 }} + style={{ + position: 'absolute', + ...e.style, + cursor: rotateCursor(e.cursor, rotationDeg), + zIndex: 30, + }} /> ))} diff --git a/ui/components/panels/RenderControlsPanel.tsx b/ui/components/panels/RenderControlsPanel.tsx index 56a3759e9..9953bbab7 100644 --- a/ui/components/panels/RenderControlsPanel.tsx +++ b/ui/components/panels/RenderControlsPanel.tsx @@ -64,6 +64,9 @@ const MIN_STROKE_WIDTH = 0.2 const MAX_STROKE_WIDTH = 24 const STROKE_WIDTH_STEP = 0.1 +const MIN_ROTATION_DEG = -180 +const MAX_ROTATION_DEG = 180 + const DEFAULT_FONT_FACES: FontFaceInfo[] = [ { familyName: 'Arial', @@ -76,6 +79,7 @@ const DEFAULT_FONT_FACES: FontFaceInfo[] = [ const clampByte = (v: number) => Math.max(0, Math.min(255, Math.round(v))) const clampStrokeWidth = (v: number) => Number(Math.max(MIN_STROKE_WIDTH, Math.min(MAX_STROKE_WIDTH, v)).toFixed(1)) +const clampRotationDeg = (v: number) => Math.max(MIN_ROTATION_DEG, Math.min(MAX_ROTATION_DEG, v)) const colorToHex = (color: number[]) => `#${color @@ -347,6 +351,48 @@ export function RenderControlsPanel() { applyStrokeSetting({ ...currentStroke, widthPx: clampStrokeWidth(value) }) } + const updateSelectedRotation = async (value: number) => { + if (!page || selectedNodes.length === 0) return + const deg = clampRotationDeg(value) + const buildOp = (n: TextNodeEntry) => + ops.updateNode(page.id, n.id, { + transform: { ...n.transform, rotationDeg: deg }, + }) + const op = + selectedNodes.length === 1 + ? buildOp(selectedNodes[0]) + : ops.batch( + 'Multi-block rotation', + selectedNodes.map((n) => buildOp(n)), + ) + await applyOp(op) + queueAutoRender(page.id) + } + + // Local draft state for the rotation number input so that intermediate + // values like "-" (needed to type negative angles) are not discarded by + // the controlled-input cycle. + const [rotationDraft, setRotationDraft] = useState(null) + + // Sync the draft when selected node or its rotation changes externally. + useEffect(() => { + setRotationDraft(null) + }, [selectedNode?.id, selectedNode?.transform.rotationDeg]) + + const commitRotationDraft = () => { + if (rotationDraft == null) return + const parsed = Number.parseFloat(rotationDraft) + setRotationDraft(null) + if (!Number.isFinite(parsed)) return + void updateSelectedRotation(parsed) + } + + // Current rotation value, preferring the draft when it's a valid number. + const currentDeg = + rotationDraft != null && Number.isFinite(Number.parseFloat(rotationDraft)) + ? clampRotationDeg(Number.parseFloat(rotationDraft)) + : (selectedNode?.transform.rotationDeg ?? 0) + const effectItems: { key: 'italic' | 'bold' label: string @@ -768,6 +814,86 @@ export function RenderControlsPanel() {
+ + {/* Rotation */} +
+ + {t('render.rotationLabel')} + +
+ + + + + + -1° + + + + { + setRotationDraft(event.target.value) + }} + onPointerUp={commitRotationDraft} + onKeyUp={commitRotationDraft} + onBlur={commitRotationDraft} + className='h-7 min-w-0 flex-1 cursor-pointer accent-primary' + /> + +
+ { + setRotationDraft(event.target.value) + }} + onBlur={commitRotationDraft} + onKeyDown={(event) => { + if (event.key === 'Enter') { + (event.target as HTMLInputElement).blur() + } else if (event.key === 'Escape') { + setRotationDraft(null) + } + }} + /> + + ° + + +
+
+
) } diff --git a/ui/public/locales/en-US/translation.json b/ui/public/locales/en-US/translation.json index 5cdb145ac..ad16ea5ba 100644 --- a/ui/public/locales/en-US/translation.json +++ b/ui/public/locales/en-US/translation.json @@ -235,6 +235,7 @@ "effectItalic": "Italic", "effectBold": "Bold", "effectBorder": "Border", + "rotationLabel": "Rotation", "strokeColorLabel": "Stroke color", "strokeWidthLabel": "Stroke width", "fontLabel": "Font", diff --git a/ui/public/locales/es-ES/translation.json b/ui/public/locales/es-ES/translation.json index 17084fbac..7f1f7b1f7 100644 --- a/ui/public/locales/es-ES/translation.json +++ b/ui/public/locales/es-ES/translation.json @@ -210,6 +210,7 @@ "effectItalic": "Cursiva", "effectBold": "Negrita", "effectBorder": "Borde", + "rotationLabel": "Rotación", "strokeColorLabel": "Color del borde", "strokeWidthLabel": "Grosor del borde", "fontWeights": { diff --git a/ui/public/locales/ja-JP/translation.json b/ui/public/locales/ja-JP/translation.json index 2fe195ab4..d5496e9a2 100644 --- a/ui/public/locales/ja-JP/translation.json +++ b/ui/public/locales/ja-JP/translation.json @@ -210,6 +210,7 @@ "effectItalic": "斜体", "effectBold": "太字", "effectBorder": "縁取り", + "rotationLabel": "回転", "strokeColorLabel": "縁取りの色", "strokeWidthLabel": "縁取りの太さ", "fontWeights": { diff --git a/ui/public/locales/ko-KR/translation.json b/ui/public/locales/ko-KR/translation.json index f9ec55422..94c28a56a 100644 --- a/ui/public/locales/ko-KR/translation.json +++ b/ui/public/locales/ko-KR/translation.json @@ -223,6 +223,7 @@ "effectItalic": "기울임꼴", "effectBold": "굵게", "effectBorder": "테두리", + "rotationLabel": "회전", "strokeColorLabel": "테두리 색상", "strokeWidthLabel": "테두리 두께", "fontLabel": "글꼴", diff --git a/ui/public/locales/pt-BR/translation.json b/ui/public/locales/pt-BR/translation.json index ad891bfc6..da1753d5d 100644 --- a/ui/public/locales/pt-BR/translation.json +++ b/ui/public/locales/pt-BR/translation.json @@ -202,6 +202,7 @@ "effectItalic": "Itálico", "effectBold": "Negrito", "effectBorder": "Contorno", + "rotationLabel": "Rotação", "strokeColorLabel": "Cor do contorno", "strokeWidthLabel": "Espessura do contorno", "fontLabel": "Fonte", diff --git a/ui/public/locales/ru-RU/translation.json b/ui/public/locales/ru-RU/translation.json index 62b06211f..63e97ee1d 100644 --- a/ui/public/locales/ru-RU/translation.json +++ b/ui/public/locales/ru-RU/translation.json @@ -210,6 +210,7 @@ "effectItalic": "Курсив", "effectBold": "Жирный", "effectBorder": "Обводка", + "rotationLabel": "Поворот", "strokeColorLabel": "Цвет обводки", "strokeWidthLabel": "Толщина обводки", "fontWeights": { diff --git a/ui/public/locales/tr-TR/translation.json b/ui/public/locales/tr-TR/translation.json index b062d6fe9..83500361d 100644 --- a/ui/public/locales/tr-TR/translation.json +++ b/ui/public/locales/tr-TR/translation.json @@ -201,6 +201,7 @@ "effectItalic": "İtalik", "effectBold": "Kalın", "effectBorder": "Kenarlık", + "rotationLabel": "Döndürme", "strokeColorLabel": "Kenarlık rengi", "strokeWidthLabel": "Kenarlık kalınlığı", "fontLabel": "Yazı tipi", diff --git a/ui/public/locales/zh-CN/translation.json b/ui/public/locales/zh-CN/translation.json index 60bfeb1af..51e9c274f 100644 --- a/ui/public/locales/zh-CN/translation.json +++ b/ui/public/locales/zh-CN/translation.json @@ -210,6 +210,7 @@ "effectItalic": "斜体", "effectBold": "粗体", "effectBorder": "描边", + "rotationLabel": "旋转", "strokeColorLabel": "描边颜色", "strokeWidthLabel": "描边宽度", "fontWeights": { diff --git a/ui/public/locales/zh-TW/translation.json b/ui/public/locales/zh-TW/translation.json index 973fac145..ac60bed12 100644 --- a/ui/public/locales/zh-TW/translation.json +++ b/ui/public/locales/zh-TW/translation.json @@ -210,6 +210,7 @@ "effectItalic": "斜體", "effectBold": "粗體", "effectBorder": "描邊", + "rotationLabel": "旋轉", "strokeColorLabel": "描邊顏色", "strokeWidthLabel": "描邊寬度", "fontWeights": { diff --git a/ui/tests/components/RenderControlsPanel.test.tsx b/ui/tests/components/RenderControlsPanel.test.tsx index 80c0f2d77..8af24400f 100644 --- a/ui/tests/components/RenderControlsPanel.test.tsx +++ b/ui/tests/components/RenderControlsPanel.test.tsx @@ -199,4 +199,18 @@ describe('RenderControlsPanel Font Assignment', () => { expect(op.updateNode.id).toBe('t1') expect(op.updateNode.patch.data.text.style.color).toEqual([0, 0, 0, 255]) }) + + it('changing rotation via the + button dispatches updateNode with rotationDeg updated', async () => { + renderWithQuery() + useSelectionStore.getState().select('t1', false) + + const plusBtn = await screen.findByTestId('render-rotation-plus') + await userEvent.click(plusBtn) + + await waitFor(() => expect(sceneActions.applyOp).toHaveBeenCalled()) + const op = (sceneActions.applyOp as any).mock.calls[0][0] + expect(op.updateNode.id).toBe('t1') + expect(op.updateNode.patch.transform.rotationDeg).toBe(1) + expect(sceneActions.queueAutoRender).toHaveBeenCalled() + }) }) From 10164796035667e9ee235b07a490987fac105a33 Mon Sep 17 00:00:00 2001 From: zachelnet Date: Thu, 16 Jul 2026 10:36:34 +0200 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20lock=20layout=20on=20rotation,=20pre?= =?UTF-8?q?vent=20360=C2=B0=20fast-path=20panic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateSelectedRotation now sets lockLayoutBox:true so manual rotation survives subsequent layout passes (matches drag/resize). - rotate_sprite_expand_top_left: use rem_euclid(4) for quadrant selection to avoid panic when normalized is near 360° (e.g. 359.9996° rounds to 360, hitting unreachable!()). Co-authored-by: DeepSeek V4 --- crates/koharu-app/src/renderer.rs | 3 ++- ui/components/panels/RenderControlsPanel.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/koharu-app/src/renderer.rs b/crates/koharu-app/src/renderer.rs index 1ca401ec5..2cfb5ee95 100644 --- a/crates/koharu-app/src/renderer.rs +++ b/crates/koharu-app/src/renderer.rs @@ -1089,7 +1089,8 @@ fn rotate_sprite_expand_top_left(src: &RgbaImage, angle_rad: f32) -> (RgbaImage, let remainder = deg % 90.0; if remainder.abs() < 0.001 || (90.0 - remainder.abs()).abs() < 0.001 { let normalized = ((deg % 360.0) + 360.0) % 360.0; - return match normalized.round() as i32 { + let quadrant = ((normalized / 90.0).round() as i32).rem_euclid(4) * 90; + return match quadrant { 0 => (src.clone(), 0.0, 0.0), 90 => { let mut dst = RgbaImage::new(src_h, src_w); diff --git a/ui/components/panels/RenderControlsPanel.tsx b/ui/components/panels/RenderControlsPanel.tsx index 9953bbab7..13e2d7e16 100644 --- a/ui/components/panels/RenderControlsPanel.tsx +++ b/ui/components/panels/RenderControlsPanel.tsx @@ -357,6 +357,7 @@ export function RenderControlsPanel() { const buildOp = (n: TextNodeEntry) => ops.updateNode(page.id, n.id, { transform: { ...n.transform, rotationDeg: deg }, + data: { text: { lockLayoutBox: true } } as never, }) const op = selectedNodes.length === 1 From ee7cc25bb53247ed6d2ec7f0326dffb4ed797ea3 Mon Sep 17 00:00:00 2001 From: zachelnet Date: Thu, 16 Jul 2026 21:40:43 +0200 Subject: [PATCH 3/6] fix(ui): revert BlockSprite to top-left origin to fix zoom drift The center-based BlockSprite positioning (transformOrigin: center / translate(-50%, -50%)) caused the sprite overlay to drift relative to the text block when zooming. The translate(-50%, -50%) depends on the element's rendered dimensions which can briefly change during re-render. Revert to the original top-left model with rotate() as an addition. The renderer still centers rotated sprites correctly; the UI overlay is a preview where minor rotation offset is acceptable. Co-authored-by: DeepSeek V4 --- ui/components/canvas/TextBlockLayer.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ui/components/canvas/TextBlockLayer.tsx b/ui/components/canvas/TextBlockLayer.tsx index af9afb5a4..0129c2fe7 100644 --- a/ui/components/canvas/TextBlockLayer.tsx +++ b/ui/components/canvas/TextBlockLayer.tsx @@ -316,10 +316,6 @@ function BlockSprite({ node, scale }: { node: TextNodeEntry; scale: number }) { const rotation = spriteT?.rotationDeg ?? node.transform.rotationDeg ?? 0 // Renderer centers the rotated sprite; match that model by rotating // around the sprite's center rather than top-left. - const sw = (spriteT?.width ?? node.transform.width) * scale - const sh = (spriteT?.height ?? node.transform.height) * scale - const cx = x + sw * 0.5 - const cy = y + sh * 0.5 return ( ) From 9845140b13b1ea1362f137a6f32b13946fa04e35 Mon Sep 17 00:00:00 2001 From: zachelnet Date: Fri, 17 Jul 2026 17:58:26 +0200 Subject: [PATCH 4/6] refactor: remove as never cast, extract normalize_rotation helper Co-authored-by: DeepSeek V4 --- crates/koharu-app/src/renderer.rs | 19 +++++++++++-------- ui/components/panels/RenderControlsPanel.tsx | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/koharu-app/src/renderer.rs b/crates/koharu-app/src/renderer.rs index 2cfb5ee95..67d9dd452 100644 --- a/crates/koharu-app/src/renderer.rs +++ b/crates/koharu-app/src/renderer.rs @@ -710,10 +710,7 @@ fn sprite_collides_with_bubble_mask( let mask_w = mask.width() as i32; let mask_h = mask.height() as i32; - let mut rotation_deg = transform.rotation_deg % 360.0; - if rotation_deg < 0.0 { - rotation_deg += 360.0; - } + let rotation_deg = normalize_rotation(transform.rotation_deg); // Fast path: no rotation — check source pixels directly, no allocation. if rotation_deg.abs() < 0.0001 || (360.0 - rotation_deg).abs() < 0.0001 { @@ -1042,16 +1039,22 @@ fn find_input(blocks: &[RenderBlockInput], id: NodeId) -> &RenderBlockInput { .expect("rendered_block must have matching input") } +/// Normalize a rotation angle to [0, 360). +fn normalize_rotation(deg: f32) -> f32 { + let mut r = deg % 360.0; + if r < 0.0 { + r += 360.0; + } + r +} + fn overlay_sprite_with_rotation( canvas: &mut RgbaImage, sprite: &RgbaImage, transform: &Transform, is_expanded: bool, ) { - let mut rotation_deg = transform.rotation_deg % 360.0; - if rotation_deg < 0.0 { - rotation_deg += 360.0; - } + let rotation_deg = normalize_rotation(transform.rotation_deg); if rotation_deg.abs() < 0.0001 || (360.0 - rotation_deg).abs() < 0.0001 { // Preserve legacy placement: expanded transforms were rounded, diff --git a/ui/components/panels/RenderControlsPanel.tsx b/ui/components/panels/RenderControlsPanel.tsx index 13e2d7e16..c989f1ac4 100644 --- a/ui/components/panels/RenderControlsPanel.tsx +++ b/ui/components/panels/RenderControlsPanel.tsx @@ -357,7 +357,7 @@ export function RenderControlsPanel() { const buildOp = (n: TextNodeEntry) => ops.updateNode(page.id, n.id, { transform: { ...n.transform, rotationDeg: deg }, - data: { text: { lockLayoutBox: true } } as never, + data: { text: { lockLayoutBox: true } }, }) const op = selectedNodes.length === 1 From 1e83150c9fcc15d5e195a697302196bad5eb9720 Mon Sep 17 00:00:00 2001 From: zachelnet Date: Sat, 18 Jul 2026 11:21:17 +0200 Subject: [PATCH 5/6] fix(psd): use Transform.rotation_deg as fallback for PSD export When a user manually rotates a text block via the UI, the rotation is stored in Transform.rotation_deg, not TextData.rotation_deg. The PSD export previously only read TextData.rotation_deg, falling back to the AI-predicted angle, which meant user rotations were silently lost on PSD export. Now falls back to Transform.rotation_deg when TextData.rotation_deg is None. Co-authored-by: DeepSeek V4 --- crates/koharu-rpc/src/psd_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/koharu-rpc/src/psd_export.rs b/crates/koharu-rpc/src/psd_export.rs index 64044b39b..02e1e8d1b 100644 --- a/crates/koharu-rpc/src/psd_export.rs +++ b/crates/koharu-rpc/src/psd_export.rs @@ -258,7 +258,7 @@ fn text_to_psd( translation: text.translation.clone(), style: text.style.as_ref().map(convert_style), rendered: text.sprite.as_ref().map(blob_ref_to_psd), - rotation_deg: text.rotation_deg, + rotation_deg: text.rotation_deg.or(Some(transform.rotation_deg)), font_prediction: text.font_prediction.as_ref().map(convert_prediction), source_direction: text.source_direction.map(convert_dir), rendered_direction: text.rendered_direction.map(convert_dir), From cff433e9240f9008e54a8b11cd051e940b0a9358 Mon Sep 17 00:00:00 2001 From: zachelnet Date: Sat, 18 Jul 2026 11:42:20 +0200 Subject: [PATCH 6/6] chore: trim verbose comments, fix dead clamp, rename rotate_point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove excessive doc comments (normalize_rotation, ROTATE_CURSOR_MAP, sample_bilinear_rgba, etc.) — code is self-documenting. - Rename rotate_point_top_left → rotate_point (rotates around origin, not specifically a corner). - Fix dead clamp(0.0, 255.0) in sample_bilinear_rgba (alpha already in 0..1 range after /255.0 normalization). - Improve 180° overlay test from weak any(alpha != 0) to precise pixel position assertion. Co-authored-by: DeepSeek V4 --- crates/koharu-app/src/renderer.rs | 44 +++++++++---------------- ui/components/canvas/TextBlockLayer.tsx | 18 ++-------- 2 files changed, 17 insertions(+), 45 deletions(-) diff --git a/crates/koharu-app/src/renderer.rs b/crates/koharu-app/src/renderer.rs index 67d9dd452..6bf9a324a 100644 --- a/crates/koharu-app/src/renderer.rs +++ b/crates/koharu-app/src/renderer.rs @@ -1039,7 +1039,6 @@ fn find_input(blocks: &[RenderBlockInput], id: NodeId) -> &RenderBlockInput { .expect("rendered_block must have matching input") } -/// Normalize a rotation angle to [0, 360). fn normalize_rotation(deg: f32) -> f32 { let mut r = deg % 360.0; if r < 0.0 { @@ -1057,8 +1056,6 @@ fn overlay_sprite_with_rotation( let rotation_deg = normalize_rotation(transform.rotation_deg); if rotation_deg.abs() < 0.0001 || (360.0 - rotation_deg).abs() < 0.0001 { - // Preserve legacy placement: expanded transforms were rounded, - // non-expanded (original) transforms were truncated (cast to i64). let (ox, oy) = if is_expanded { (transform.x.round() as i64, transform.y.round() as i64) } else { @@ -1069,9 +1066,7 @@ fn overlay_sprite_with_rotation( } let (rotated, _, _) = rotate_sprite_expand_top_left(sprite, rotation_deg.to_radians()); - // The unrotated sprite is centered at (transform.x + w/2, transform.y + h/2). - // Align the rotated sprite's center to the same point so rotated text - // stays visually centered within its layout box. + // Center the rotated sprite over the same anchor as the unrotated one. let origin_x = (transform.x + transform.width * 0.5 - rotated.width() as f32 * 0.5).round() as i64; let origin_y = @@ -1086,8 +1081,7 @@ fn rotate_sprite_expand_top_left(src: &RgbaImage, angle_rad: f32) -> (RgbaImage, return (RgbaImage::new(0, 0), 0.0, 0.0); } - // Fast path: exact 90° multiples — direct integer pixel rearrangement, - // no resampling. The layout matches the bilinear path exactly. + // 90° multiples → direct pixel rearrangement, no resampling. let deg = angle_rad.to_degrees(); let remainder = deg % 90.0; if remainder.abs() < 0.001 || (90.0 - remainder.abs()).abs() < 0.001 { @@ -1129,10 +1123,10 @@ fn rotate_sprite_expand_top_left(src: &RgbaImage, angle_rad: f32) -> (RgbaImage, let cos = angle_rad.cos(); let sin = angle_rad.sin(); let corners = [ - rotate_point_top_left(0.0, 0.0, cos, sin), - rotate_point_top_left(src_w as f32, 0.0, cos, sin), - rotate_point_top_left(0.0, src_h as f32, cos, sin), - rotate_point_top_left(src_w as f32, src_h as f32, cos, sin), + rotate_point(0.0, 0.0, cos, sin), + rotate_point(src_w as f32, 0.0, cos, sin), + rotate_point(0.0, src_h as f32, cos, sin), + rotate_point(src_w as f32, src_h as f32, cos, sin), ]; let min_x = corners @@ -1171,18 +1165,11 @@ fn rotate_sprite_expand_top_left(src: &RgbaImage, angle_rad: f32) -> (RgbaImage, (dst, min_x, min_y) } -fn rotate_point_top_left(x: f32, y: f32, cos: f32, sin: f32) -> (f32, f32) { - // Matches CSS rotate(theta) matrix. +fn rotate_point(x: f32, y: f32, cos: f32, sin: f32) -> (f32, f32) { (cos * x - sin * y, sin * x + cos * y) } -/// Bilinear sample of an RGBA sprite. -/// -/// Interpolates RGB in premultiplied-alpha space and alpha in straight-alpha -/// space, then un-premultiplies once at the end. This is mathematically -/// equivalent to the standard "premultiply all four channels, interpolate, -/// un-premultiply" approach but avoids premultiplying alpha (which is -/// invariant under premultiplication) through the interpolation step. +/// Bilinear sample in premultiplied-alpha space, un-premultiply once at the end. fn sample_bilinear_rgba(src: &RgbaImage, x: f32, y: f32) -> Rgba { let max_x = src.width() as f32 - 1.0; let max_y = src.height() as f32 - 1.0; @@ -1226,7 +1213,7 @@ fn sample_bilinear_rgba(src: &RgbaImage, x: f32, y: f32) -> Rgba { // Alpha is interpolated in straight-alpha space. let top_a = lerp(p00[3], p10[3], wx); let bottom_a = lerp(p01[3], p11[3], wx); - let alpha = lerp(top_a, bottom_a, wy).clamp(0.0, 255.0); + let alpha = lerp(top_a, bottom_a, wy); let mut out = [0u8; 4]; let alpha_u8 = alpha.round() as u8; @@ -1573,7 +1560,6 @@ mod tests { fn make_test_sprite(w: u32, h: u32) -> RgbaImage { let mut img = RgbaImage::new(w, h); - // Paint a solid red pixel so we can verify the output is not empty. img.put_pixel(0, 0, Rgba([255, 0, 0, 255])); img } @@ -1594,7 +1580,6 @@ mod tests { let sprite = make_test_sprite(20, 20); let transform = make_transform(50.0, 50.0, 0.0); overlay_sprite_with_rotation(&mut canvas, &sprite, &transform, false); - // An unrotated sprite at (50, 50) should leave a red pixel there. assert_eq!(canvas.get_pixel(50, 50), &Rgba([255, 0, 0, 255])); } @@ -1608,12 +1593,13 @@ mod tests { } #[test] - fn overlay_sprite_180deg_rotates() { - let mut canvas = RgbaImage::new(600, 600); - let sprite = make_test_sprite(100, 100); - let transform = make_transform(250.0, 250.0, 180.0); + fn overlay_sprite_180deg_is_centered() { + let mut canvas = RgbaImage::new(300, 300); + let sprite = make_test_sprite(20, 20); + let transform = make_transform(50.0, 50.0, 180.0); overlay_sprite_with_rotation(&mut canvas, &sprite, &transform, false); - assert!(canvas.pixels().any(|p| p.0[3] != 0)); + // (0,0) pixel flipped 180° → (19,19) in rotated sprite, centered on transform. + assert_eq!(canvas.get_pixel(109, 84), &Rgba([255, 0, 0, 255])); } #[test] diff --git a/ui/components/canvas/TextBlockLayer.tsx b/ui/components/canvas/TextBlockLayer.tsx index 0129c2fe7..bf44ca728 100644 --- a/ui/components/canvas/TextBlockLayer.tsx +++ b/ui/components/canvas/TextBlockLayer.tsx @@ -24,18 +24,7 @@ type BoxGeometry = { height: number } -/** - * Maps CSS resize cursors through 45° rotation steps. - * - * Each entry is a ring of 8 cursor names at 0°, 45°, 90°, …, 315°. - * - Index 0: 0° (identity) - * - Index 1: 45° - * - Index 2: 90° - * - … - * - Index 7: 315° - * - * Unknown cursors fall back to the original name (see `rotateCursor`). - */ +// Resize cursors rotated in 45° steps. Index = (deg/45) % 8. const ROTATE_CURSOR_MAP: Record = { 'ns-resize': ['ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize'], 'ew-resize': ['ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize', 'ew-resize', 'nwse-resize', 'ns-resize', 'nesw-resize'], @@ -192,8 +181,7 @@ function TextBlockItem({ const cos = Math.cos(rotationRad) const sin = Math.sin(rotationRad) - // Project pointer movement to box-local axes so resize directions - // remain intuitive even when the box is rotated. + // Project pointer delta to box-local space so resize feels natural under rotation. const localDx = mx * cos + my * sin const localDy = -mx * sin + my * cos @@ -314,8 +302,6 @@ function BlockSprite({ node, scale }: { node: TextNodeEntry; scale: number }) { const x = (spriteT?.x ?? node.transform.x) * scale const y = (spriteT?.y ?? node.transform.y) * scale const rotation = spriteT?.rotationDeg ?? node.transform.rotationDeg ?? 0 - // Renderer centers the rotated sprite; match that model by rotating - // around the sprite's center rather than top-left. return (