diff --git a/changelog.md b/changelog.md index 0dc545d9..af00aeb2 100644 --- a/changelog.md +++ b/changelog.md @@ -3,11 +3,15 @@ ## Unreleased ### Added +- `com.jzbrooks.vgo.core.util.math.Surveyor`, which computes the bounding box of an arbitrary list of commands +- Bezier curve interpolation for all variants and elliptical arc bounding box functions ### Changed - `vgo-plugin` (`com.jzbrooks.vgo.plugin`) no longer requires a particular version of Android Gradle Plugin. - Note: `:vgo` is now an abstract implementation of the tool which does not assume either a cli or plugin context. CLI related logic has been relocated into `:vgo-cli`. + Note: `:vgo` is an abstract implementation of the tool which does not assume either a cli or plugin context. CLI related logic has been relocated into `:vgo-cli`. +- **Breaking:** `CubicCurve<*>.interpolate` has been split into `CubicBezierCurve.interpolate` and `SmoothCubicBezierCurve.interpolate` +- Paths with an even odd fill rule can be merged ### Deprecated @@ -15,9 +19,10 @@ ### Fixed +- Overlapping paths are no longer merged, which avoids some image warping issues (#88, #101) - Conversions without a specified output file will write a file the file extension corresponding to the format. - Decimal separators are locale-invariant. -- Crash when using the cli to convert an svg containing a clip path to vector drawable. +- Crash when using the cli to convert an svg containing a clip path to vector drawable. ### Security diff --git a/vgo-core/api/vgo-core.api b/vgo-core/api/vgo-core.api index 74ec0f43..e120bb58 100644 --- a/vgo-core/api/vgo-core.api +++ b/vgo-core/api/vgo-core.api @@ -626,6 +626,23 @@ public final class com/jzbrooks/vgo/core/util/element/TraverseKt { public static final fun traverseTopDown (Lcom/jzbrooks/vgo/core/graphic/Element;Lkotlin/jvm/functions/Function1;)Lcom/jzbrooks/vgo/core/graphic/Element; } +public final class com/jzbrooks/vgo/core/util/math/CenterParameterization { + public fun (Lcom/jzbrooks/vgo/core/util/math/Point;FFD)V + public final fun component1 ()Lcom/jzbrooks/vgo/core/util/math/Point; + public final fun component2 ()F + public final fun component3 ()F + public final fun component4 ()D + public final fun copy (Lcom/jzbrooks/vgo/core/util/math/Point;FFD)Lcom/jzbrooks/vgo/core/util/math/CenterParameterization; + public static synthetic fun copy$default (Lcom/jzbrooks/vgo/core/util/math/CenterParameterization;Lcom/jzbrooks/vgo/core/util/math/Point;FFDILjava/lang/Object;)Lcom/jzbrooks/vgo/core/util/math/CenterParameterization; + public fun equals (Ljava/lang/Object;)Z + public final fun getCenter ()Lcom/jzbrooks/vgo/core/util/math/Point; + public final fun getPhi ()D + public final fun getRadiusX ()F + public final fun getRadiusY ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/jzbrooks/vgo/core/util/math/Circle { public fun (Lcom/jzbrooks/vgo/core/util/math/Point;F)V public final fun component1 ()Lcom/jzbrooks/vgo/core/util/math/Point; @@ -645,14 +662,20 @@ public final class com/jzbrooks/vgo/core/util/math/CommandsKt { } public final class com/jzbrooks/vgo/core/util/math/CurvesKt { + public static final fun computeBoundingBox (Lcom/jzbrooks/vgo/core/graphic/command/EllipticalArcCurve$Parameter;Lcom/jzbrooks/vgo/core/graphic/command/CommandVariant;Lcom/jzbrooks/vgo/core/util/math/Point;)Lcom/jzbrooks/vgo/core/util/math/Rectangle; + public static final fun computeCenterParameterization (Lcom/jzbrooks/vgo/core/graphic/command/EllipticalArcCurve$Parameter;Lcom/jzbrooks/vgo/core/graphic/command/CommandVariant;Lcom/jzbrooks/vgo/core/util/math/Point;)Lcom/jzbrooks/vgo/core/util/math/CenterParameterization; public static final fun findArcAngle (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;Lcom/jzbrooks/vgo/core/util/math/Circle;)F - public static final fun fitCircle (Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;F)Lcom/jzbrooks/vgo/core/util/math/Circle; - public static synthetic fun fitCircle$default (Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;FILjava/lang/Object;)Lcom/jzbrooks/vgo/core/util/math/Circle; - public static final fun interpolate (Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;F)Lcom/jzbrooks/vgo/core/util/math/Point; - public static final fun isConvex (Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;F)Z - public static synthetic fun isConvex$default (Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;FILjava/lang/Object;)Z - public static final fun liesOnCircle (Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;Lcom/jzbrooks/vgo/core/util/math/Circle;F)Z - public static synthetic fun liesOnCircle$default (Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;Lcom/jzbrooks/vgo/core/util/math/Circle;FILjava/lang/Object;)Z + public static final fun fitCircle (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;F)Lcom/jzbrooks/vgo/core/util/math/Circle; + public static synthetic fun fitCircle$default (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;FILjava/lang/Object;)Lcom/jzbrooks/vgo/core/util/math/Circle; + public static final fun interpolate (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve$Parameter;Lcom/jzbrooks/vgo/core/util/math/Point;F)Lcom/jzbrooks/vgo/core/util/math/Point; + public static final fun interpolate (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;Lcom/jzbrooks/vgo/core/util/math/Point;F)Lcom/jzbrooks/vgo/core/util/math/Point; + public static final fun interpolate (Lcom/jzbrooks/vgo/core/graphic/command/QuadraticBezierCurve$Parameter;Lcom/jzbrooks/vgo/core/util/math/Point;F)Lcom/jzbrooks/vgo/core/util/math/Point; + public static final fun interpolate (Lcom/jzbrooks/vgo/core/graphic/command/SmoothCubicBezierCurve$Parameter;Lcom/jzbrooks/vgo/core/util/math/Point;Lcom/jzbrooks/vgo/core/util/math/Point;F)Lcom/jzbrooks/vgo/core/util/math/Point; + public static final fun interpolateSmoothQuadraticBezierCurve (Lcom/jzbrooks/vgo/core/util/math/Point;Lcom/jzbrooks/vgo/core/util/math/Point;Lcom/jzbrooks/vgo/core/util/math/Point;F)Lcom/jzbrooks/vgo/core/util/math/Point; + public static final fun isConvex (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;F)Z + public static synthetic fun isConvex$default (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;FILjava/lang/Object;)Z + public static final fun liesOnCircle (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;Lcom/jzbrooks/vgo/core/util/math/Circle;F)Z + public static synthetic fun liesOnCircle$default (Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve;Lcom/jzbrooks/vgo/core/util/math/Circle;FILjava/lang/Object;)Z public static final fun toCubicBezierCurve (Lcom/jzbrooks/vgo/core/graphic/command/SmoothCubicBezierCurve;Lcom/jzbrooks/vgo/core/graphic/command/CubicCurve;)Lcom/jzbrooks/vgo/core/graphic/command/CubicBezierCurve; } @@ -729,6 +752,32 @@ public final class com/jzbrooks/vgo/core/util/math/Point$Companion { public final fun getZERO ()Lcom/jzbrooks/vgo/core/util/math/Point; } +public final class com/jzbrooks/vgo/core/util/math/Rectangle { + public fun (FFFF)V + public final fun component1 ()F + public final fun component2 ()F + public final fun component3 ()F + public final fun component4 ()F + public final fun copy (FFFF)Lcom/jzbrooks/vgo/core/util/math/Rectangle; + public static synthetic fun copy$default (Lcom/jzbrooks/vgo/core/util/math/Rectangle;FFFFILjava/lang/Object;)Lcom/jzbrooks/vgo/core/util/math/Rectangle; + public fun equals (Ljava/lang/Object;)Z + public final fun getBottom ()F + public final fun getLeft ()F + public final fun getRight ()F + public final fun getTop ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/jzbrooks/vgo/core/util/math/RectangleKt { + public static final fun intersects (Lcom/jzbrooks/vgo/core/util/math/Rectangle;Lcom/jzbrooks/vgo/core/util/math/Rectangle;)Z +} + +public final class com/jzbrooks/vgo/core/util/math/Surveyor { + public fun ()V + public final fun findBoundingBox (Ljava/util/List;)Lcom/jzbrooks/vgo/core/util/math/Rectangle; +} + public final class com/jzbrooks/vgo/core/util/math/Vector3 { public fun (FFF)V public fun (Lcom/jzbrooks/vgo/core/util/math/Point;)V diff --git a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/ConvertCurvesToArcs.kt b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/ConvertCurvesToArcs.kt index de03a65b..829c75c6 100644 --- a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/ConvertCurvesToArcs.kt +++ b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/ConvertCurvesToArcs.kt @@ -12,6 +12,7 @@ import com.jzbrooks.vgo.core.graphic.command.CubicBezierCurve import com.jzbrooks.vgo.core.graphic.command.CubicCurve import com.jzbrooks.vgo.core.graphic.command.EllipticalArcCurve import com.jzbrooks.vgo.core.graphic.command.SmoothCubicBezierCurve +import com.jzbrooks.vgo.core.util.math.Circle import com.jzbrooks.vgo.core.util.math.computeAbsoluteCoordinates import com.jzbrooks.vgo.core.util.math.findArcAngle import com.jzbrooks.vgo.core.util.math.fitCircle @@ -101,52 +102,53 @@ class ConvertCurvesToArcs( val previous = computeAbsoluteCoordinates(commands.take(i)) - while (nextCommand is CubicCurve<*> && nextCommand.isConvex() && nextCommand.liesOnCircle(relativeCircle)) { - val normalizedNext = - if (nextCommand is SmoothCubicBezierCurve) { - nextCommand.toCubicBezierCurve(pendingCurves.last()) - } else { - nextCommand - } + if (nextCommand != null) { + var currentCurve = currentCommand + var nextCurve = convertToCircularCubicCurve(nextCommand, relativeCircle, currentCurve) - check(normalizedNext is CubicBezierCurve) - - pendingCurves.add(nextCommand) + while (nextCommand is CubicCurve<*> && nextCurve != null) { + pendingCurves.add(nextCommand) - val next = computeAbsoluteCoordinates(commands.take(j + 1)) + val next = computeAbsoluteCoordinates(commands.take(j + 1)) - angle += normalizedNext.findArcAngle(relativeCircle) + angle += nextCurve.findArcAngle(relativeCircle) - if (angle - 2 * Math.PI > 1e-3) { - break - } + if (angle - 2 * Math.PI > 1e-3) { + break + } - if (angle > Math.PI) arc.parameters[0].arc = EllipticalArcCurve.ArcFlag.LARGE + if (angle > Math.PI) arc.parameters[0].arc = EllipticalArcCurve.ArcFlag.LARGE - if (2 * Math.PI - angle > 1e-3) { - arc.parameters[0].end = next - previous - } else { - arc.parameters[0].end = (relativeCircle.center - normalizedNext.parameters[0].end) * 2f - ellipticalArcs.add( - EllipticalArcCurve( - CommandVariant.RELATIVE, - listOf( - EllipticalArcCurve.Parameter( - radius, - radius, - 0f, - EllipticalArcCurve.ArcFlag.SMALL, - sweep, - next - (previous + arc.parameters[0].end), + if (2 * Math.PI - angle > 1e-3) { + arc.parameters[0].end = next - previous + } else { + arc.parameters[0].end = (relativeCircle.center - nextCurve.parameters[0].end) * 2f + ellipticalArcs.add( + EllipticalArcCurve( + CommandVariant.RELATIVE, + listOf( + EllipticalArcCurve.Parameter( + radius, + radius, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + sweep, + next - (previous + arc.parameters[0].end), + ), ), ), - ), - ) - break - } + ) + break + } - relativeCircle.center += normalizedNext.parameters[0].end - nextCommand = commands.getOrNull(++j) + relativeCircle.center += nextCurve.parameters[0].end + nextCommand = commands.getOrNull(++j) + currentCurve = nextCurve + nextCurve = + nextCommand?.let { + convertToCircularCubicCurve(it, relativeCircle, currentCurve) + } + } } // If the next curve is a shorthand, it must be converted @@ -245,4 +247,15 @@ class ConvertCurvesToArcs( return newCommands } + + private fun convertToCircularCubicCurve( + command: Command, + relativeCircle: Circle, + previousCurve: CubicBezierCurve?, + ): CubicBezierCurve? = + when (command) { + is CubicBezierCurve -> command + is SmoothCubicBezierCurve -> command.toCubicBezierCurve(previousCurve!!) + else -> null + }?.takeIf { it.isConvex() && it.liesOnCircle(relativeCircle) } } diff --git a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/MergePaths.kt b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/MergePaths.kt index beb64097..36bc0e2d 100644 --- a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/MergePaths.kt +++ b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/optimization/MergePaths.kt @@ -7,11 +7,15 @@ import com.jzbrooks.vgo.core.graphic.Extra import com.jzbrooks.vgo.core.graphic.Graphic import com.jzbrooks.vgo.core.graphic.Group import com.jzbrooks.vgo.core.graphic.Path +import com.jzbrooks.vgo.core.util.math.Surveyor +import com.jzbrooks.vgo.core.util.math.intersects /** * Merges multiple paths into a single path where possible */ class MergePaths : BottomUpOptimization { + private val surveyor = Surveyor() + override fun visit(graphic: Graphic) = merge(graphic) override fun visit(group: Group) = merge(group) @@ -57,14 +61,10 @@ class MergePaths : BottomUpOptimization { for (current in paths.drop(1)) { val previous = mergedPaths.last() - // Avoid merging paths with even odd fill rule, because - // the merge might cause some paths to be considered 'interior' - // according to those rules when they were previously exterior - // in their own paths. - // - // There might be a reasonable way to deduce that situation more - // specifically, which could enable merging of some even odd paths. - if (!haveSameAttributes(current, previous) || current.fillRule == Path.FillRule.EVEN_ODD) { + // Intersecting paths can cause problems with path fill rules and with transparency. + if (!haveSameAttributes(current, previous) || + surveyor.findBoundingBox(previous.commands) intersects surveyor.findBoundingBox(current.commands) + ) { mergedPaths.add(current) } else { previous.commands += current.commands diff --git a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Curves.kt b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Curves.kt index 3624aaa2..f6d13d33 100644 --- a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Curves.kt +++ b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Curves.kt @@ -3,11 +3,16 @@ package com.jzbrooks.vgo.core.util.math import com.jzbrooks.vgo.core.graphic.command.CommandVariant import com.jzbrooks.vgo.core.graphic.command.CubicBezierCurve import com.jzbrooks.vgo.core.graphic.command.CubicCurve +import com.jzbrooks.vgo.core.graphic.command.EllipticalArcCurve +import com.jzbrooks.vgo.core.graphic.command.QuadraticBezierCurve import com.jzbrooks.vgo.core.graphic.command.SmoothCubicBezierCurve import kotlin.math.abs import kotlin.math.acos +import kotlin.math.cos import kotlin.math.hypot import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt private const val ARC_THRESHOLD = 2f private const val ARC_TOLERANCE = 0.5f @@ -16,11 +21,11 @@ private const val ARC_TOLERANCE = 0.5f * Requires that the curve only has a single parameter * Requires that the curve use relative coordinates */ -fun CubicCurve<*>.fitCircle(tolerance: Float = 1e-3f): Circle? { +fun CubicBezierCurve.fitCircle(tolerance: Float = 1e-3f): Circle? { assert(variant == CommandVariant.RELATIVE) assert(parameters.size == 1) - val mid = interpolate(0.5f) + val mid = interpolate(Point.ZERO, 0.5f) val end = parameters[0].end val m1 = mid * 0.5f @@ -38,7 +43,7 @@ fun CubicCurve<*>.fitCircle(tolerance: Float = 1e-3f): Circle? { val withinTolerance = radius < 1e7 && floatArrayOf(0.25f, 0.75f).all { - val curveValue = interpolate(it) + val curveValue = interpolate(Point.ZERO, it) abs(curveValue.distanceTo(center) - radius) <= tolerance } @@ -47,26 +52,31 @@ fun CubicCurve<*>.fitCircle(tolerance: Float = 1e-3f): Circle? { /** * Requires that the curve only has a single parameter - * Requires that the curve use relative coordinates */ -fun CubicCurve<*>.interpolate(t: Float): Point { - assert(variant == CommandVariant.RELATIVE) +fun CubicBezierCurve.interpolate( + currentPoint: Point, + t: Float, +): Point { assert(parameters.size == 1) + return parameters.first().interpolate(currentPoint, t) +} - val (startControl, endControl, end) = - when (this) { - is CubicBezierCurve -> Triple(parameters[0].startControl, parameters[0].endControl, parameters[0].end) - is SmoothCubicBezierCurve -> Triple(Point.ZERO, parameters[0].endControl, parameters[0].end) - } - +/** + * Requires that the curve only has a single parameter + */ +fun CubicBezierCurve.Parameter.interpolate( + currentPoint: Point, + t: Float, +): Point { val square = t * t val cube = square * t val param = 1 - t val paramSquare = param * param + val paramCube = paramSquare * param return Point( - 3 * paramSquare * t * startControl.x + 3 * param * square * endControl.x + cube * end.x, - 3 * paramSquare * t * startControl.y + 3 * param * square * endControl.y + cube * end.y, + x = currentPoint.x * paramCube + 3 * paramSquare * t * startControl.x + 3 * param * square * endControl.x + cube * end.x, + y = currentPoint.y * paramCube + 3 * paramSquare * t * startControl.y + 3 * param * square * endControl.y + cube * end.y, ) } @@ -74,15 +84,11 @@ fun CubicCurve<*>.interpolate(t: Float): Point { * Requires that the curve only has a single parameter * Requires that the curve use relative coordinates */ -fun CubicCurve<*>.isConvex(tolerance: Float = 1e-3f): Boolean { +fun CubicBezierCurve.isConvex(tolerance: Float = 1e-3f): Boolean { assert(variant == CommandVariant.RELATIVE) assert(parameters.size == 1) - val (startControl, endControl, end) = - when (this) { - is CubicBezierCurve -> Triple(parameters[0].startControl, parameters[0].endControl, parameters[0].end) - is SmoothCubicBezierCurve -> Triple(Point.ZERO, parameters[0].endControl, parameters[0].end) - } + val (startControl, endControl, end) = parameters[0] val firstDiagonal = LineSegment(Point.ZERO, endControl) val secondDiagonal = LineSegment(startControl, end) @@ -113,6 +119,7 @@ fun SmoothCubicBezierCurve.toCubicBezierCurve(previous: CubicCurve<*>): CubicBez else -> throw IllegalStateException("A destructuring of control points is required for ${previous::class.simpleName}.") } + // todo: is this implied control point computed correctly? It doesn't look reflected return CubicBezierCurve( variant, parameters.map { (endControl, end) -> @@ -121,7 +128,26 @@ fun SmoothCubicBezierCurve.toCubicBezierCurve(previous: CubicCurve<*>): CubicBez ) } -fun CubicCurve<*>.liesOnCircle( +fun SmoothCubicBezierCurve.Parameter.interpolate( + currentPoint: Point, + previousControl: Point, + t: Float, +): Point { + val startControl = (currentPoint * 2f) - previousControl + + val param = 1 - t + val paramSquare = param * param + val paramCube = paramSquare * param + val square = t * t + val cube = square * t + + return Point( + x = paramCube * currentPoint.x + 3 * paramSquare * t * startControl.x + 3 * param * square * endControl.x + cube * end.x, + y = paramCube * currentPoint.y + 3 * paramSquare * t * startControl.y + 3 * param * square * endControl.y + cube * end.y, + ) +} + +fun CubicBezierCurve.liesOnCircle( circle: Circle, tolerance: Float = 1e-3f, ): Boolean { @@ -129,7 +155,7 @@ fun CubicCurve<*>.liesOnCircle( val tolerance = min(ARC_THRESHOLD * tolerance, ARC_TOLERANCE * circle.radius / 100) return floatArrayOf(0f, 0.25f, 0.5f, 0.75f, 1f).all { t -> - abs(interpolate(t).distanceTo(circle.center) - circle.radius) <= tolerance + abs(interpolate(Point.ZERO, t).distanceTo(circle.center) - circle.radius) <= tolerance } } @@ -142,3 +168,119 @@ fun CubicBezierCurve.findArcAngle(circle: Circle): Float { return acos(innerProduct / magnitudeProduct) } + +fun QuadraticBezierCurve.Parameter.interpolate( + currentPoint: Point, + t: Float, +): Point { + val param = 1 - t + val paramSquare = param * param + val square = t * t + + return Point( + x = paramSquare * currentPoint.x + 2 * param * t * control.x + square * end.x, + y = paramSquare * currentPoint.y + 2 * param * t * control.y + square * end.y, + ) +} + +// todo: it might be a little nicer if the T parameter was a value class around a point +fun Point.interpolateSmoothQuadraticBezierCurve( + currentPoint: Point, + control: Point, + t: Float, +): Point { + val param = 1 - t + val paramSquare = param * param + val square = t * t + + return Point( + x = paramSquare * currentPoint.x + 2 * param * t * control.x + square * x, + y = paramSquare * currentPoint.y + 2 * param * t * control.y + square * y, + ) +} + +data class CenterParameterization( + val center: Point, + val radiusX: Float, + val radiusY: Float, + val phi: Double, +) + +/** + * Computes the parameters needed to specify the entire ellipse + * @param currentPoint the current point at the start of the curve in absolute coordinates + * + * **See also:** [https://www.w3.org/TR/SVG11/implnote.html#ArcConversionEndpointToCenter](https://www.w3.org/TR/SVG11/implnote.html#ArcConversionEndpointToCenter) + */ +fun EllipticalArcCurve.Parameter.computeCenterParameterization( + variant: CommandVariant, + currentPoint: Point, +): CenterParameterization { + val phi = Math.toRadians(angle.toDouble()) + val cosPhi = cos(phi) + val sinPhi = sin(phi) + var rx = radiusX + var ry = radiusY + + val start = currentPoint + val end = if (variant == CommandVariant.RELATIVE) end + currentPoint else end + + val x1prime = cosPhi * (start.x - end.x) / 2.0f + sinPhi * (start.y - end.y) / 2.0f + val y1prime = -sinPhi * (start.x - end.x) / 2.0f + cosPhi * (start.y - end.y) / 2.0f + + // handle minuscule radii + val x1primeSquared = x1prime * x1prime + val y1primeSquared = y1prime * y1prime + + var radiusChecker = x1primeSquared / (radiusX * radiusX) + y1primeSquared / (radiusY * radiusY) + if (radiusChecker > 1) { + val root = sqrt(radiusChecker) + rx *= root.toFloat() + ry *= root.toFloat() + } + + var rx2 = rx * rx + var ry2 = ry * ry + val sign = if ((arc == EllipticalArcCurve.ArcFlag.LARGE) == (sweep == EllipticalArcCurve.SweepFlag.CLOCKWISE)) -1 else 1 + val sq = ((rx2 * ry2 - rx2 * y1primeSquared - ry2 * x1primeSquared) / (rx2 * y1primeSquared + ry2 * x1primeSquared)) + val c = sign * sqrt(sq.coerceAtLeast(0.0)) + + val cxPrime = c * (rx * y1prime) / ry + val cyPrime = c * (-ry * x1prime) / rx + + val cx = cosPhi * cxPrime - sinPhi * cyPrime + (start.x + end.x) / 2.0 + val cy = sinPhi * cxPrime + cosPhi * cyPrime + (start.y + end.y) / 2.0 + + return CenterParameterization( + Point(cx.toFloat(), cy.toFloat()), + rx, + ry, + phi, + ) +} + +/** + * Computes an axis-aligned bounding box for an elliptical arc. + * + * The entire ellipse is used to calculate the box, not just + * the segment of the ellipse that constitutes the arc. + */ +fun EllipticalArcCurve.Parameter.computeBoundingBox( + variant: CommandVariant, + currentPoint: Point, +): Rectangle { + val centerParameterization = computeCenterParameterization(variant, currentPoint) + + val cosPhi = cos(centerParameterization.phi) + val sinPhi = sin(centerParameterization.phi) + + val xOffset = hypot(centerParameterization.radiusX * cosPhi, centerParameterization.radiusY * sinPhi) + val yOffset = hypot(centerParameterization.radiusX * sinPhi, centerParameterization.radiusY * cosPhi) + + return Rectangle( + centerParameterization.center.x - xOffset.toFloat(), + centerParameterization.center.y + yOffset.toFloat(), + centerParameterization.center.x + xOffset.toFloat(), + centerParameterization.center.y - yOffset.toFloat(), + ) +} diff --git a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Rectangle.kt b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Rectangle.kt new file mode 100644 index 00000000..5d241c52 --- /dev/null +++ b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Rectangle.kt @@ -0,0 +1,12 @@ +package com.jzbrooks.vgo.core.util.math + +/** Coordinate system is SVGs. Origin is top-left. Coordinates lower on the screen are 'higher'. */ +data class Rectangle( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) + +infix fun Rectangle.intersects(rectangle: Rectangle): Boolean = + !(left > rectangle.right || right < rectangle.left || top < rectangle.bottom || bottom > rectangle.top) diff --git a/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Surveyor.kt b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Surveyor.kt new file mode 100644 index 00000000..80ea013e --- /dev/null +++ b/vgo-core/src/main/kotlin/com/jzbrooks/vgo/core/util/math/Surveyor.kt @@ -0,0 +1,300 @@ +package com.jzbrooks.vgo.core.util.math + +import com.jzbrooks.vgo.core.graphic.command.ClosePath +import com.jzbrooks.vgo.core.graphic.command.Command +import com.jzbrooks.vgo.core.graphic.command.CommandVariant +import com.jzbrooks.vgo.core.graphic.command.CubicBezierCurve +import com.jzbrooks.vgo.core.graphic.command.EllipticalArcCurve +import com.jzbrooks.vgo.core.graphic.command.HorizontalLineTo +import com.jzbrooks.vgo.core.graphic.command.LineTo +import com.jzbrooks.vgo.core.graphic.command.MoveTo +import com.jzbrooks.vgo.core.graphic.command.QuadraticBezierCurve +import com.jzbrooks.vgo.core.graphic.command.SmoothCubicBezierCurve +import com.jzbrooks.vgo.core.graphic.command.SmoothQuadraticBezierCurve +import com.jzbrooks.vgo.core.graphic.command.VerticalLineTo + +/** + * Makes determinations based on the boundaries of a given set of path data + */ +class Surveyor { + private val pathStart = ArrayDeque() + + // Updated once per process call when computing + // the other variant of the command. This works + // because the coordinates are accurate regardless + // of their absolute or relative nature. + private lateinit var currentPoint: Point + private lateinit var previousControlPoint: Point + private lateinit var rectangle: Rectangle + + private val Command.shouldResetPreviousControlPoint: Boolean + get() = + when (this) { + is MoveTo, + is LineTo, + is HorizontalLineTo, + is VerticalLineTo, + is EllipticalArcCurve, + ClosePath, + -> true + + is CubicBezierCurve, + is SmoothCubicBezierCurve, + is QuadraticBezierCurve, + is SmoothQuadraticBezierCurve, + -> false + } + + fun findBoundingBox(commands: List): Rectangle { + pathStart.clear() + currentPoint = Point(0f, 0f) + + (commands.firstOrNull() as MoveTo?)?.let { firstMoveTo -> + currentPoint = firstMoveTo.parameters.first() + previousControlPoint = currentPoint + pathStart.addFirst(currentPoint.copy()) + rectangle = Rectangle(currentPoint.x, currentPoint.y, currentPoint.x, currentPoint.y) + + updateBoundingBoxForLineParameters(firstMoveTo.variant, firstMoveTo.parameters.drop(1)) + } + + for (i in commands.indices.drop(1)) { + val command = commands[i] + val previousCommand = commands.getOrNull(i - 1) + + if (previousCommand is ClosePath && command !is MoveTo) { + pathStart.addFirst(currentPoint.copy()) + } + + if (command.shouldResetPreviousControlPoint) { + previousControlPoint = currentPoint + } + + when (command) { + is MoveTo -> { + if (command.variant == CommandVariant.RELATIVE) { + currentPoint += command.parameters.first() + } else { + currentPoint = command.parameters.first() + } + + pathStart.addFirst(currentPoint.copy()) + + updateBoundingBoxForLineParameters(command.variant, command.parameters.drop(1)) + } + + is LineTo -> { + updateBoundingBoxForLineParameters(command.variant, command.parameters) + } + + is HorizontalLineTo -> { + for (parameter in command.parameters) { + currentPoint = + if (command.variant == CommandVariant.RELATIVE) { + currentPoint.copy(x = currentPoint.x + parameter) + } else { + currentPoint.copy(x = parameter) + } + + if (currentPoint.x < rectangle.left) { + rectangle = rectangle.copy(left = currentPoint.x) + } + + if (currentPoint.x > rectangle.right) { + rectangle = rectangle.copy(right = currentPoint.x) + } + } + } + + is VerticalLineTo -> { + for (parameter in command.parameters) { + currentPoint = + if (command.variant == CommandVariant.RELATIVE) { + currentPoint.copy(y = currentPoint.y + parameter) + } else { + currentPoint.copy(y = parameter) + } + + if (currentPoint.y > rectangle.top) { + rectangle = rectangle.copy(top = currentPoint.y) + } + + if (currentPoint.y < rectangle.bottom) { + rectangle = rectangle.copy(bottom = currentPoint.y) + } + } + } + + is CubicBezierCurve -> { + for (parameter in command.parameters) { + if (command.variant == CommandVariant.RELATIVE) { + for (t in interpolationResolution) { + val interpolatedPoint = parameter.interpolate(Point.ZERO, t) + currentPoint + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = currentPoint + parameter.endControl + currentPoint += parameter.end + } else { + for (t in interpolationResolution) { + val interpolatedPoint = parameter.interpolate(currentPoint, t) + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = parameter.endControl + currentPoint = parameter.end + } + } + } + + is SmoothCubicBezierCurve -> { + for (parameter in command.parameters) { + if (command.variant == CommandVariant.RELATIVE) { + for (t in interpolationResolution) { + val interpolatedPoint = + parameter.interpolate(Point.ZERO, previousControlPoint - currentPoint, t) + currentPoint + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = currentPoint + parameter.endControl + currentPoint += parameter.end + } else { + for (t in interpolationResolution) { + val interpolatedPoint = parameter.interpolate(currentPoint, previousControlPoint, t) + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = parameter.endControl + currentPoint = parameter.end + } + } + } + + is QuadraticBezierCurve -> { + for (parameter in command.parameters) { + if (command.variant == CommandVariant.RELATIVE) { + for (t in interpolationResolution) { + val interpolatedPoint = parameter.interpolate(Point.ZERO, t) + currentPoint + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = currentPoint + parameter.control + currentPoint += parameter.end + } else { + for (t in interpolationResolution) { + val interpolatedPoint = parameter.interpolate(currentPoint, t) + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = parameter.control + currentPoint = parameter.end + } + } + } + + is SmoothQuadraticBezierCurve -> { + for (parameter in command.parameters) { + val control = currentPoint * 2f - previousControlPoint + + if (command.variant == CommandVariant.RELATIVE) { + for (t in interpolationResolution) { + val interpolatedPoint = + parameter.interpolateSmoothQuadraticBezierCurve(Point.ZERO, control - currentPoint, t) + currentPoint + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = control + currentPoint += parameter + } else { + for (t in interpolationResolution) { + val interpolatedPoint = parameter.interpolateSmoothQuadraticBezierCurve(currentPoint, control, t) + expandBoundingBoxForPoint(interpolatedPoint) + } + + previousControlPoint = control + currentPoint = parameter + } + } + } + + is EllipticalArcCurve -> { + for (arcParameter in command.parameters) { + val box = arcParameter.computeBoundingBox(command.variant, currentPoint) + + expandBoundingBoxForBox(box) + + if (command.variant == CommandVariant.RELATIVE) { + currentPoint += arcParameter.end + } else { + currentPoint = arcParameter.end + } + } + } + + is ClosePath -> { + // If there is a close path, there should be a corresponding path start entry on the stack + currentPoint = pathStart.removeFirst() + command + } + } + } + + return rectangle + } + + private fun updateBoundingBoxForLineParameters( + variant: CommandVariant, + linesTo: List, + ) { + for (lineToParameter in linesTo) { + if (variant == CommandVariant.RELATIVE) { + currentPoint += lineToParameter + } else { + currentPoint = lineToParameter + } + + expandBoundingBoxForPoint(currentPoint) + } + } + + private fun expandBoundingBoxForPoint(point: Point) { + if (point.x < rectangle.left) { + rectangle = rectangle.copy(left = point.x) + } + + if (point.x > rectangle.right) { + rectangle = rectangle.copy(right = point.x) + } + + if (point.y > rectangle.top) { + rectangle = rectangle.copy(top = point.y) + } + + if (point.y < rectangle.bottom) { + rectangle = rectangle.copy(bottom = point.y) + } + } + + private fun expandBoundingBoxForBox(box: Rectangle) { + if (box.left < rectangle.left) { + rectangle = rectangle.copy(left = box.left) + } + + if (box.right > rectangle.right) { + rectangle = rectangle.copy(right = box.right) + } + + if (box.top > rectangle.top) { + rectangle = rectangle.copy(top = box.top) + } + + if (box.bottom < rectangle.bottom) { + rectangle = rectangle.copy(bottom = box.bottom) + } + } + + private companion object { + private const val RESOLUTION = 10 + val interpolationResolution = (1..RESOLUTION).map { it / RESOLUTION.toDouble() }.map(Double::toFloat) + } +} diff --git a/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/optimization/MergePathsTests.kt b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/optimization/MergePathsTests.kt index c967914f..4a688231 100644 --- a/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/optimization/MergePathsTests.kt +++ b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/optimization/MergePathsTests.kt @@ -2,14 +2,17 @@ package com.jzbrooks.vgo.core.optimization import assertk.assertThat import assertk.assertions.containsExactly +import assertk.assertions.hasSize import assertk.assertions.index import assertk.assertions.isEqualTo import com.jzbrooks.vgo.core.Color import com.jzbrooks.vgo.core.graphic.Group import com.jzbrooks.vgo.core.graphic.command.Command import com.jzbrooks.vgo.core.graphic.command.CommandVariant +import com.jzbrooks.vgo.core.graphic.command.EllipticalArcCurve import com.jzbrooks.vgo.core.graphic.command.LineTo import com.jzbrooks.vgo.core.graphic.command.MoveTo +import com.jzbrooks.vgo.core.graphic.command.QuadraticBezierCurve import com.jzbrooks.vgo.core.graphic.command.SmoothCubicBezierCurve import com.jzbrooks.vgo.core.util.element.createGraphic import com.jzbrooks.vgo.core.util.element.createPath @@ -113,6 +116,10 @@ class MergePathsTests { createPath( listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(40f, 40f))), + ), + ), + createPath( + listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(50f, 50f), Point(10f, 10f), Point(20f, 30f), Point(40f, 0f))), ), ), @@ -178,6 +185,10 @@ class MergePathsTests { createPath( listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(40f, 40f))), + ), + ), + createPath( + listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(50f, 50f), Point(10f, 10f), Point(20f, 30f), Point(40f, 0f))), ), ), @@ -243,6 +254,10 @@ class MergePathsTests { createPath( listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(40f, 40f))), + ), + ), + createPath( + listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(50f, 50f), Point(10f, 10f), Point(20f, 30f), Point(40f, 0f))), ), ), @@ -297,9 +312,109 @@ class MergePathsTests { createPath( listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(40f, 40f))), + ), + ), + createPath( + listOf( MoveTo(CommandVariant.ABSOLUTE, listOf(Point(50f, 50f), Point(10f, 10f), Point(20f, 30f), Point(40f, 0f))), ), ), ) } + + @Test + fun `overlapping paths are not merged`() { + // M 10,30 + // A 20,20 0,0,1 50,30 + // A 20,20 0,0,1 90,30 + // Q 90,60 50,90 + // Q 10,60 10,30 + val firstHeart = + createPath( + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 30f))), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 20f, + 20f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(50f, 30f), + ), + ), + ), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 20f, + 20f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(90f, 30f), + ), + ), + ), + QuadraticBezierCurve(CommandVariant.ABSOLUTE, listOf(QuadraticBezierCurve.Parameter(Point(90f, 60f), Point(50f, 90f)))), + QuadraticBezierCurve(CommandVariant.ABSOLUTE, listOf(QuadraticBezierCurve.Parameter(Point(10f, 60f), Point(10f, 30f)))), + ), + ) + + // M 20,40 + // A 20,20 0,0,1 60,40 + // A 20,20 0,0,1 100,40 + // Q 100,70 60,100 + // Q 20,70 20,40 + val offsetHeart = + createPath( + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(20f, 40f))), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 20f, + 20f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(60f, 40f), + ), + ), + ), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 20f, + 20f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(100f, 40f), + ), + ), + ), + QuadraticBezierCurve( + CommandVariant.ABSOLUTE, + listOf(QuadraticBezierCurve.Parameter(Point(100f, 70f), Point(60f, 100f))), + ), + QuadraticBezierCurve( + CommandVariant.ABSOLUTE, + listOf(QuadraticBezierCurve.Parameter(Point(20f, 70f), Point(20f, 40f))), + ), + ), + ) + + val graphic = createGraphic(listOf(firstHeart, offsetHeart)) + val optimization = MergePaths() + + traverseBottomUp(graphic) { it.accept(optimization) } + + assertThat(graphic::elements).hasSize(2) + } } diff --git a/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/CurvesTests.kt b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/CurvesTests.kt index 767e4389..e3820f4b 100644 --- a/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/CurvesTests.kt +++ b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/CurvesTests.kt @@ -3,10 +3,12 @@ package com.jzbrooks.vgo.core.util.math import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isFalse -import assertk.assertions.isNull import assertk.assertions.isTrue +import assertk.assertions.prop import com.jzbrooks.vgo.core.graphic.command.CommandVariant import com.jzbrooks.vgo.core.graphic.command.CubicBezierCurve +import com.jzbrooks.vgo.core.graphic.command.EllipticalArcCurve +import com.jzbrooks.vgo.core.graphic.command.QuadraticBezierCurve import com.jzbrooks.vgo.core.graphic.command.SmoothCubicBezierCurve import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -31,17 +33,10 @@ class CurvesTests { ), ) - @Test - fun `Ensure non-relative interpolation throws`() { - assertThrows { - nonRelativeCurve.interpolate(0.2f) - } - } - @Test fun `Ensure multiple curve parameter interpolation throws`() { assertThrows { - multiParameterCurve.interpolate(0.2f) + multiParameterCurve.interpolate(Point.ZERO, 0.2f) } } @@ -60,34 +55,38 @@ class CurvesTests { } @Test - fun `Ensure non-relative shortcut throws`() { - val nonRelativeCurve = - SmoothCubicBezierCurve( + fun `Elliptical arc center computation`() { + val curve = + EllipticalArcCurve( CommandVariant.ABSOLUTE, listOf( - SmoothCubicBezierCurve.Parameter(Point(-5f, 2.25f), Point(-5f, 5f)), + EllipticalArcCurve.Parameter( + 50f, + 50f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(200f, 100f), + ), ), ) - assertThrows { - nonRelativeCurve.isConvex() - } + val result = curve.parameters.first().computeCenterParameterization(CommandVariant.ABSOLUTE, Point(10f, 10f)) + + assertThat(result).prop(CenterParameterization::center).isEqualTo(Point(105f, 55f)) } @Test - fun `Ensure multiple shortcut curve parameters throws`() { - val mutliParameterCurve = - SmoothCubicBezierCurve( - CommandVariant.RELATIVE, - listOf( - SmoothCubicBezierCurve.Parameter(Point(-5f, 2.25f), Point(-5f, 5f)), - SmoothCubicBezierCurve.Parameter(Point(-5f, 2.25f), Point(-5f, 5f)), - ), + fun `Quadratic interpolation works`() { + val curve = + QuadraticBezierCurve( + CommandVariant.ABSOLUTE, + listOf(QuadraticBezierCurve.Parameter(Point(5f, 10f), Point(10f, 10f))), ) - assertThrows { - mutliParameterCurve.isConvex() - } + val result = curve.parameters.first().interpolate(Point.ZERO, 0.5f) + + assertThat(result).isEqualTo(Point(5f, 7.5f)) } @MethodSource @@ -97,24 +96,10 @@ class CurvesTests { assertThat(circle).isEqualTo(data.expectedCircle) } - @MethodSource - @ParameterizedTest - fun `Shortcut curve does not fit to circle`(curve: SmoothCubicBezierCurve) { - val circle = curve.fitCircle(0.01f) - assertThat(circle).isNull() - } - @MethodSource @ParameterizedTest fun `Point along a curve is computed correctly`(data: ParameterizedCurve) { - val point = data.curve.interpolate(data.t) - assertThat(point).isEqualTo(data.expected) - } - - @MethodSource - @ParameterizedTest - fun `Point along a shortcut curve is computed correctly`(data: ShortcutParameterizedCurve) { - val point = data.curve.interpolate(data.t) + val point = data.curve.interpolate(Point.ZERO, data.t) assertThat(point).isEqualTo(data.expected) } @@ -130,28 +115,11 @@ class CurvesTests { assertThat(curve.isConvex()).isFalse() } - @MethodSource - @ParameterizedTest - fun `Convex shortcut curves are convex`(curve: SmoothCubicBezierCurve) { - assertThat(curve.isConvex()).isTrue() - } - - @MethodSource - @ParameterizedTest - fun `Concave shortcut curves are not convex`(curve: SmoothCubicBezierCurve) { - assertThat(curve.isConvex()).isFalse() - } - data class FitCircle( val curve: CubicBezierCurve, val expectedCircle: Circle, ) - data class ShortcutFitCircle( - val curve: SmoothCubicBezierCurve, - val expectedCircle: Circle?, - ) - data class ParameterizedCurve( val curve: CubicBezierCurve, val t: Float, @@ -205,21 +173,6 @@ class CurvesTests { ), ) - @JvmStatic - fun `Point along a shortcut curve is computed correctly`(): List = - listOf( - ShortcutParameterizedCurve( - SmoothCubicBezierCurve( - CommandVariant.RELATIVE, - listOf( - SmoothCubicBezierCurve.Parameter(Point(-5.1f, 14f), Point(12.4f, 11.1f)), - ), - ), - 0.54f, - Point(-0.099726915f, 7.381562f), - ), - ) - @JvmStatic fun `Convex curves are convex`(): List = listOf( @@ -253,39 +206,5 @@ class CurvesTests { ), ), ) - - @JvmStatic - fun `Convex shortcut curves are convex`(): List = - listOf( - SmoothCubicBezierCurve( - CommandVariant.RELATIVE, - listOf( - SmoothCubicBezierCurve.Parameter(Point(5f, 2.25f), Point(5f, 5f)), - ), - ), - SmoothCubicBezierCurve( - CommandVariant.RELATIVE, - listOf( - SmoothCubicBezierCurve.Parameter(Point(1.5f, 0.5f), Point(3.5f, 2f)), - ), - ), - SmoothCubicBezierCurve( - CommandVariant.RELATIVE, - listOf( - SmoothCubicBezierCurve.Parameter(Point(109f, 8f), Point(113f, 12f)), - ), - ), - ) - - @JvmStatic - fun `Concave shortcut curves are not convex`(): List = - listOf( - SmoothCubicBezierCurve( - CommandVariant.RELATIVE, - listOf( - SmoothCubicBezierCurve.Parameter(Point(2.5f, 0f), Point(-5f, 2.25f)), - ), - ), - ) } } diff --git a/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/RectangleTests.kt b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/RectangleTests.kt new file mode 100644 index 00000000..2c28dad8 --- /dev/null +++ b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/RectangleTests.kt @@ -0,0 +1,38 @@ +package com.jzbrooks.vgo.core.util.math + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.jupiter.api.Test + +class RectangleTests { + @Test + fun `test non-intersection`() { + val first = Rectangle(10f, 90f, 110f, 10f) + val second = Rectangle(130f, 90f, 110f, 10f) + + val intersects = first intersects second + + assertThat(intersects).isFalse() + } + + @Test + fun `test intersection`() { + val first = Rectangle(10f, 90f, 110f, 10f) + val second = Rectangle(90f, 90f, 110f, 10f) + + val intersects = first intersects second + + assertThat(intersects).isTrue() + } + + @Test + fun `test interior rectangle`() { + val first = Rectangle(10f, 90f, 110f, 10f) + val second = Rectangle(20f, 40f, 40f, 20f) + + val intersects = first intersects second + + assertThat(intersects).isTrue() + } +} diff --git a/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/SurveyorTest.kt b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/SurveyorTest.kt new file mode 100644 index 00000000..3434179f --- /dev/null +++ b/vgo-core/src/test/kotlin/com/jzbrooks/vgo/core/util/math/SurveyorTest.kt @@ -0,0 +1,759 @@ +package com.jzbrooks.vgo.core.util.math + +import assertk.all +import assertk.assertThat +import assertk.assertions.isCloseTo +import assertk.assertions.prop +import com.jzbrooks.vgo.core.graphic.command.Command +import com.jzbrooks.vgo.core.graphic.command.CommandVariant +import com.jzbrooks.vgo.core.graphic.command.CubicBezierCurve +import com.jzbrooks.vgo.core.graphic.command.EllipticalArcCurve +import com.jzbrooks.vgo.core.graphic.command.HorizontalLineTo +import com.jzbrooks.vgo.core.graphic.command.LineTo +import com.jzbrooks.vgo.core.graphic.command.MoveTo +import com.jzbrooks.vgo.core.graphic.command.QuadraticBezierCurve +import com.jzbrooks.vgo.core.graphic.command.SmoothCubicBezierCurve +import com.jzbrooks.vgo.core.graphic.command.SmoothQuadraticBezierCurve +import com.jzbrooks.vgo.core.graphic.command.VerticalLineTo +import org.junit.jupiter.api.Test + +class SurveyorTest { + @Test + fun `line bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 5f))), + LineTo(CommandVariant.RELATIVE, listOf(Point(10f, 5f))), + LineTo(CommandVariant.RELATIVE, listOf(Point(10f, 5f))), + HorizontalLineTo(CommandVariant.RELATIVE, listOf(10f)), + VerticalLineTo(CommandVariant.RELATIVE, listOf(5f)), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(20f, 0.1f) + prop(Rectangle::right).isCloseTo(40f, 0.1f) + prop(Rectangle::bottom).isCloseTo(5f, 0.1f) + } + } + + @Test + fun `cubic curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(5.9f, 0.1f) + prop(Rectangle::top).isCloseTo(100f, 0.1f) + prop(Rectangle::right).isCloseTo(20f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `relative polycubic bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + CubicBezierCurve( + CommandVariant.RELATIVE, + listOf( + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(310f, 0.1f) + prop(Rectangle::right).isCloseTo(70f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `absolute polycubic bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + CubicBezierCurve.Parameter( + Point(40f, 90f), + Point(70f, 120f), + Point(150f, 200f), + ), + CubicBezierCurve.Parameter( + Point(150f, 150f), + Point(175f, 175f), + Point(200f, 250f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(5.9f, 0.1f) + prop(Rectangle::top).isCloseTo(250f, 0.1f) + prop(Rectangle::right).isCloseTo(200f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `relative smooth polycubic bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + CubicBezierCurve( + CommandVariant.RELATIVE, + listOf( + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + ), + ), + SmoothCubicBezierCurve( + CommandVariant.RELATIVE, + listOf( + SmoothCubicBezierCurve.Parameter( + Point(10f, 50f), + Point(20f, 100f), + ), + SmoothCubicBezierCurve.Parameter( + Point(10f, 50f), + Point(20f, 100f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(310f, 0.1f) + prop(Rectangle::right).isCloseTo(70f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `absolute smooth polycubic bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + ), + ), + SmoothCubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + SmoothCubicBezierCurve.Parameter( + Point(70f, 120f), + Point(150f, 200f), + ), + SmoothCubicBezierCurve.Parameter( + Point(175f, 175f), + Point(200f, 250f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(5.9f, 0.1f) + prop(Rectangle::top).isCloseTo(250f, 0.1f) + prop(Rectangle::right).isCloseTo(200f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `smooth cubic bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + CubicBezierCurve.Parameter( + Point(0f, 50f), + Point(10f, 50f), + Point(20f, 100f), + ), + ), + ), + SmoothCubicBezierCurve(CommandVariant.ABSOLUTE, listOf(SmoothCubicBezierCurve.Parameter(Point(0f, 25f), Point(10f, 75f)))), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(5.9f, 0.1f) + prop(Rectangle::top).isCloseTo(111.8f, 0.1f) + prop(Rectangle::right).isCloseTo(21.9f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `quadratic curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + QuadraticBezierCurve(CommandVariant.ABSOLUTE, listOf(QuadraticBezierCurve.Parameter(Point(40f, 10f), Point(10f, 40f)))), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(40f, 0.1f) + prop(Rectangle::right).isCloseTo(25f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `absolute polyquadratic curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + QuadraticBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + QuadraticBezierCurve.Parameter( + Point(40f, 10f), + Point(10f, 40f), + ), + QuadraticBezierCurve.Parameter( + Point(90f, 40f), + Point(50f, 80f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(80f, 0.1f) + prop(Rectangle::right).isCloseTo(63.2f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `relative polyquadratic curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + QuadraticBezierCurve( + CommandVariant.RELATIVE, + listOf( + QuadraticBezierCurve.Parameter( + Point(40f, 10f), + Point(10f, 40f), + ), + QuadraticBezierCurve.Parameter( + Point(90f, 40f), + Point(50f, 80f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(130f, 0.1f) + prop(Rectangle::right).isCloseTo(82.3f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `absolute smooth polyquadratic curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + QuadraticBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + QuadraticBezierCurve.Parameter( + Point(40f, 10f), + Point(10f, 40f), + ), + ), + ), + SmoothQuadraticBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + Point(90f, 40f), + Point(50f, 80f), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(3.6f, 0.1f) + prop(Rectangle::top).isCloseTo(80f, 0.1f) + prop(Rectangle::right).isCloseTo(136.4f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `relative smooth polyquadratic curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 10f))), + QuadraticBezierCurve( + CommandVariant.ABSOLUTE, + listOf( + QuadraticBezierCurve.Parameter( + Point(40f, 10f), + Point(10f, 40f), + ), + ), + ), + SmoothQuadraticBezierCurve( + CommandVariant.RELATIVE, + listOf( + Point(90f, 40f), + Point(50f, 80f), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(4f, 0.1f) + prop(Rectangle::top).isCloseTo(160f, 0.1f) + prop(Rectangle::right).isCloseTo(175.6f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `absolute elliptical arc curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(200f, 200f))), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 50f, + 50f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(200f, 100f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(150f, 0.1f) + prop(Rectangle::top).isCloseTo(200f, 0.1f) + prop(Rectangle::right).isCloseTo(250f, 0.1f) + prop(Rectangle::bottom).isCloseTo(100f, 0.1f) + } + } + + @Test + fun `relative elliptical arc curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(200f, 200f))), + EllipticalArcCurve( + CommandVariant.RELATIVE, + listOf( + EllipticalArcCurve.Parameter( + 50f, + 50f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(200f, 100f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(188.1f, 0.1f) + prop(Rectangle::top).isCloseTo(361.8f, 0.1f) + prop(Rectangle::right).isCloseTo(411.8f, 0.1f) + prop(Rectangle::bottom).isCloseTo(138.2f, 0.1f) + } + } + + @Test + fun `rotated elliptical arc curve bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(200f, 200f))), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 50f, + 80f, + 40f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(400f, 400f), + ), + ), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(119.1f, 0.1f) + prop(Rectangle::top).isCloseTo(495.3f, 0.1f) + prop(Rectangle::right).isCloseTo(481f, 0.1f) + prop(Rectangle::bottom).isCloseTo(104.7f, 0.1f) + } + } + + @Test + fun `moveto polycommand bounding box`() { + val commands = + listOf( + MoveTo(CommandVariant.RELATIVE, listOf(Point(10f, 10f), Point(10f, 10f), Point(10f, 10f))), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(30f, 0.1f) + prop(Rectangle::right).isCloseTo(30f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `shorthand after moveto bounding box`() { + // M 100,200 S 200,300,300,200 + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(100f, 200f))), + SmoothCubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(SmoothCubicBezierCurve.Parameter(Point(200f, 300f), Point(300f, 200f))), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(100f, 0.1f) + prop(Rectangle::top).isCloseTo(244.1f, 0.1f) + prop(Rectangle::right).isCloseTo(300f, 0.1f) + prop(Rectangle::bottom).isCloseTo(200f, 0.1f) + } + } + + @Test + fun `heart bounding box`() { + // M 10,30 + // A 20,20 0,0,1 50,30 + // A 20,20 0,0,1 90,30 + // Q 90,60 50,90 + // Q 10,60 10,30 + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(10f, 30f))), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 20f, + 20f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(50f, 30f), + ), + ), + ), + EllipticalArcCurve( + CommandVariant.ABSOLUTE, + listOf( + EllipticalArcCurve.Parameter( + 20f, + 20f, + 0f, + EllipticalArcCurve.ArcFlag.SMALL, + EllipticalArcCurve.SweepFlag.CLOCKWISE, + Point(90f, 30f), + ), + ), + ), + QuadraticBezierCurve(CommandVariant.ABSOLUTE, listOf(QuadraticBezierCurve.Parameter(Point(90f, 60f), Point(50f, 90f)))), + QuadraticBezierCurve(CommandVariant.ABSOLUTE, listOf(QuadraticBezierCurve.Parameter(Point(10f, 60f), Point(10f, 30f)))), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(10f, 0.1f) + prop(Rectangle::top).isCloseTo(90f, 0.1f) + prop(Rectangle::right).isCloseTo(90f, 0.1f) + prop(Rectangle::bottom).isCloseTo(10f, 0.1f) + } + } + + @Test + fun `state bounding box`() { + // M161.851,18.849C162.174,19.023 162.162,19.285 162.206,19.5C162.649,21.694 163.971,23.02 165.916,23.671C167.186,24.096 168.488,24.414 169.768,24.778C169.816,24.685 169.874,24.625 169.871,24.569C169.77,22.9 169.769,22.9 171.364,22.9L173.323,22.9C173.192,23.464 172.66,23.654 172.444,24.236C173.691,24.681 174.935,25.117 176.174,25.572C176.468,25.68 176.873,25.684 177.012,26.002C177.462,27.033 178.322,26.931 179.137,26.934C181.675,26.944 184.209,27.345 186.772,26.934C186.749,27.413 186.404,27.452 186.19,27.593C183.371,29.448 180.694,31.518 178.048,33.636C177.05,34.435 176.496,35.629 175.935,36.727C175.608,37.365 175.797,38.412 175.983,39.215C176.388,40.965 175.875,42.246 174.441,43.066C172.895,43.95 172.911,45.356 173.131,46.882C173.246,47.681 173.396,48.461 173.619,49.246C173.897,50.223 173.515,51.251 173.397,52.252C173.203,53.893 173.861,55.024 175.367,55.492C176.189,55.748 177.012,56.002 177.829,56.277C178.833,56.615 179.673,57.164 180.068,58.286C180.282,58.894 180.564,59.444 181.185,59.725C181.832,60.019 181.553,60.667 181.565,61.164C181.601,62.764 181.583,62.729 180.072,62.697C171.889,62.525 163.706,62.357 155.522,62.236C154.716,62.224 154.542,61.995 154.537,61.172C154.513,57.578 154.386,53.984 154.366,50.389C154.362,49.619 153.935,49.23 153.554,48.76C152.69,47.694 152.49,46.395 153.291,45.295C154.015,44.301 154.041,43.361 153.823,42.195C153.251,39.144 152.868,36.056 152.538,32.962C152.496,32.562 152.47,32.135 152.551,31.747C152.844,30.349 152.686,29.048 151.854,27.908C150.742,26.384 150.876,24.622 150.896,22.864C150.919,20.852 150.902,20.879 152.749,20.822C155.263,20.744 157.777,20.63 160.291,20.51C161.714,20.442 161.742,20.392 161.851,18.849 + val commands = + listOf( + MoveTo(CommandVariant.ABSOLUTE, listOf(Point(161.851f, 18.849f))), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(162.174f, 19.023f), Point(162.162f, 19.285f), Point(162.206f, 19.5f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(162.649f, 21.694f), Point(163.971f, 23.02f), Point(165.916f, 23.671f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(167.186f, 24.096f), Point(168.488f, 24.414f), Point(169.768f, 24.778f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(169.816f, 24.685f), Point(169.874f, 24.625f), Point(169.871f, 24.569f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(169.77f, 22.9f), Point(169.769f, 22.9f), Point(171.364f, 22.9f))), + ), + LineTo(CommandVariant.ABSOLUTE, listOf(Point(173.323f, 22.9f))), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(173.192f, 23.464f), Point(172.66f, 23.654f), Point(172.444f, 24.236f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(173.691f, 24.681f), Point(174.935f, 25.117f), Point(176.174f, 25.572f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(176.468f, 25.68f), Point(176.873f, 25.684f), Point(177.012f, 26.002f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(177.462f, 27.033f), Point(178.322f, 26.931f), Point(179.137f, 26.934f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(181.675f, 26.944f), Point(184.209f, 27.345f), Point(186.772f, 26.934f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(186.749f, 27.413f), Point(186.404f, 27.452f), Point(186.19f, 27.593f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(183.371f, 29.448f), Point(180.694f, 31.518f), Point(178.048f, 33.636f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(177.05f, 34.435f), Point(176.496f, 35.629f), Point(175.935f, 36.727f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(175.608f, 37.365f), Point(175.797f, 38.412f), Point(175.983f, 39.215f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(176.388f, 40.965f), Point(175.875f, 42.246f), Point(174.441f, 43.066f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(172.895f, 43.95f), Point(172.911f, 45.356f), Point(173.131f, 46.882f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(173.246f, 47.681f), Point(173.396f, 48.461f), Point(173.619f, 49.246f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(173.897f, 50.223f), Point(173.515f, 51.251f), Point(173.397f, 52.252f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(173.203f, 53.893f), Point(173.861f, 55.024f), Point(175.367f, 55.492f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(176.189f, 55.748f), Point(177.012f, 56.002f), Point(177.829f, 56.277f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(178.833f, 56.615f), Point(179.673f, 57.164f), Point(180.068f, 58.286f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(180.282f, 58.894f), Point(180.564f, 59.444f), Point(181.185f, 59.725f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(181.832f, 60.019f), Point(181.553f, 60.667f), Point(181.565f, 61.164f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(181.601f, 62.764f), Point(181.583f, 62.729f), Point(180.072f, 62.697f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(171.889f, 62.525f), Point(163.706f, 62.357f), Point(155.522f, 62.236f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(154.716f, 62.224f), Point(154.542f, 61.995f), Point(154.537f, 61.172f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(154.513f, 57.578f), Point(154.386f, 53.984f), Point(154.366f, 50.389f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(154.362f, 49.619f), Point(153.935f, 49.23f), Point(153.554f, 48.76f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(152.69f, 47.694f), Point(152.49f, 46.395f), Point(153.291f, 45.295f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(154.015f, 44.301f), Point(154.041f, 43.361f), Point(153.823f, 42.195f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(153.251f, 39.144f), Point(152.868f, 36.056f), Point(152.538f, 32.962f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(152.496f, 32.562f), Point(152.47f, 32.135f), Point(152.551f, 31.747f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(152.844f, 30.349f), Point(152.686f, 29.048f), Point(151.854f, 27.908f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(150.742f, 26.384f), Point(150.876f, 24.622f), Point(150.896f, 22.864f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(150.919f, 20.852f), Point(150.902f, 20.879f), Point(152.749f, 20.822f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(155.263f, 20.744f), Point(157.777f, 20.63f), Point(160.291f, 20.51f))), + ), + CubicBezierCurve( + CommandVariant.ABSOLUTE, + listOf(CubicBezierCurve.Parameter(Point(161.714f, 20.442f), Point(161.742f, 20.392f), Point(161.851f, 18.849f))), + ), + ) + + val surveyor = Surveyor() + val box = surveyor.findBoundingBox(commands) + + assertThat(box).all { + prop(Rectangle::left).isCloseTo(150.9f, 0.1f) + prop(Rectangle::top).isCloseTo(62.7f, 0.1f) + prop(Rectangle::right).isCloseTo(186.8f, 0.1f) + prop(Rectangle::bottom).isCloseTo(18.8f, 0.1f) + } + } +} diff --git a/vgo/src/test/resources/baseline/avocado_example_optimized.xml b/vgo/src/test/resources/baseline/avocado_example_optimized.xml index 06ee381a..dfc69196 100644 --- a/vgo/src/test/resources/baseline/avocado_example_optimized.xml +++ b/vgo/src/test/resources/baseline/avocado_example_optimized.xml @@ -1,4 +1,8 @@ - + + + + + diff --git a/vgo/src/test/resources/baseline/eleven_below_single_optimized.xml b/vgo/src/test/resources/baseline/eleven_below_single_optimized.xml index 5856851e..809592e8 100644 --- a/vgo/src/test/resources/baseline/eleven_below_single_optimized.xml +++ b/vgo/src/test/resources/baseline/eleven_below_single_optimized.xml @@ -7,9 +7,48 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -26,7 +65,9 @@ - + + + @@ -44,7 +85,8 @@ - + + @@ -68,15 +110,12 @@ - + + - - - - - - - + + + @@ -85,7 +124,8 @@ - + + @@ -98,21 +138,17 @@ - - + - - - - + + - - + @@ -120,5 +156,7 @@ - + + + diff --git a/vgo/src/test/resources/baseline/nasa_optimized.svg b/vgo/src/test/resources/baseline/nasa_optimized.svg index 17915121..4f88f39f 100644 --- a/vgo/src/test/resources/baseline/nasa_optimized.svg +++ b/vgo/src/test/resources/baseline/nasa_optimized.svg @@ -36,8 +36,11 @@ - + + - + + + diff --git a/vgo/src/test/resources/baseline/nasa_optimized.xml b/vgo/src/test/resources/baseline/nasa_optimized.xml index 6ccb97ed..04291dd2 100644 --- a/vgo/src/test/resources/baseline/nasa_optimized.xml +++ b/vgo/src/test/resources/baseline/nasa_optimized.xml @@ -1,6 +1,17 @@ - + + + + + + + + + + - + + + diff --git a/vgo/src/test/resources/baseline/regression_101_optimized.xml b/vgo/src/test/resources/baseline/regression_101_optimized.xml new file mode 100644 index 00000000..8f76c1bf --- /dev/null +++ b/vgo/src/test/resources/baseline/regression_101_optimized.xml @@ -0,0 +1,4 @@ + + + + diff --git a/vgo/src/test/resources/baseline/regression_88_optimized.xml b/vgo/src/test/resources/baseline/regression_88_optimized.xml new file mode 100644 index 00000000..095b4a63 --- /dev/null +++ b/vgo/src/test/resources/baseline/regression_88_optimized.xml @@ -0,0 +1,4 @@ + + + + diff --git a/vgo/src/test/resources/in-place-modify/avocado_example_optimized.xml b/vgo/src/test/resources/in-place-modify/avocado_example_optimized.xml index 01f4a1ac..bd310e41 100644 --- a/vgo/src/test/resources/in-place-modify/avocado_example_optimized.xml +++ b/vgo/src/test/resources/in-place-modify/avocado_example_optimized.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/vgo/src/test/resources/regression_101.xml b/vgo/src/test/resources/regression_101.xml new file mode 100644 index 00000000..8ff75a9f --- /dev/null +++ b/vgo/src/test/resources/regression_101.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/vgo/src/test/resources/regression_88.xml b/vgo/src/test/resources/regression_88.xml new file mode 100644 index 00000000..8725a8b4 --- /dev/null +++ b/vgo/src/test/resources/regression_88.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file