feat(photo-editor): 旋转图片时加平滑动画,裁剪填满零黑边

- 旋转角用 Animatable + tween(300ms) 平滑过渡,容器宽高比用
  animateFloatAsState 在目标稳态比例间过渡,消除硬切
- 黑边根因:旋转矩形填不满自身 AABB 的四角(45° 时黑色占 51%)。
  用户选「裁剪填满」方案:图片显示 rotatedBitmap(目标态),
  graphicsLayer 旋转 + coverScale 放大使四角覆盖容器 → 零黑边
- 提取旋转几何为纯函数 RotationGeometry(无 Android 依赖):
  stableAspect + coverScale,便于单测
- 回归测试 RotationGeometryTest 锁住「任意角度旋转图片四角
  覆盖容器四角」断言(maxViolation <= 1e-4),防止黑边回归
- 稳态(offset=0) scale=1 零裁剪;动画中(±45°) scale≈1.65 裁边缘

保存逻辑未变:PhotoProcessor.render 仍用 rotationDegrees 烘焙。
This commit is contained in:
xfy 2026-07-22 18:31:48 +08:00
parent ca00839c9b
commit b6146a29ae
3 changed files with 202 additions and 7 deletions

View File

@ -0,0 +1,50 @@
package plus.rua.project
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/**
* 图片旋转显示几何计算纯函数 Android 依赖可单测
*
* 解决旋转动画中容器露黑边问题的数学核心旋转矩形无法填满它自己的轴对齐
* 外接矩形AABB的四个角因此若图片按 AABB 缩放必留 4 个黑三角本对象提供
* 两个公式让图片在任意角度都恰好覆盖容器零黑边代价是动画中斜角处的裁剪
*/
object RotationGeometry {
/**
* 目标稳态下容器的宽高比
*
* 旋转 90° 的倍数后图片宽高在 0/180° 90/270° 间互换
*
* @param srcAspect 源图宽高比/
* @param rotationDegrees 累计旋转角90 的倍数
* @return 该稳态角度下容器的宽高比
*/
fun stableAspect(srcAspect: Float, rotationDegrees: Int): Float =
if (rotationDegrees % 180 == 0) srcAspect else 1f / srcAspect
/**
* 与容器同比例的图片矩形旋转 [offsetDegrees] 后仍完整覆盖容器所需的最小缩放因子
*
* 推导图片 base 矩形 = 容器尺寸 [aspect] 1归一化绕中心旋转 θ
* 容器四角±aspect/2, ±1/2反旋转到图片坐标系必须落在缩放后图片的半尺寸内
* 解得最小缩放
* ```
* k = max( |cosθ| + |sinθ|/aspect, |sinθ|·aspect + |cosθ| )
* ```
* - θ = 0稳态 k = 1零裁剪零黑边
* - θ = ±45°旋转中点 k 最大斜角处裁剪最多但零黑边
*
* @param offsetDegrees 图片当前显示角度相对目标稳态的偏移稳态为 0
* @param aspect 容器宽高比
* @return 最小覆盖缩放因子 >= 1
*/
fun coverScale(offsetDegrees: Float, aspect: Float): Float {
val rad = Math.toRadians(offsetDegrees.toDouble())
val c = abs(cos(rad)).toFloat()
val s = abs(sin(rad)).toFloat()
return maxOf(c + s / aspect, s * aspect + c)
}
}

View File

@ -1,5 +1,9 @@
package plus.rua.project.ui package plus.rua.project.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -46,7 +50,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import kotlin.math.absoluteValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@ -55,8 +58,10 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.testTagsAsResourceId
@ -66,8 +71,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlin.math.absoluteValue
import plus.rua.project.PhotoEditorState import plus.rua.project.PhotoEditorState
import plus.rua.project.PhotoEditorViewModel import plus.rua.project.PhotoEditorViewModel
import plus.rua.project.RotationGeometry
import plus.rua.project.toPath import plus.rua.project.toPath
/** /**
@ -269,19 +276,61 @@ private fun EditableImage(
onEndStroke: () -> Unit, onEndStroke: () -> Unit,
onDisplaySizeChange: (Float, Float) -> Unit onDisplaySizeChange: (Float, Float) -> Unit
) { ) {
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) { state.rotatedBitmap } // 视觉旋转角:从当前显示值平滑动画到数据层目标 rotationDegrees。
// rotationDegrees 是最终态(保存/裁剪用),这里只负责"看起来"的旋转过渡。
// 初值取当前 rotationDegrees避免首次组合闪动。
val displayRotation = remember(state.sourceBitmap) {
Animatable(state.rotationDegrees.toFloat())
}
LaunchedEffect(state.rotationDegrees) {
displayRotation.animateTo(
targetValue = state.rotationDegrees.toFloat(),
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)
}
val srcAspect = state.sourceBitmap.width.toFloat() / state.sourceBitmap.height.toFloat()
// 容器宽高比 = 目标稳态比例0/180°=原图比90/270°=倒数),随 rotationDegrees 平滑过渡。
// 不随中间旋转角变形,避免旋转中容器尺寸抖动。
val containerAspect by animateFloatAsState(
targetValue = RotationGeometry.stableAspect(srcAspect, state.rotationDegrees),
animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing),
label = "rotateAspect"
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(rotatedBmp.width.toFloat() / rotatedBmp.height.toFloat()) .aspectRatio(containerAspect)
.background(Color.Black) .background(Color.Black)
// 尺寸上报放布局阶段:仅在尺寸真正变化时回调一次,
// 避免在 draw 阶段每帧写 StateFlow 导致重组风暴。
.onSizeChanged { size ->
if (size.width > 0 && size.height > 0) {
onDisplaySizeChange(size.width.toFloat(), size.height.toFloat())
}
}
) { ) {
// 显示已旋转到目标态的 bitmap比例 = 容器比例,稳态时填满无黑边无裁剪)。
// 动画中:图片相对目标态多转 (displayRotation - rotationDegrees)
// 用 coverScale 放大使旋转后图片四角仍覆盖容器 → 零黑边(几何见 RotationGeometry
// 把动画值捕获到局部 val确保组合算的 offset 与 draw 的 rotationZ 同帧。
val angle = displayRotation.value
val offset = angle - state.rotationDegrees
val scale = RotationGeometry.coverScale(offset, containerAspect)
val rotatedBmp = remember(state.rotationDegrees, state.sourceBitmap) {
state.rotatedBitmap
}
Image( Image(
bitmap = rotatedBmp.asImageBitmap(), bitmap = rotatedBmp.asImageBitmap(),
contentDescription = "编辑中的照片", contentDescription = "编辑中的照片",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer {
rotationZ = angle
scaleX = scale
scaleY = scale
}
.pointerInput(mode) { .pointerInput(mode) {
if (mode == EditTab.HANDWRITE) { if (mode == EditTab.HANDWRITE) {
detectDragGestures( detectDragGestures(
@ -295,10 +344,10 @@ private fun EditableImage(
) )
} }
}, },
contentScale = ContentScale.Fit contentScale = ContentScale.Crop
) )
// 裁剪框覆盖层 // 裁剪框覆盖层(基于数据层 rotationDegrees 目标态坐标系)
if (mode == EditTab.CROP && state.cropEnabled) { if (mode == EditTab.CROP && state.cropEnabled) {
CropOverlay( CropOverlay(
state = state, state = state,
@ -307,9 +356,8 @@ private fun EditableImage(
) )
} }
// 手写笔触覆盖层 + 同步显示尺寸 // 手写笔触覆盖层
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
onDisplaySizeChange(size.width, size.height)
state.strokes.forEach { stroke -> state.strokes.forEach { stroke ->
drawPath( drawPath(
path = stroke.toPath(), path = stroke.toPath(),

View File

@ -0,0 +1,97 @@
package plus.rua.project
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* [RotationGeometry] 单元测试
*
* 核心回归断言 [RotationGeometry.coverScale] 缩放后旋转图片的四个角
* 在任意偏移角度下都覆盖容器四角 零黑边这是旋转中容器露黑边
* bug 的几何重现与防护缩放因子不足时角点会落进容器内 容器角露黑
*/
class RotationGeometryTest {
@Test
fun stableAspect_noRotation_returnsSourceAspect() {
assertEquals(1.5f, RotationGeometry.stableAspect(1.5f, 0))
assertEquals(1.5f, RotationGeometry.stableAspect(1.5f, 180))
assertEquals(1.5f, RotationGeometry.stableAspect(1.5f, 360))
}
@Test
fun stableAspect_90Degrees_invertsAspect() {
// 4:3 横图 → 3:4 竖向
assertEquals(0.75f, RotationGeometry.stableAspect(1.3333f, 90), 1e-3f)
assertEquals(0.75f, RotationGeometry.stableAspect(1.3333f, 270), 1e-3f)
}
@Test
fun coverScale_atStableOffset_isOne() {
// 稳态offset=0零裁剪零黑边
listOf(0.5f, 0.75f, 1.0f, 1.3333f, 2.0f).forEach { aspect ->
assertEquals(1.0f, RotationGeometry.coverScale(0f, aspect), 1e-5f)
}
}
@Test
fun coverScale_duringAnimation_isAtLeastOne() {
// 动画中任意偏移都 >= 1裁剪填满不缩出黑边
val aspect = 0.75f
for (offset in -89..89) {
val k = RotationGeometry.coverScale(offset.toFloat(), aspect)
assertTrue(k >= 1.0f, "offset=$offset scale=$k 应 >= 1")
}
}
/**
* 回归核心缩放后旋转的图片四角必须覆盖容器四角否则容器角露黑
*
* 模型图片 base = 容器矩形 aspect 1居中于原点先放大 k
* 再旋转 offset容器四角±aspect/2, ±1/2反旋转到图片坐标后
* 必须落在缩放图片的半尺寸±k·aspect/2, ±1/2
*/
@Test
fun coverScale_rotatedImageCornersCoverContainer_noBlackAtAnyAngle() {
val aspects = listOf(0.5f, 0.75f, 1.0f, 1.3333f, 2.0f, 3.0f)
val maxViolation = aspects.maxOf { aspect ->
(-90..90).maxOf { offsetDeg ->
val offset = offsetDeg.toFloat()
val k = RotationGeometry.coverScale(offset, aspect)
val rad = offset * PI.toFloat() / 180f
val ca = cos(rad); val sa = sin(rad)
// 容器四角反旋转到图片坐标系,与缩放图片半尺寸比较(>0 表示露出黑边)
listOf(
aspect / 2 to 0.5f,
aspect / 2 to -0.5f,
-aspect / 2 to 0.5f,
-aspect / 2 to -0.5f
).maxOf { (x, y) ->
val xp = x * ca + y * sa
val yp = -x * sa + y * ca
maxOf(abs(xp) - k * aspect / 2, abs(yp) - k * 0.5f)
}
}
}
// 无任何角点超出缩放图片 → 无黑边
assertTrue(maxViolation <= 1e-4f, "存在角点露出黑边,最大越界=$maxViolation")
}
@Test
fun coverScale_symmetricAroundZero() {
// 正负偏移对称(旋转方向不影响裁剪量)
val aspect = 1.3333f
for (offset in 1..89) {
assertEquals(
RotationGeometry.coverScale(offset.toFloat(), aspect),
RotationGeometry.coverScale(-offset.toFloat(), aspect),
1e-5f
)
}
}
}