From b6146a29ae1d4f383b0882428d09faa61cb0c47e Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 22 Jul 2026 18:31:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(photo-editor):=20=E6=97=8B=E8=BD=AC?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=97=B6=E5=8A=A0=E5=B9=B3=E6=BB=91=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=EF=BC=8C=E8=A3=81=E5=89=AA=E5=A1=AB=E6=BB=A1=E9=9B=B6?= =?UTF-8?q?=E9=BB=91=E8=BE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 旋转角用 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 烘焙。 --- .../plus/rua/project/RotationGeometry.kt | 50 ++++++++++ .../plus/rua/project/ui/PhotoEditorScreen.kt | 62 ++++++++++-- .../plus/rua/project/RotationGeometryTest.kt | 97 +++++++++++++++++++ 3 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 core/src/main/kotlin/plus/rua/project/RotationGeometry.kt create mode 100644 core/src/test/kotlin/plus/rua/project/RotationGeometryTest.kt diff --git a/core/src/main/kotlin/plus/rua/project/RotationGeometry.kt b/core/src/main/kotlin/plus/rua/project/RotationGeometry.kt new file mode 100644 index 0000000..69dfb26 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/RotationGeometry.kt @@ -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) + } +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt index a68e8c4..9d9fe1c 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/PhotoEditorScreen.kt @@ -1,5 +1,9 @@ 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.Image import androidx.compose.foundation.background @@ -46,7 +50,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import kotlin.math.absoluteValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset 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.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics 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.initializer import androidx.lifecycle.viewmodel.viewModelFactory +import kotlin.math.absoluteValue import plus.rua.project.PhotoEditorState import plus.rua.project.PhotoEditorViewModel +import plus.rua.project.RotationGeometry import plus.rua.project.toPath /** @@ -269,19 +276,61 @@ private fun EditableImage( onEndStroke: () -> 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( modifier = Modifier .fillMaxWidth() - .aspectRatio(rotatedBmp.width.toFloat() / rotatedBmp.height.toFloat()) + .aspectRatio(containerAspect) .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( bitmap = rotatedBmp.asImageBitmap(), contentDescription = "编辑中的照片", modifier = Modifier .fillMaxSize() + .graphicsLayer { + rotationZ = angle + scaleX = scale + scaleY = scale + } .pointerInput(mode) { if (mode == EditTab.HANDWRITE) { detectDragGestures( @@ -295,10 +344,10 @@ private fun EditableImage( ) } }, - contentScale = ContentScale.Fit + contentScale = ContentScale.Crop ) - // 裁剪框覆盖层 + // 裁剪框覆盖层(基于数据层 rotationDegrees 目标态坐标系) if (mode == EditTab.CROP && state.cropEnabled) { CropOverlay( state = state, @@ -307,9 +356,8 @@ private fun EditableImage( ) } - // 手写笔触覆盖层 + 同步显示尺寸 + // 手写笔触覆盖层 Canvas(modifier = Modifier.fillMaxSize()) { - onDisplaySizeChange(size.width, size.height) state.strokes.forEach { stroke -> drawPath( path = stroke.toPath(), diff --git a/core/src/test/kotlin/plus/rua/project/RotationGeometryTest.kt b/core/src/test/kotlin/plus/rua/project/RotationGeometryTest.kt new file mode 100644 index 0000000..28afe69 --- /dev/null +++ b/core/src/test/kotlin/plus/rua/project/RotationGeometryTest.kt @@ -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 + ) + } + } +}