Add implementation for conversion from conic to quadratic and use it on host platforms. Bug: 422712992 Test: added robolectric tests to check the code running on host platform. Change-Id: I3a36a3e7b31e75e34041b0c4ba202192cce787cf
diff --git a/graphics/graphics-path/build.gradle b/graphics/graphics-path/build.gradle index b67834a..c6107a7 100644 --- a/graphics/graphics-path/build.gradle +++ b/graphics/graphics-path/build.gradle
@@ -34,6 +34,12 @@ implementation("androidx.core:core:1.12.0") + testImplementation(libs.junit) + testImplementation(libs.testExtJunit) + testImplementation(libs.testCore) + testImplementation(libs.testRunner) + testImplementation("androidx.core:core-ktx:1.12.0") + androidTestImplementation("androidx.annotation:annotation:1.8.1") androidTestImplementation("androidx.core:core-ktx:1.12.0") androidTestImplementation("androidx.test:core:1.5.0@aar") @@ -89,4 +95,5 @@ inceptionYear = "2022" description = "Query segment data for android.graphics.Path objects" legacyDisableKotlinStrictApiMode = true + enableRobolectric() }
diff --git a/graphics/graphics-path/src/main/java/androidx/graphics/path/ConicConverter.kt b/graphics/graphics-path/src/main/java/androidx/graphics/path/ConicConverter.kt index b4456e6..b04fd8c 100644 --- a/graphics/graphics-path/src/main/java/androidx/graphics/path/ConicConverter.kt +++ b/graphics/graphics-path/src/main/java/androidx/graphics/path/ConicConverter.kt
@@ -65,18 +65,39 @@ /** Converts the conic in [points] to a series of quadratics, which will all be stored */ fun convert(points: FloatArray, weight: Float, tolerance: Float, offset: Int = 0) { - quadraticCount = internalConicToQuadratics(points, offset, quadraticData, weight, tolerance) + quadraticCount = conicToQuadraticsCompat(points, offset, quadraticData, weight, tolerance) // 3 points per quadratic, 2 floats per point, with one point of overlap val newDataSize = quadraticCount * 2 * 2 + 2 if (newDataSize > quadraticData.size) { quadraticData = FloatArray(newDataSize) quadraticCount = - internalConicToQuadratics(points, offset, quadraticData, weight, tolerance) + conicToQuadraticsCompat(points, offset, quadraticData, weight, tolerance) } currentQuadratic = 0 } /** + * Calls the appropriate function for the conversion from conic to quadratic. + * + * As the native library is only available for Android, it uses our own implementation for host + * platforms, e.g. LayoutLib and Robolectric. + */ + private fun conicToQuadraticsCompat( + conicPoints: FloatArray, + offset: Int, + quadraticPoints: FloatArray, + weight: Float, + tolerance: Float, + ): Int { + val isDalvik = "dalvik".equals(System.getProperty("java.vm.name"), ignoreCase = true) + return if (isDalvik) { + internalConicToQuadratics(conicPoints, offset, quadraticPoints, weight, tolerance) + } else { + conicToQuadratics(conicPoints, offset, quadraticPoints, weight, tolerance) + } + } + + /** * The actual conversion from conic to quadratic data happens in native code, in the library * loaded elsewhere. This JNI function wraps that native functionality. */
diff --git a/graphics/graphics-path/src/main/java/androidx/graphics/path/ConicsImpl.kt b/graphics/graphics-path/src/main/java/androidx/graphics/path/ConicsImpl.kt new file mode 100644 index 0000000..8ebac33 --- /dev/null +++ b/graphics/graphics-path/src/main/java/androidx/graphics/path/ConicsImpl.kt
@@ -0,0 +1,193 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.graphics.path + +import java.util.ArrayList +import kotlin.math.abs +import kotlin.math.sqrt + +private const val MAX_CONIC_TO_QUAD_COUNT = 5 + +internal fun conicToQuadratics( + conicPoints: FloatArray, + offset: Int, + quadraticPoints: FloatArray, + weight: Float, + tolerance: Float, +): Int { + val conic = + Conic( + arrayOf( + Point(conicPoints[0 + offset], conicPoints[1 + offset]), + Point(conicPoints[2 + offset], conicPoints[3 + offset]), + Point(conicPoints[4 + offset], conicPoints[5 + offset]), + ), + weight, + ) + + val count = conic.computeQuadraticCount(tolerance) + val quadraticCount = 1 shl count + if (quadraticCount > quadraticPoints.size) { + // Buffer not large enough; return necessary size to resize and try again + return quadraticCount + } + + val dstPoints = ArrayList<Point>() + val finalCount = conic.splitIntoQuadratics(dstPoints, count) + + var index = 0 + for (p in dstPoints) { + quadraticPoints[index++] = p.x + quadraticPoints[index++] = p.y + } + + return finalCount +} + +private class Point(val x: Float, val y: Float) { + fun isFinite() = x.isFinite() && y.isFinite() +} + +private fun add(a: Point, b: Point) = Point(a.x + b.x, a.y + b.y) + +private fun mul(a: Point, b: Point) = Point(a.x * b.x, a.y * b.y) + +private fun approxEquals(a: Float, b: Float): Boolean = abs(a - b) < 0.0001f + +private fun approxEquals(a: Point, b: Point) = approxEquals(a.x, b.x) && approxEquals(a.y, b.y) + +private fun between(a: Float, b: Float, c: Float): Boolean = (a - b) * (c - b) <= 0.0f + +private fun subdivide(src: Conic, pts: ArrayList<Point>, level: Int) { + if (level == 0) { + pts.add(src.points[1]) + pts.add(src.points[2]) + } else { + val s = src.split() + val startY = src.points[0].y + val endY = src.points[2].y + if (between(startY, src.points[1].y, endY)) { + val midY = s.a.points[2].y + if (!between(startY, midY, endY)) { + val closerY = if (abs(midY - startY) < abs(midY - endY)) startY else endY + s.a.points[2] = Point(s.a.points[2].x, closerY) + s.b.points[0] = Point(s.b.points[0].x, closerY) + } + if (!between(startY, s.a.points[1].y, s.a.points[2].y)) { + s.a.points[1] = Point(s.a.points[1].x, startY) + } + if (!between(s.b.points[0].y, s.b.points[1].y, endY)) { + s.b.points[1] = Point(s.b.points[1].x, endY) + } + } + subdivide(s.a, pts, level - 1) + subdivide(s.b, pts, level - 1) + } +} + +private class Conic(val points: Array<Point>, val weight: Float) { + fun computeQuadraticCount(tolerance: Float): Int { + val a = weight - 1.0f + val k = a / (4.0f * (2.0f + a)) + val x = k * (points[0].x - 2.0f * points[1].x + points[2].x) + val y = k * (points[0].y - 2.0f * points[1].y + points[2].y) + + var error = sqrt(x * x + y * y) + var count = 0 + while (count < MAX_CONIC_TO_QUAD_COUNT) { + if (error <= tolerance) { + break + } + error *= 0.25f + count++ + } + return count + } + + class SplitResult(val a: Conic, val b: Conic) + + fun split(): SplitResult { + val scale = Point(1.0f / (1.0f + weight), 1.0f / (1.0f + weight)) + val newW = sqrt(0.5f + weight * 0.5f) + + val p0 = points[0] + val p1 = points[1] + val p2 = points[2] + val ww = Point(weight, weight) + + val wp1 = mul(ww, p1) + var m = mul(mul(add(add(p0, add(wp1, wp1)), p2), scale), Point(0.5f, 0.5f)) + if (!m.isFinite()) { + val wD = weight.toDouble() + val w2 = wD * 2.0 + val scaleHalf = 1.0 / (1.0 + wD) * 0.5 + m = + Point( + ((p0.x.toDouble() + w2 * p1.x.toDouble() + p2.x.toDouble()) * scaleHalf) + .toFloat(), + ((p0.y.toDouble() + w2 * p1.y.toDouble() + p2.y.toDouble()) * scaleHalf) + .toFloat(), + ) + } + return SplitResult( + Conic(arrayOf(p0, mul(add(p0, wp1), scale), m), newW), + Conic(arrayOf(m, mul(add(wp1, p2), scale), p2), newW), + ) + } + + fun commonFinitePointCheck(dstPoints: ArrayList<Point>, count: Int): Int { + val quadCount = 1 shl count + val pointCount = 2 * quadCount + 1 + + var isFinite = true + for (p in dstPoints) { + if (!p.isFinite()) { + isFinite = false + break + } + } + + if (!isFinite) { + for (i in 1..<pointCount) { + dstPoints[i] = points[1] + } + } + + return quadCount + } + + fun splitIntoQuadratics(dstPoints: ArrayList<Point>, count: Int): Int { + dstPoints.add(points[0]) + + if (count > MAX_CONIC_TO_QUAD_COUNT) { + val s = split() + + if ( + approxEquals(s.a.points[1], s.a.points[2]) && + approxEquals(s.b.points[0], s.b.points[1]) + ) { + dstPoints.add(s.a.points[1]) + dstPoints.add(s.a.points[1]) + dstPoints.add(s.a.points[1]) + dstPoints.add(s.b.points[2]) + return commonFinitePointCheck(dstPoints, 1) + } + } + + subdivide(this, dstPoints, count) + return commonFinitePointCheck(dstPoints, count) + } +}
diff --git a/graphics/graphics-path/src/test/java/androidx/graphics/path/PathIteratorJvmTest.kt b/graphics/graphics-path/src/test/java/androidx/graphics/path/PathIteratorJvmTest.kt new file mode 100644 index 0000000..aba3a36 --- /dev/null +++ b/graphics/graphics-path/src/test/java/androidx/graphics/path/PathIteratorJvmTest.kt
@@ -0,0 +1,527 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.graphics.path + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.graphics.PointF +import android.graphics.RectF +import androidx.core.graphics.applyCanvas +import androidx.core.graphics.createBitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlin.math.abs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.internal.DoNotInstrument + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(minSdk = 35) +@DoNotInstrument +class PathIteratorJvmTest { + @Test + fun emptyIterator() { + val path = Path() + + val iterator = path.iterator() + assertFalse(iterator.hasNext()) + val firstSegment = iterator.next() + assertEquals(PathSegment.Type.Done, firstSegment.type) + + var count = 0 + for (segment in path) { + count++ + } + + assertEquals(0, count) + } + + @Test + fun emptyPeek() { + val path = Path() + val iterator = path.iterator() + assertEquals(PathSegment.Type.Done, iterator.peek()) + } + + @Test + fun nonEmptyIterator() { + val path = + Path().apply { + moveTo(1.0f, 1.0f) + lineTo(2.0f, 2.0f) + close() + } + + val iterator = path.iterator() + assertTrue(iterator.hasNext()) + + val types = + arrayOf( + PathSegment.Type.Move, + PathSegment.Type.Line, + PathSegment.Type.Close, + PathSegment.Type.Done, + ) + val points = arrayOf(PointF(1.0f, 1.0f), PointF(2.0f, 2.0f)) + + var count = 0 + for (segment in path) { + assertEquals(types[count], segment.type) + when (segment.type) { + PathSegment.Type.Move -> { + assertEquals(points[count], segment.points[0]) + } + PathSegment.Type.Line -> { + assertEquals(points[count - 1], segment.points[0]) + assertEquals(points[count], segment.points[1]) + } + else -> {} + } + // TODO: remove condition and just auto-increment count when platform change is + // checked in which ignores DONE during iteration + if (segment.type != PathSegment.Type.Done) count++ + } + + assertEquals(3, count) + } + + @Test + fun peek() { + val path = + Path().apply { + moveTo(1.0f, 1.0f) + lineTo(2.0f, 2.0f) + close() + } + + val iterator = path.iterator() + assertEquals(PathSegment.Type.Move, iterator.peek()) + } + + @Test + fun peekBeyond() { + val path = Path() + assertEquals(PathSegment.Type.Done, path.iterator().peek()) + + path.apply { + moveTo(1.0f, 1.0f) + lineTo(2.0f, 2.0f) + close() + } + + val iterator = path.iterator() + while (iterator.hasNext()) iterator.next() + assertEquals(PathSegment.Type.Done, iterator.peek()) + } + + @Test + fun iteratorStyles() { + val path = + Path().apply { + moveTo(1.0f, 1.0f) + lineTo(2.0f, 2.0f) + cubicTo(3.0f, 3.0f, 4.0f, 4.0f, 5.0f, 5.0f) + quadTo(7.0f, 7.0f, 8.0f, 8.0f) + moveTo(10.0f, 10.0f) + // addRoundRect() will generate conic curves on certain API levels + addRoundRect(RectF(12.0f, 12.0f, 36.0f, 36.0f), 8.0f, 8.0f, Path.Direction.CW) + close() + } + + iteratorStylesImpl(path, PathIterator.ConicEvaluation.AsConic) + iteratorStylesImpl(path, PathIterator.ConicEvaluation.AsQuadratics) + } + + private fun iteratorStylesImpl(path: Path, conicEvaluation: PathIterator.ConicEvaluation) { + val iterator1 = path.iterator(conicEvaluation) + val iterator2 = path.iterator(conicEvaluation) + val iterator3 = path.iterator(conicEvaluation) + + val points = FloatArray(8) + val points2 = FloatArray(16) + + while (iterator1.hasNext() || iterator2.hasNext() || iterator3.hasNext()) { + val segment = iterator1.next() + val type = iterator2.next(points) + val type2 = iterator3.next(points2, 8) + + assertEquals(type, segment.type) + assertEquals(type2, segment.type) + + when (type) { + PathSegment.Type.Move -> { + assertPointsEquals(points, 0, segment.points[0]) + assertPointsEquals(points2, 4, segment.points[0]) + } + PathSegment.Type.Line -> { + assertPointsEquals(points, 0, segment.points[0]) + assertPointsEquals(points, 1, segment.points[1]) + assertPointsEquals(points2, 4, segment.points[0]) + assertPointsEquals(points2, 5, segment.points[1]) + } + PathSegment.Type.Quadratic -> { + assertPointsEquals(points, 0, segment.points[0]) + assertPointsEquals(points, 1, segment.points[1]) + assertPointsEquals(points, 2, segment.points[2]) + assertPointsEquals(points2, 4, segment.points[0]) + assertPointsEquals(points2, 5, segment.points[1]) + assertPointsEquals(points2, 6, segment.points[2]) + } + PathSegment.Type.Conic -> { + assertPointsEquals(points, 0, segment.points[0]) + assertPointsEquals(points, 1, segment.points[1]) + assertPointsEquals(points, 2, segment.points[2]) + // Weight is stored after all of the points + assertEquals(points[6], segment.weight) + + assertPointsEquals(points2, 4, segment.points[0]) + assertPointsEquals(points2, 5, segment.points[1]) + assertPointsEquals(points2, 6, segment.points[2]) + // Weight is stored after all of the points + assertEquals(points2[14], segment.weight) + } + PathSegment.Type.Cubic -> { + assertPointsEquals(points, 0, segment.points[0]) + assertPointsEquals(points, 1, segment.points[1]) + assertPointsEquals(points, 2, segment.points[2]) + assertPointsEquals(points, 3, segment.points[3]) + + assertPointsEquals(points2, 4, segment.points[0]) + assertPointsEquals(points2, 5, segment.points[1]) + assertPointsEquals(points2, 6, segment.points[2]) + assertPointsEquals(points2, 7, segment.points[3]) + } + PathSegment.Type.Close -> {} + PathSegment.Type.Done -> {} + } + } + } + + @Test + fun done() { + val path = Path().apply { close() } + + val segment = path.iterator().next() + + assertEquals(PathSegment.Type.Done, segment.type) + assertEquals(0, segment.points.size) + assertEquals(0.0f, segment.weight) + } + + @Test + fun close() { + val path = + Path().apply { + lineTo(10.0f, 12.0f) + close() + } + + val iterator = path.iterator() + // Swallow the move + iterator.next() + // Swallow the line + iterator.next() + + val segment = iterator.next() + + assertEquals(PathSegment.Type.Close, segment.type) + assertEquals(0, segment.points.size) + assertEquals(0.0f, segment.weight) + } + + @Test + fun moveTo() { + val path = Path().apply { moveTo(10.0f, 12.0f) } + + val segment = path.iterator().next() + + assertEquals(PathSegment.Type.Move, segment.type) + assertEquals(1, segment.points.size) + assertPointsEquals(PointF(10.0f, 12.0f), segment.points[0]) + assertEquals(0.0f, segment.weight) + } + + @Test + fun lineTo() { + val path = + Path().apply { + moveTo(4.0f, 6.0f) + lineTo(10.0f, 12.0f) + } + + val iterator = path.iterator() + // Swallow the move + iterator.next() + + val segment = iterator.next() + + assertEquals(PathSegment.Type.Line, segment.type) + assertEquals(2, segment.points.size) + assertPointsEquals(PointF(4.0f, 6.0f), segment.points[0]) + assertPointsEquals(PointF(10.0f, 12.0f), segment.points[1]) + assertEquals(0.0f, segment.weight) + } + + @Test + fun quadraticTo() { + val path = + Path().apply { + moveTo(4.0f, 6.0f) + quadTo(10.0f, 12.0f, 20.0f, 24.0f) + } + + val iterator = path.iterator() + // Swallow the move + iterator.next() + + val segment = iterator.next() + + assertEquals(PathSegment.Type.Quadratic, segment.type) + assertEquals(3, segment.points.size) + assertPointsEquals(PointF(4.0f, 6.0f), segment.points[0]) + assertPointsEquals(PointF(10.0f, 12.0f), segment.points[1]) + assertPointsEquals(PointF(20.0f, 24.0f), segment.points[2]) + assertEquals(0.0f, segment.weight) + } + + @Test + fun cubicTo() { + val path = + Path().apply { + moveTo(4.0f, 6.0f) + cubicTo(10.0f, 12.0f, 20.0f, 24.0f, 30.0f, 36.0f) + } + + val iterator = path.iterator() + // Swallow the move + iterator.next() + + val segment = iterator.next() + + assertEquals(PathSegment.Type.Cubic, segment.type) + assertEquals(4, segment.points.size) + assertPointsEquals(PointF(4.0f, 6.0f), segment.points[0]) + assertPointsEquals(PointF(10.0f, 12.0f), segment.points[1]) + assertPointsEquals(PointF(20.0f, 24.0f), segment.points[2]) + assertPointsEquals(PointF(30.0f, 36.0f), segment.points[3]) + assertEquals(0.0f, segment.weight) + } + + @Test + fun conicTo() { + val path = + Path().apply { + addRoundRect(RectF(12.0f, 12.0f, 24.0f, 24.0f), 8.0f, 8.0f, Path.Direction.CW) + } + + val iterator = path.iterator(PathIterator.ConicEvaluation.AsConic) + // Swallow the move + iterator.next() + + val segment = iterator.next() + + assertEquals(PathSegment.Type.Conic, segment.type) + assertEquals(3, segment.points.size) + + assertPointsEquals(PointF(12.0f, 18.0f), segment.points[0]) + assertPointsEquals(PointF(12.0f, 12.0f), segment.points[1]) + assertPointsEquals(PointF(18.0f, 12.0f), segment.points[2]) + assertEquals(0.70710677f, segment.weight) + } + + @Test + fun conicAsQuadratics() { + val path = + Path().apply { + addRoundRect(RectF(12.0f, 12.0f, 24.0f, 24.0f), 8.0f, 8.0f, Path.Direction.CW) + } + + for (segment in path) { + if (segment.type == PathSegment.Type.Conic) fail("Found conic, none expected: $segment") + } + } + + @Test + fun convertedConics() { + val path1 = + Path().apply { + addRoundRect(RectF(12.0f, 12.0f, 64.0f, 64.0f), 12.0f, 12.0f, Path.Direction.CW) + } + + val path2 = Path() + for (segment in path1) { + when (segment.type) { + PathSegment.Type.Move -> path2.moveTo(segment.points[0].x, segment.points[0].y) + PathSegment.Type.Line -> path2.lineTo(segment.points[1].x, segment.points[1].y) + PathSegment.Type.Quadratic -> + path2.quadTo( + segment.points[1].x, + segment.points[1].y, + segment.points[2].x, + segment.points[2].y, + ) + PathSegment.Type.Conic -> fail("Unexpected conic! $segment") + PathSegment.Type.Cubic -> + path2.cubicTo( + segment.points[1].x, + segment.points[1].y, + segment.points[2].x, + segment.points[2].y, + segment.points[3].x, + segment.points[3].y, + ) + PathSegment.Type.Close -> path2.close() + PathSegment.Type.Done -> {} + } + } + + // Now with smaller error tolerance + val path3 = Path() + for (segment in + path1.iterator(conicEvaluation = PathIterator.ConicEvaluation.AsQuadratics, 0.001f)) { + when (segment.type) { + PathSegment.Type.Move -> path3.moveTo(segment.points[0].x, segment.points[0].y) + PathSegment.Type.Line -> path3.lineTo(segment.points[1].x, segment.points[1].y) + PathSegment.Type.Quadratic -> + path3.quadTo( + segment.points[1].x, + segment.points[1].y, + segment.points[2].x, + segment.points[2].y, + ) + PathSegment.Type.Conic -> fail("Unexpected conic! $segment") + PathSegment.Type.Cubic -> + path3.cubicTo( + segment.points[1].x, + segment.points[1].y, + segment.points[2].x, + segment.points[2].y, + segment.points[3].x, + segment.points[3].y, + ) + PathSegment.Type.Close -> path3.close() + PathSegment.Type.Done -> {} + } + } + + val b1 = + createBitmap(76, 76).applyCanvas { + drawARGB(255, 255, 255, 255) + drawPath( + path1, + Paint().apply { + color = argb(1.0f, 0.0f, 0.0f, 1.0f) + strokeWidth = 2.0f + isAntiAlias = true + style = Paint.Style.STROKE + }, + ) + } + + val b2 = + createBitmap(76, 76).applyCanvas { + drawARGB(255, 255, 255, 255) + drawPath( + path2, + Paint().apply { + color = argb(1.0f, 0.0f, 0.0f, 1.0f) + strokeWidth = 2.0f + isAntiAlias = true + style = Paint.Style.STROKE + }, + ) + } + + compareBitmaps(b1, b2) + // Note: b1-vs-b3 is not a valid comparison; default Skia rendering does not use an + // error tolerance that low. The test for fine-precision in path3 was just to + // ensure that the system could handle the extra data and operations required + } + + @Test + fun sizes() { + val path = Path() + var iterator: PathIterator = path.iterator() + + assertEquals(0, iterator.calculateSize()) + + path.addRoundRect(RectF(12.0f, 12.0f, 64.0f, 64.0f), 8.0f, 8.0f, Path.Direction.CW) + + // Skia converted + // Preserve conics and count + iterator = path.iterator(PathIterator.ConicEvaluation.AsConic) + assertEquals(10, iterator.calculateSize()) + assertEquals(iterator.calculateSize(false), iterator.calculateSize()) + + // Convert conics and count + iterator = path.iterator(PathIterator.ConicEvaluation.AsQuadratics) + // simple size, not including conic conversion + assertEquals(10, iterator.calculateSize(false)) + // now get the size with converted conics + assertEquals(14, iterator.calculateSize()) + } +} + +fun argb(alpha: Float, red: Float, green: Float, blue: Float) = + ((alpha * 255.0f + 0.5f).toInt() shl 24) or + ((red * 255.0f + 0.5f).toInt() shl 16) or + ((green * 255.0f + 0.5f).toInt() shl 8) or + (blue * 255.0f + 0.5f).toInt() + +private fun assertPointsEquals(p1: PointF, p2: PointF) { + assertEquals(p1.x, p2.x, 1e-6f) + assertEquals(p1.y, p2.y, 1e-6f) +} + +private fun assertPointsEquals(p1: FloatArray, offset: Int, p2: PointF) { + assertEquals(p1[0 + offset * 2], p2.x, 1e-6f) + assertEquals(p1[1 + offset * 2], p2.y, 1e-6f) +} + +private fun compareBitmaps(b1: Bitmap, b2: Bitmap) { + val epsilon = 1 + + assertEquals(b1.width, b2.width) + assertEquals(b1.height, b2.height) + + val p1 = IntArray(b1.width * b1.height) + b1.getPixels(p1, 0, b1.width, 0, 0, b1.width, b1.height) + + val p2 = IntArray(b2.width * b2.height) + b2.getPixels(p2, 0, b2.width, 0, 0, b2.width, b2.height) + + for (x in 0 until b1.width) { + for (y in 0 until b2.width) { + val index = y * b1.width + x + + val c1 = p1[index] + val c2 = p2[index] + + assertTrue(abs(Color.red(c1) - Color.red(c2)) <= epsilon) + assertTrue(abs(Color.green(c1) - Color.green(c2)) <= epsilon) + assertTrue(abs(Color.blue(c1) - Color.blue(c2)) <= epsilon) + } + } +}