diff --git a/.editorconfig b/.editorconfig index 95581296277..248df6926e9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,3 +29,4 @@ ktlint_standard_condition-wrapping = disabled ktlint_standard_function-literal = disabled ktlint_standard_backing-property-naming = disabled ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_no-unused-imports=enabled diff --git a/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt b/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt index 352e867f959..e9d6d2164a2 100644 --- a/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt +++ b/collect_app/src/main/java/org/odk/collect/android/formmanagement/CollectFormEntryControllerFactory.kt @@ -22,6 +22,7 @@ import org.odk.collect.entities.javarosa.filter.PullDataFunctionHandler import org.odk.collect.entities.javarosa.finalization.EntityFormFinalizationProcessor import org.odk.collect.entities.storage.EntitiesRepository import org.odk.collect.forms.instances.Instance +import org.odk.collect.geo.javarosa.IntersectsFunctionHandler import org.odk.collect.settings.keys.ProjectKeys import org.odk.collect.shared.settings.Settings import java.io.File @@ -46,6 +47,9 @@ class CollectFormEntryControllerFactory( externalDataHandlerPull ) ) + + it.addFunctionHandler(IntersectsFunctionHandler()) + it.addPostProcessor(EntityFormFinalizationProcessor()) it.addPostProcessor(EditedFormFinalizationProcessor(instance)) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java index 7ff19d2a30a..1841eebe880 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointMapWidget.java @@ -14,6 +14,8 @@ package org.odk.collect.android.widgets; +import static org.odk.collect.geo.GeoUtils.parseGeometryPoint; + import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; @@ -83,7 +85,7 @@ protected View onCreateWidgetView(Context context, FormEntryPrompt prompt, int a @Override public IAnswerData getAnswer() { - double[] parsedGeometryPoint = GeoWidgetUtils.parseGeometryPoint(answerText); + double[] parsedGeometryPoint = parseGeometryPoint(answerText); return parsedGeometryPoint == null ? null : new GeoPointData(parsedGeometryPoint); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java index 2df65875be9..bd443d78461 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/GeoPointWidget.java @@ -14,6 +14,8 @@ package org.odk.collect.android.widgets; +import static org.odk.collect.geo.GeoUtils.parseGeometryPoint; + import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; @@ -76,7 +78,7 @@ protected View onCreateWidgetView(Context context, FormEntryPrompt prompt, int a @Override public IAnswerData getAnswer() { - double[] parsedGeometryPoint = GeoWidgetUtils.parseGeometryPoint(answerText); + double[] parsedGeometryPoint = parseGeometryPoint(answerText); return parsedGeometryPoint == null ? null : new GeoPointData(parsedGeometryPoint); diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt index 9af41bbce87..6c4278fdcfc 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/items/SelectOneFromMapDialogFragment.kt @@ -27,6 +27,7 @@ import org.odk.collect.androidshared.livedata.NonNullLiveData import org.odk.collect.androidshared.ui.FragmentFactoryBuilder import org.odk.collect.async.Scheduler import org.odk.collect.entities.javarosa.parse.EntitySchema +import org.odk.collect.geo.geopoly.GeoPolyUtils.parseGeometry import org.odk.collect.geo.selection.IconifiedText import org.odk.collect.geo.selection.MappableSelectItem import org.odk.collect.geo.selection.SelectionMapData @@ -139,7 +140,7 @@ internal class SelectChoicesMapData( if (geometry != null) { try { - val points = GeoWidgetUtils.parseGeometry(geometry) + val points = parseGeometry(geometry) if (points.isNotEmpty()) { val withinBounds = points.all { GeoWidgetUtils.isWithinMapBounds(it) diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ActivityGeoDataRequester.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ActivityGeoDataRequester.kt index 8ac033c9de8..90d386f751a 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ActivityGeoDataRequester.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/ActivityGeoDataRequester.kt @@ -14,6 +14,7 @@ import org.odk.collect.geo.Constants.EXTRA_RETAIN_MOCK_ACCURACY import org.odk.collect.geo.geopoint.GeoPointActivity import org.odk.collect.geo.geopoint.GeoPointMapActivity import org.odk.collect.geo.geopoly.GeoPolyActivity +import org.odk.collect.geo.geopoly.GeoPolyUtils.parseGeometry import org.odk.collect.permissions.PermissionListener import org.odk.collect.permissions.PermissionsProvider import java.lang.Boolean.parseBoolean @@ -35,7 +36,7 @@ class ActivityGeoDataRequester( waitingForDataRegistry.waitForData(prompt.index) val bundle = Bundle().also { - val parsedGeometry = GeoWidgetUtils.parseGeometry(answerText) + val parsedGeometry = parseGeometry(answerText) if (parsedGeometry.isNotEmpty()) { it.putParcelable( GeoPointMapActivity.EXTRA_LOCATION, @@ -97,7 +98,7 @@ class ActivityGeoDataRequester( val intent = Intent(activity, GeoPolyActivity::class.java).also { it.putExtra( GeoPolyActivity.EXTRA_POLYGON, - GeoWidgetUtils.parseGeometry(answerText) + ArrayList(parseGeometry(answerText)) ) it.putExtra( GeoPolyActivity.OUTPUT_MODE_KEY, @@ -130,7 +131,7 @@ class ActivityGeoDataRequester( val intent = Intent(activity, GeoPolyActivity::class.java).also { it.putExtra( GeoPolyActivity.EXTRA_POLYGON, - GeoWidgetUtils.parseGeometry(answerText) + ArrayList(parseGeometry(answerText)) ) it.putExtra( GeoPolyActivity.OUTPUT_MODE_KEY, diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtils.kt b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtils.kt index ed904352eb3..36fe116b88b 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtils.kt +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtils.kt @@ -41,40 +41,6 @@ object GeoWidgetUtils { } } - @JvmStatic - fun parseGeometryPoint(answer: String?): DoubleArray? { - if (answer != null && answer.isNotEmpty()) { - val sa = answer.trim { it <= ' ' }.split(" ").toTypedArray() - return try { - doubleArrayOf( - sa[0].toDouble(), - if (sa.size > 1) sa[1].toDouble() else 0.0, - if (sa.size > 2) sa[2].toDouble() else 0.0, - if (sa.size > 3) sa[3].toDouble() else 0.0 - ) - } catch (e: Throwable) { - null - } - } else { - return null - } - } - - fun parseGeometry(geometry: String?): ArrayList { - val points = ArrayList() - - for (vertex in (geometry ?: "").split(";").toTypedArray()) { - val point = parseGeometryPoint(vertex) - if (point != null) { - points.add(MapPoint(point[0], point[1], point[2], point[3])) - } else { - return ArrayList() - } - } - - return points - } - fun isWithinMapBounds(point: MapPoint): Boolean { return point.latitude.absoluteValue <= 90 && point.longitude.absoluteValue <= 180 } diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/GeoPointMapWidgetTest.java b/collect_app/src/test/java/org/odk/collect/android/widgets/GeoPointMapWidgetTest.java index 7fdb575a084..fb13d1fee88 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/GeoPointMapWidgetTest.java +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/GeoPointMapWidgetTest.java @@ -27,6 +27,7 @@ import static org.odk.collect.android.widgets.support.QuestionWidgetHelpers.promptWithReadOnlyAndAnswer; import static org.odk.collect.android.widgets.support.QuestionWidgetHelpers.widgetDependencies; import static org.odk.collect.android.widgets.support.QuestionWidgetHelpers.widgetTestActivity; +import static org.odk.collect.geo.GeoUtils.parseGeometryPoint; @RunWith(AndroidJUnit4.class) public class GeoPointMapWidgetTest { @@ -51,7 +52,7 @@ public void getAnswer_whenPromptDoesNotHaveAnswer_returnsNull() { public void getAnswer_whenPromptHasAnswer_returnsPromptAnswer() { GeoPointMapWidget widget = createWidget(promptWithAnswer(answer)); assertEquals(widget.getAnswer().getDisplayText(), - new GeoPointData(GeoWidgetUtils.parseGeometryPoint(answer.getDisplayText())).getDisplayText()); + new GeoPointData(parseGeometryPoint(answer.getDisplayText())).getDisplayText()); } @Test diff --git a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtilsTest.kt b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtilsTest.kt index b9f67953c28..2d6b7a785da 100644 --- a/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtilsTest.kt +++ b/collect_app/src/test/java/org/odk/collect/android/widgets/utilities/GeoWidgetUtilsTest.kt @@ -9,15 +9,12 @@ import org.hamcrest.Matchers.equalTo import org.javarosa.core.model.data.GeoPointData import org.junit.Test import org.junit.runner.RunWith -import org.odk.collect.android.R import org.odk.collect.android.widgets.support.GeoWidgetHelpers import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.convertCoordinatesIntoDegreeFormat import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.floor import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.getGeoPointAnswerToDisplay import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.getGeoPolyAnswerToDisplay import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.isWithinMapBounds -import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.parseGeometry -import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.parseGeometryPoint import org.odk.collect.android.widgets.utilities.GeoWidgetUtils.truncateDouble import org.odk.collect.maps.MapPoint @@ -102,32 +99,6 @@ class GeoWidgetUtilsTest { assertEquals("qwerty", floor("qwerty")) } - @Test - fun parseGeometryPointTest() { - var gp = - parseGeometryPoint("37.45153333333334 -122.15539166666667 0.0 20.0")!! - assertEquals(37.45153333333334, gp[0]) - assertEquals(-122.15539166666667, gp[1]) - assertEquals(0.0, gp[2]) - assertEquals(20.0, gp[3]) - - gp = parseGeometryPoint("37.45153333333334")!! - assertEquals(37.45153333333334, gp[0]) - assertEquals(0.0, gp[1]) - assertEquals(0.0, gp[2]) - assertEquals(0.0, gp[3]) - - gp = parseGeometryPoint(" 37.45153333333334 -122.15539166666667 0.0 ")!! - assertEquals(37.45153333333334, gp[0]) - assertEquals(-122.15539166666667, gp[1]) - assertEquals(0.0, gp[2]) - assertEquals(0.0, gp[3]) - - assertEquals(null, parseGeometryPoint("37.45153333333334 -122.15539166666667 0.0 qwerty")) - assertEquals(null, parseGeometryPoint("")) - assertEquals(null, parseGeometryPoint(null)) - } - @Test fun truncateDoubleTest() { assertEquals("5", truncateDouble("5")) @@ -141,22 +112,6 @@ class GeoWidgetUtilsTest { assertEquals("", truncateDouble("qwerty")) } - @Test - fun parseGeometryTest() { - assertThat(parseGeometry("1.0 2.0 3 4"), equalTo(listOf(MapPoint(1.0, 2.0, 3.0, 4.0)))) - assertThat( - parseGeometry("1.0 2.0 3 4; 5.0 6.0 7 8"), - equalTo(listOf(MapPoint(1.0, 2.0, 3.0, 4.0), MapPoint(5.0, 6.0, 7.0, 8.0))) - ) - - assertThat(parseGeometry("blah"), equalTo(emptyList())) - assertThat(parseGeometry("1.0 2.0 3 4; blah"), equalTo(emptyList())) - assertThat( - parseGeometry("37.45153333333334 -122.15539166666667 0.0 qwerty"), - equalTo(emptyList()) - ) - } - @Test fun isWithinMapBoundsTest() { assertThat(isWithinMapBounds(MapPoint(90.0, 0.0, 0.0, 0.0)), equalTo(true)) diff --git a/collect_app/src/test/java/org/odk/collect/geo/javarosa/IntersectsFunctionHandlerTest.kt b/collect_app/src/test/java/org/odk/collect/geo/javarosa/IntersectsFunctionHandlerTest.kt new file mode 100644 index 00000000000..c5c4e594e2d --- /dev/null +++ b/collect_app/src/test/java/org/odk/collect/geo/javarosa/IntersectsFunctionHandlerTest.kt @@ -0,0 +1,175 @@ +package org.odk.collect.geo.javarosa + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.instanceOf +import org.javarosa.core.model.data.BooleanData +import org.javarosa.form.api.FormEntryController +import org.javarosa.form.api.FormEntryModel +import org.javarosa.test.BindBuilderXFormsElement.bind +import org.javarosa.test.Scenario +import org.javarosa.test.XFormsElement.body +import org.javarosa.test.XFormsElement.head +import org.javarosa.test.XFormsElement.html +import org.javarosa.test.XFormsElement.input +import org.javarosa.test.XFormsElement.mainInstance +import org.javarosa.test.XFormsElement.model +import org.javarosa.test.XFormsElement.t +import org.javarosa.test.XFormsElement.title +import org.javarosa.xpath.XPathTypeMismatchException +import org.junit.Assert.fail +import org.junit.Test + +class IntersectsFunctionHandlerTest { + + @Test + fun `returns false when input is empty`() { + val scenario = Scenario.init( + "Intersects form", + html( + head( + title("Intersects form"), + model( + mainInstance( + t( + "data id=\"intersects-form\"", + t("question"), + t("calculate") + ) + ), + bind("/data/question").type("geotrace"), + bind("/data/calculate").type("boolean") + .calculate("intersects(/data/question)") + ) + ), + body( + input("/data/question"), + input("/data/calculate") + ) + ) + ) { formDef -> + FormEntryController(FormEntryModel(formDef)).also { + it.addFunctionHandler(IntersectsFunctionHandler()) + } + } + + assertThat(scenario.answerOf("/data/calculate").value, equalTo(false)) + } + + @Test + fun `returns true when input is intersecting trace`() { + val scenario = Scenario.init( + "Intersects form", + html( + head( + title("Intersects form"), + model( + mainInstance( + t( + "data id=\"intersects-form\"", + t("question"), + t("calculate") + ) + ), + bind("/data/question").type("geotrace"), + bind("/data/calculate").type("boolean") + .calculate("intersects(/data/question)") + ) + ), + body( + input("/data/question"), + input("/data/calculate") + ) + ) + ) { formDef -> + FormEntryController(FormEntryModel(formDef)).also { + it.addFunctionHandler(IntersectsFunctionHandler()) + } + } + + scenario.answer( + "/data/question", + "1.0 1.0 0.0 0.0; 1.0 3.0 0.0 0.0; 2.0 3.0 0.0 0.0; 2.0 2.0 0.0 0.0; 0.0 2.0 0.0 0.0" + ) + assertThat(scenario.answerOf("/data/calculate").value, equalTo(true)) + } + + @Test + fun `throws exception when input is a non-geo string`() { + val scenario = Scenario.init( + "Intersects form", + html( + head( + title("Intersects form"), + model( + mainInstance( + t( + "data id=\"intersects-form\"", + t("question"), + t("calculate") + ) + ), + bind("/data/question").type("string"), + bind("/data/calculate").type("boolean") + .calculate("intersects(/data/question)") + ) + ), + body( + input("/data/question"), + input("/data/calculate") + ) + ) + ) { formDef -> + FormEntryController(FormEntryModel(formDef)).also { + it.addFunctionHandler(IntersectsFunctionHandler()) + } + } + + val expected = XPathTypeMismatchException::class.java + try { + scenario.answer("/data/question", "blah") + fail("Expected exception: $expected") + } catch (e: Exception) { + assertThat(e.cause, instanceOf(expected)) + } + } + + @Test + fun `throws exception when passed too many args`() { + val expected = XPathTypeMismatchException::class.java + try { + Scenario.init( + "Intersects form", + html( + head( + title("Intersects form"), + model( + mainInstance( + t( + "data id=\"intersects-form\"", + t("question"), + t("calculate") + ) + ), + bind("/data/question").type("geotrace"), + bind("/data/calculate").type("boolean") + .calculate("intersects(/data/question,/data/question)") + ) + ), + body( + input("/data/question"), + input("/data/calculate") + ) + ) + ) { formDef -> + FormEntryController(FormEntryModel(formDef)).also { + it.addFunctionHandler(IntersectsFunctionHandler()) + } + } + + fail("Expected exception: $expected") + } catch (e: Exception) { + assertThat(e.cause, instanceOf(expected)) + } + } +} diff --git a/geo/build.gradle.kts b/geo/build.gradle.kts index c04cbf797e5..b75c45dadba 100644 --- a/geo/build.gradle.kts +++ b/geo/build.gradle.kts @@ -64,6 +64,10 @@ dependencies { implementation(libs.androidxFragmentKtx) implementation(libs.dagger) kapt(libs.daggerCompiler) + implementation(libs.javarosa) { + exclude(group = "joda-time") + exclude(group = "org.hamcrest", module = "hamcrest-all") + } debugImplementation(project(":fragments-test")) diff --git a/geo/src/main/java/org/odk/collect/geo/GeoUtils.java b/geo/src/main/java/org/odk/collect/geo/GeoUtils.java deleted file mode 100644 index 2390c02d618..00000000000 --- a/geo/src/main/java/org/odk/collect/geo/GeoUtils.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.odk.collect.geo; - -import android.content.Context; -import android.location.Location; - -import org.odk.collect.maps.MapPoint; -import org.odk.collect.shared.strings.StringUtils; - -import java.text.DecimalFormat; -import java.util.List; -import java.util.Locale; - -public final class GeoUtils { - - private GeoUtils() { - - } - - /** - * Serializes a list of vertices into a string, in the format - * appropriate for storing as the result of the form question. - */ - public static String formatPointsResultString(List points, boolean isShape) { - if (isShape) { - // Polygons are stored with a last point that duplicates the - // first point. Add this extra point if it's not already present. - int count = points.size(); - if (count > 1 && !points.get(0).equals(points.get(count - 1))) { - points.add(points.get(0)); - } - } - StringBuilder result = new StringBuilder(); - for (MapPoint point : points) { - // TODO(ping): Remove excess precision when we're ready for the output to change. - result.append(String.format(Locale.US, "%s %s %s %s;", - Double.toString(point.latitude), Double.toString(point.longitude), - Double.toString(point.altitude), Float.toString((float) point.accuracy))); - } - - return StringUtils.removeEnd(result.toString().trim(), ";"); - } - - public static String formatLocationResultString(Location location) { - return formatLocationResultString(new org.odk.collect.location.Location( - location.getLatitude(), - location.getLongitude(), - location.getAltitude(), - location.getAccuracy() - )); - } - - public static String formatLocationResultString(org.odk.collect.location.Location location) { - return String.format("%s %s %s %s", location.getLatitude(), location.getLongitude(), - location.getAltitude(), location.getAccuracy()); - } - - public static String formatAccuracy(Context context, float accuracy) { - String formattedValue = new DecimalFormat("#.##").format(accuracy); - return context.getString(org.odk.collect.strings.R.string.accuracy_m, formattedValue); - } -} diff --git a/geo/src/main/java/org/odk/collect/geo/GeoUtils.kt b/geo/src/main/java/org/odk/collect/geo/GeoUtils.kt new file mode 100644 index 00000000000..dd05fc14dcb --- /dev/null +++ b/geo/src/main/java/org/odk/collect/geo/GeoUtils.kt @@ -0,0 +1,89 @@ +package org.odk.collect.geo + +import android.content.Context +import android.location.Location +import org.odk.collect.maps.MapPoint +import org.odk.collect.shared.strings.StringUtils.removeEnd +import org.odk.collect.strings.R +import java.text.DecimalFormat +import java.util.Locale + +object GeoUtils { + + /** + * Serializes a list of vertices into a string, in the format + * appropriate for storing as the result of the form question. + */ + @JvmStatic + fun formatPointsResultString(points: MutableList, isShape: Boolean): String? { + if (isShape) { + // Polygons are stored with a last point that duplicates the + // first point. Add this extra point if it's not already present. + val count = points.size + if (count > 1 && points[0] != points[count - 1]) { + points.add(points[0]) + } + } + val result = StringBuilder() + for (point in points) { + // TODO(ping): Remove excess precision when we're ready for the output to change. + result.append( + String.format( + Locale.US, "%s %s %s %s;", + point.latitude.toString(), point.longitude.toString(), + point.altitude.toString(), point.accuracy.toFloat().toString() + ) + ) + } + + return removeEnd(result.toString().trim(), ";") + } + + @JvmStatic + fun formatLocationResultString(location: Location): String { + return formatLocationResultString( + org.odk.collect.location.Location( + location.latitude, + location.longitude, + location.altitude, + location.accuracy + ) + ) + } + + fun formatLocationResultString(location: org.odk.collect.location.Location): String { + return String.format( + "%s %s %s %s", location.latitude, location.longitude, + location.altitude, location.accuracy + ) + } + + fun formatAccuracy(context: Context, accuracy: Float): String { + val formattedValue = DecimalFormat("#.##").format(accuracy.toDouble()) + return context.getString(R.string.accuracy_m, formattedValue) + } + + @JvmStatic + @JvmOverloads + fun parseGeometryPoint(answer: String?, strict: Boolean = false): DoubleArray? { + if (!answer.isNullOrEmpty()) { + val sa = answer.trim().split(" ") + return try { + doubleArrayOf( + sa[0].toDouble(), + if (sa.size > 1) sa[1].toDouble() else 0.0, + if (sa.size > 2) sa[2].toDouble() else 0.0, + if (sa.size > 3) sa[3].toDouble() else 0.0 + ) + } catch (_: Throwable) { + if (strict) { + throw IllegalArgumentException() + } else { + null + } + } + } else { + return null + } + } +} diff --git a/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointDialogFragment.kt b/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointDialogFragment.kt index 4fda6c9d507..4bb05cb85f8 100644 --- a/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointDialogFragment.kt +++ b/geo/src/main/java/org/odk/collect/geo/geopoint/GeoPointDialogFragment.kt @@ -58,7 +58,7 @@ class GeoPointDialogFragment : DialogFragment() { } binding.threshold.text = - getString(org.odk.collect.strings.R.string.point_will_be_saved, formatAccuracy(context, accuracyThreshold)) + getString(org.odk.collect.strings.R.string.point_will_be_saved, formatAccuracy(requireContext(), accuracyThreshold)) viewModel.timeElapsed.observe(this) { binding.time.text = diff --git a/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyUtils.kt b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyUtils.kt new file mode 100644 index 00000000000..ed2cc3953df --- /dev/null +++ b/geo/src/main/java/org/odk/collect/geo/geopoly/GeoPolyUtils.kt @@ -0,0 +1,22 @@ +package org.odk.collect.geo.geopoly + +import org.odk.collect.geo.GeoUtils.parseGeometryPoint +import org.odk.collect.maps.MapPoint + +object GeoPolyUtils { + + fun parseGeometry(geometry: String?, strict: Boolean = false): List { + val points = ArrayList() + + for (vertex in (geometry ?: "").split(";")) { + val point = parseGeometryPoint(vertex, strict) + if (point != null) { + points.add(MapPoint(point[0], point[1], point[2], point[3])) + } else { + return ArrayList() + } + } + + return points + } +} diff --git a/geo/src/main/java/org/odk/collect/geo/javarosa/IntersectsFunctionHandler.kt b/geo/src/main/java/org/odk/collect/geo/javarosa/IntersectsFunctionHandler.kt new file mode 100644 index 00000000000..86ca3b12b00 --- /dev/null +++ b/geo/src/main/java/org/odk/collect/geo/javarosa/IntersectsFunctionHandler.kt @@ -0,0 +1,40 @@ +package org.odk.collect.geo.javarosa + +import org.javarosa.core.model.condition.EvaluationContext +import org.javarosa.core.model.condition.IFunctionHandler +import org.javarosa.xpath.XPathTypeMismatchException +import org.odk.collect.geo.geopoly.GeoPolyUtils.parseGeometry +import org.odk.collect.maps.toPoint +import org.odk.collect.shared.geometry.Trace +import org.odk.collect.shared.geometry.intersects + +class IntersectsFunctionHandler : IFunctionHandler { + override fun getName(): String { + return "intersects" + } + + override fun getPrototypes(): List>> { + return listOf(arrayOf(String::class.java)) + } + + override fun rawArgs(): Boolean { + return false + } + + override fun realTime(): Boolean { + TODO("Not yet implemented") + } + + override fun eval( + args: Array, + ec: EvaluationContext + ): Any { + try { + val mapPoints = parseGeometry(args[0] as String, strict = true) + val trace = Trace(mapPoints.map { it.toPoint() }) + return trace.intersects() + } catch (_: IllegalArgumentException) { + throw XPathTypeMismatchException() + } + } +} diff --git a/geo/src/test/java/org/odk/collect/geo/GeoUtilsTest.kt b/geo/src/test/java/org/odk/collect/geo/GeoUtilsTest.kt index 72f5593ea5b..7d9e5bcc1bc 100644 --- a/geo/src/test/java/org/odk/collect/geo/GeoUtilsTest.kt +++ b/geo/src/test/java/org/odk/collect/geo/GeoUtilsTest.kt @@ -6,10 +6,10 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo -import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith import org.odk.collect.geo.GeoUtils.formatPointsResultString +import org.odk.collect.geo.GeoUtils.parseGeometryPoint import org.odk.collect.maps.MapPoint import org.odk.collect.testshared.LocationTestUtils.createLocation @@ -25,30 +25,30 @@ class GeoUtilsTest { @Test fun whenPointsAreNull_formatPoints_returnsEmptyString() { - assertEquals(formatPointsResultString(emptyList(), true), "") - assertEquals(formatPointsResultString(emptyList(), false), "") + assertThat(formatPointsResultString(mutableListOf(), true), equalTo("")) + assertThat(formatPointsResultString(mutableListOf(), false), equalTo("")) } @Test fun geotraces_areSeparatedBySemicolon_withoutTrialingSemicolon() { - assertEquals( + assertThat( formatPointsResultString(points, false), - "11.0 12.0 13.0 14.0;21.0 22.0 23.0 24.0;31.0 32.0 33.0 34.0" + equalTo("11.0 12.0 13.0 14.0;21.0 22.0 23.0 24.0;31.0 32.0 33.0 34.0") ) } @Test fun geoshapes_areSeparatedBySemicolon_withoutTrialingSemicolon_andHaveMatchingFirstAndLastPoints() { - assertEquals( + assertThat( formatPointsResultString(points, true), - "11.0 12.0 13.0 14.0;21.0 22.0 23.0 24.0;31.0 32.0 33.0 34.0;11.0 12.0 13.0 14.0" + equalTo("11.0 12.0 13.0 14.0;21.0 22.0 23.0 24.0;31.0 32.0 33.0 34.0;11.0 12.0 13.0 14.0") ) } @Test fun test_formatLocationResultString() { val location: Location = createLocation("GPS", 1.0, 2.0, 3.0, 4f) - assertEquals(GeoUtils.formatLocationResultString(location), "1.0 2.0 3.0 4.0") + assertThat(GeoUtils.formatLocationResultString(location), equalTo("1.0 2.0 3.0 4.0")) } @Test @@ -59,4 +59,33 @@ class GeoUtilsTest { assertThat(GeoUtils.formatAccuracy(context, 0.10f), equalTo("0.1 m")) assertThat(GeoUtils.formatAccuracy(context, 1.1f), equalTo("1.1 m")) } + + @Test + fun parseGeometryPointTest() { + var gp = + parseGeometryPoint("37.45153333333334 -122.15539166666667 0.0 20.0")!! + assertThat(37.45153333333334, equalTo(gp[0])) + assertThat(-122.15539166666667, equalTo(gp[1])) + assertThat(0.0, equalTo(gp[2])) + assertThat(20.0, equalTo(gp[3])) + + gp = parseGeometryPoint("37.45153333333334")!! + assertThat(37.45153333333334, equalTo(gp[0])) + assertThat(0.0, equalTo(gp[1])) + assertThat(0.0, equalTo(gp[2])) + assertThat(0.0, equalTo(gp[3])) + + gp = parseGeometryPoint(" 37.45153333333334 -122.15539166666667 0.0 ")!! + assertThat(37.45153333333334, equalTo(gp[0])) + assertThat(-122.15539166666667, equalTo(gp[1])) + assertThat(0.0, equalTo(gp[2])) + assertThat(0.0, equalTo(gp[3])) + + assertThat( + null, + equalTo(parseGeometryPoint("37.45153333333334 -122.15539166666667 0.0 qwerty")) + ) + assertThat(null, equalTo(parseGeometryPoint(""))) + assertThat(null, equalTo(parseGeometryPoint(null))) + } } diff --git a/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyUtilsTest.kt b/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyUtilsTest.kt new file mode 100644 index 00000000000..cb36c610308 --- /dev/null +++ b/geo/src/test/java/org/odk/collect/geo/geopoly/GeoPolyUtilsTest.kt @@ -0,0 +1,26 @@ +package org.odk.collect.geo.geopoly + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.odk.collect.geo.geopoly.GeoPolyUtils.parseGeometry +import org.odk.collect.maps.MapPoint + +class GeoPolyUtilsTest { + + @Test + fun parseGeometryTest() { + assertThat(parseGeometry("1.0 2.0 3 4"), equalTo(listOf(MapPoint(1.0, 2.0, 3.0, 4.0)))) + assertThat( + parseGeometry("1.0 2.0 3 4; 5.0 6.0 7 8"), + equalTo(listOf(MapPoint(1.0, 2.0, 3.0, 4.0), MapPoint(5.0, 6.0, 7.0, 8.0))) + ) + + assertThat(parseGeometry("blah"), equalTo(emptyList())) + assertThat(parseGeometry("1.0 2.0 3 4; blah"), equalTo(emptyList())) + assertThat( + parseGeometry("37.45153333333334 -122.15539166666667 0.0 qwerty"), + equalTo(emptyList()) + ) + } +} diff --git a/maps/src/main/java/org/odk/collect/maps/MapPoint.kt b/maps/src/main/java/org/odk/collect/maps/MapPoint.kt index 9260d8ccf9d..844c231ad60 100644 --- a/maps/src/main/java/org/odk/collect/maps/MapPoint.kt +++ b/maps/src/main/java/org/odk/collect/maps/MapPoint.kt @@ -15,6 +15,7 @@ package org.odk.collect.maps import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.odk.collect.shared.geometry.Point @Parcelize data class MapPoint @JvmOverloads constructor( @@ -23,3 +24,7 @@ data class MapPoint @JvmOverloads constructor( @JvmField val altitude: Double = 0.0, @JvmField val accuracy: Double = 0.0 ) : Parcelable + +fun MapPoint.toPoint(): Point { + return Point(latitude, longitude) +} diff --git a/shared/src/main/java/org/odk/collect/shared/geometry/Geometry.kt b/shared/src/main/java/org/odk/collect/shared/geometry/Geometry.kt new file mode 100644 index 00000000000..f9732aca023 --- /dev/null +++ b/shared/src/main/java/org/odk/collect/shared/geometry/Geometry.kt @@ -0,0 +1,159 @@ +package org.odk.collect.shared.geometry + +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +data class Point(val x: Double, val y: Double) +data class LineSegment(val start: Point, val end: Point) +data class Trace(val points: List) { + fun isClosed(): Boolean { + return points.first() == points.last() + } +} + +fun Trace.segments(): List { + return points.zipWithNext().flatMap { (start, end) -> + if (start != end) { + listOf(LineSegment(start, end)) + } else { + emptyList() + } + } +} + +/** + * Returns `true` if any segment of the trace intersects with any other and `false` otherwise. + */ +fun Trace.intersects(): Boolean { + val points = this.points + return if (points.size >= 3) { + val segments = segments() + if (segments.size == 2) { + segments[0].intersects(segments[1], allowConnection = true) + } else { + segments.filterIndexed { line1Index, line1 -> + segments.filterIndexed { line2Index, line2 -> + if (isClosed() && line1Index == 0 && line2Index == segments.size - 1) { + false + } else if (line2Index == line1Index + 1) { + line1.intersects(line2, allowConnection = true) + } else if (line2Index >= line1Index + 2) { + line1.intersects(line2) + } else { + false + } + }.isNotEmpty() + }.isNotEmpty() + } + } else { + false + } +} + +/** + * Check if a point is within the bounding box defined by a line between two non-consecutive corners + */ +fun Point.within(segment: LineSegment): Boolean { + val lineXMin = min(segment.start.x, segment.end.x) + val lineXMax = max(segment.start.x, segment.end.x) + val lineYMin = min(segment.start.y, segment.end.y) + val lineYMax = max(segment.start.y, segment.end.y) + val xRange = lineXMin..lineXMax + val yRange = lineYMin..lineYMax + + return x in xRange && y in yRange +} + +/** + * Work out whether two line segments intersect by calculating if the endpoints of one segment + * are on opposite sides (or touching of the other segment **and** vice versa. This is + * determined by finding the orientation of endpoints relative to the other line. + * + * @param allowConnection will allow the end of `this` and the start of `other` to intersect + * provided they are equivalent (the two segments are "connected") + */ +fun LineSegment.intersects(other: LineSegment, allowConnection: Boolean = false): Boolean { + val (a, b) = this + val (c, d) = other + + val orientationA = orientation(a, c, d) + val orientationD = orientation(a, b, d) + + return if (orientationA == Orientation.Collinear && a.within(other)) { + true + } else if (orientationD == Orientation.Collinear && d.within(this)) { + true + } else if (b == c && allowConnection) { + false + } else { + val orientationB = orientation(b, c, d) + val orientationC = orientation(a, b, c) + + if (orientationA.isOpposing(orientationB) && orientationC.isOpposing(orientationD)) { + true + } else if (orientationB == Orientation.Collinear && b.within(other)) { + true + } else if (orientationC == Orientation.Collinear && c.within(this)) { + true + } else { + false + } + } +} + +/** + * Calculate a [Point] on this [LineSegment] based on the `position` using + * [Linear interpolation](https://en.wikipedia.org/wiki/Linear_interpolation). `0` will return + * [LineSegment.start] and `1` will return [LineSegment.end]. + */ +fun LineSegment.interpolate(position: Double): Point { + val x = start.x + position * (end.x - start.x) + val y = start.y + position * (end.y - start.y) + return Point(x, y) +} + +/** + * Calculate the "orientation" (or "direction") of three points using the cross product of the + * vectors of the pairs of points (see + * [here](https://en.wikipedia.org/wiki/Cross_product#Computational_geometry)). This can + * either be clockwise, anticlockwise or collinear (the three points form a straight line). + * + * @param epsilon the epsilon used to check for collinearity + * + */ +fun orientation(a: Point, b: Point, c: Point, epsilon: Double = 0.00000000001): Orientation { + val crossProduct = crossProduct(Pair(b.x - a.x, b.y - a.y), Pair(c.x - a.x, c.y - a.y)) + return if (abs(crossProduct) < epsilon) { + Orientation.Collinear + } else if (crossProduct > 0) { + Orientation.AntiClockwise + } else { + Orientation.Clockwise + } +} + +/** + * [https://en.wikipedia.org/wiki/Cross_product](https://en.wikipedia.org/wiki/Cross_product) + */ +private fun crossProduct(x: Pair, y: Pair): Double { + return (x.first * y.second) - (y.first * x.second) +} + +enum class Orientation { + Collinear, + Clockwise, + AntiClockwise; + + fun isOpposing(other: Orientation): Boolean { + return if (this == Collinear) { + false + } else if (this == Clockwise && other == AntiClockwise) { + true + } else if (this == AntiClockwise && other == Clockwise) { + true + } else { + false + } + } +} diff --git a/shared/src/test/java/org/odk/collect/shared/QuickCheck.kt b/shared/src/test/java/org/odk/collect/shared/QuickCheck.kt new file mode 100644 index 00000000000..c6d9b6615c6 --- /dev/null +++ b/shared/src/test/java/org/odk/collect/shared/QuickCheck.kt @@ -0,0 +1,16 @@ +package org.odk.collect.shared + +/** + * Simple helper to allow writing neater [QuickCheck](https://en.wikipedia.org/wiki/QuickCheck) + * style tests. + */ +fun ((Input) -> Output).quickCheck( + iterations: Int, + generator: Sequence, + checks: (Input, Output) -> Unit +) { + generator.take(iterations).forEach { input -> + val output = this(input) + checks(input, output) + } +} diff --git a/shared/src/test/java/org/odk/collect/shared/geometry/GeometryTest.kt b/shared/src/test/java/org/odk/collect/shared/geometry/GeometryTest.kt new file mode 100644 index 00000000000..1e7bd5b7f76 --- /dev/null +++ b/shared/src/test/java/org/odk/collect/shared/geometry/GeometryTest.kt @@ -0,0 +1,325 @@ +package org.odk.collect.shared.geometry + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.odk.collect.shared.quickCheck +import kotlin.random.Random + +class GeometryTest { + + @Test + fun `Trace#intersects returns false for an empty list`() { + assertThat(Trace(emptyList()).intersects(), equalTo(false)) + } + + @Test + fun `Trace#intersects returns false when there is only one point`() { + val trace = Trace(listOf(Point(0.0, 0.0))) + assertThat(trace.intersects(), equalTo(false)) + } + + @Test + fun `Trace#intersects returns false when there is only one segment`() { + val trace = Trace(listOf(Point(0.0, 0.0), Point(1.0, 0.0))) + assertThat(trace.intersects(), equalTo(false)) + } + + @Test + fun `Trace#intersects returns false when no segment intersects with another`() { + val trace = Trace( + listOf( + Point(0.0, 0.0), + Point(1.0, 1.0), + Point(2.0, 0.0) + ) + ) + + assertThat(trace.intersects(), equalTo(false)) + } + + @Test + fun `Trace#intersects returns false when no segment intersects with another in a closed trace`() { + val trace = Trace( + listOf( + Point(0.0, 0.0), + Point(1.0, 1.0), + Point(2.0, 0.0), + Point(0.0, 0.0) + ) + ) + + assertThat(trace.intersects(), equalTo(false)) + } + + @Test + fun `Trace#intersects returns true when a segment intersects with another`() { + val trace = Trace( + listOf( + Point(1.0, 1.0), + Point(1.0, 3.0), + Point(2.0, 3.0), + Point(2.0, 2.0), + Point(0.0, 2.0) + ) + ) + + assertThat(trace.intersects(), equalTo(true)) + } + + @Test + fun `Trace#intersects returns true when a segment intersects with another in a closed trace`() { + val trace = Trace( + listOf( + Point(1.0, 1.0), + Point(1.0, 3.0), + Point(2.0, 3.0), + Point(2.0, 2.0), + Point(0.0, 2.0), + Point(1.0, 1.0) + ) + ) + + assertThat(trace.intersects(), equalTo(true)) + } + + @Test + fun `Trace#intersects returns false when a segment's end points are both on different sides of another, but the segments do not intersect`() { + val trace = Trace( + listOf( + Point(1.0, 1.0), + Point(1.0, 2.0), + Point(3.0, 3.0), + Point(0.0, 3.0) + ) + ) + + assertThat(trace.intersects(), equalTo(false)) + } + + @Test + fun `Trace#intersects returns true when just an endpoint touches another segment`() { + val trace = Trace( + listOf( + Point(0.0, 0.0), + Point(1.0, 1.0), + Point(2.0, 0.0), + Point(-1.0, 0.0) + ) + ) + + assertThat(trace.intersects(), equalTo(true)) + } + + @Test + fun `Trace#intersects returns true when two segments and they intersect`() { + val endpointWithin = Trace( + listOf( + Point(0.0, 0.0), + Point(0.0, 1.0), + Point(0.0, 0.5) + ) + ) + assertThat(endpointWithin.intersects(), equalTo(true)) + + val endpointBeyond = Trace(listOf( + Point(0.0, 0.0), + Point(0.0, 1.0), + Point(0.0, -1.0), + )) + assertThat(endpointBeyond.intersects(), equalTo(true)) + + val endpointMatching = Trace(listOf( + Point(0.0, 0.0), + Point(0.0, 1.0), + Point(0.0, 0.0), + )) + assertThat(endpointMatching.intersects(), equalTo(true)) + } + + @Test + fun `Trace#intersects returns true when the trace closes on a non-origin vertex`() { + val trace = Trace( + listOf( + Point(0.0, 0.0), + Point(0.0, 1.0), // Close back on this point + Point(0.0, 2.0), + Point(1.0, 2.0), + Point(0.0, 1.0) + ) + ) + + assertThat(trace.intersects(), equalTo(true)) + } + + @Test + fun `Trace#intersects returns false for right angled triangle`() { + val trace = Trace( + listOf( + Point(0.0, 0.0), + Point(10.0, 10.0), + Point(0.0, 10.0) + ) + ) + + assertThat(trace.intersects(), equalTo(false)) + } + + @Test + fun `Trace#segments returns false for trace with duplicate points`() { + val trace = Trace( + listOf( + Point(0.0, 0.0), + Point(1.0, 0.0), + Point(1.0, 0.0), + Point(2.0, 0.0) + ) + ) + + assertThat(trace.intersects(), equalTo(false)) + } + + @Test + fun `Trace#segments does not include zero-length segments`() { + val trace = Trace( + listOf( + Point(0.0, 0.0), + Point(1.0, 0.0), + Point(1.0, 0.0), + Point(2.0, 0.0) + ) + ) + + assertThat(trace.segments(), equalTo(listOf( + LineSegment(Point(0.0, 0.0), Point(1.0, 0.0)), + LineSegment(Point(1.0, 0.0), Point(2.0, 0.0)) + ))) + } + + @Test + fun `Trace#intersects returns true for 3 segment closed trace reversing on itself`() { + val trace = Trace(listOf( + Point(0.0, 0.0), + Point(2.0, 0.0), + Point(1.0, 0.0), + Point(0.0, 0.0) + )) + assertThat(trace.intersects(), equalTo(true)) + } + + @Test + fun `Trace#intersects satisfies metamorphic relationships`() { + { trace: Trace -> trace.intersects() }.quickCheck( + iterations = 1000, + generator = getTraceGenerator() + ) { trace, intersects -> + // Check intersects is consistent when trace is reversed + val reversedTrace = Trace(trace.points.reversed()) + assertThat( + "Expected intersects=$intersects:\n$reversedTrace", + reversedTrace.intersects(), + equalTo(intersects) + ) + + // Check intersects is consistent when trace is scaled + val scaleFactor = Random.nextDouble(0.1, 10.0) + val scaledTrace = Trace(trace.points.map { + Point(it.x * scaleFactor, it.y * scaleFactor) + }) + assertThat( + "Expected intersects=$intersects:\n$scaledTrace", + scaledTrace.intersects(), + equalTo(intersects) + ) + + // Check adding an intersection makes intersects true + if (!intersects) { + val intersectionSegment = trace.segments().random() + val intersectPosition = Random.nextDouble(0.1, 1.0) + val intersectionPoint = intersectionSegment.interpolate(intersectPosition) + val intersectingTrace = + Trace(trace.points + listOf(trace.points.last(), intersectionPoint)) + assertThat( + "Expected intersects=true:\n$intersectingTrace", + intersectingTrace.intersects(), + equalTo(true) + ) + } + } + } + + @Test + fun `LineSegment#intersects detects any endpoint touching the other line`() { + val line = LineSegment(Point(0.0, 0.0), Point(0.0, 2.0)) + + val aTouching = LineSegment(Point(-1.0, 0.0), Point(1.0, 0.0)) + assertThat(line.intersects(aTouching), equalTo(true)) + + val bTouching = LineSegment(Point(-1.0, 2.0), Point(1.0, 2.0)) + assertThat(line.intersects(bTouching), equalTo(true)) + + val cTouching = LineSegment(Point(0.0, 1.0), Point(1.0, 1.0)) + assertThat(line.intersects(cTouching), equalTo(true)) + + val dTouching = LineSegment(Point(-1.0, 1.0), Point(0.0, 1.0)) + assertThat(line.intersects(dTouching), equalTo(true)) + } + + @Test + fun `LineSegment#intersects does not detect intersections for collinear endpoints`() { + val segment1 = LineSegment(Point(0.0, 0.0), Point(4.0, 0.0)) + val segment2 = LineSegment(Point(4.0, 4.0), Point(8.0, 0.0)) + + assertThat(segment1.intersects(segment2), equalTo(false)) + } + + @Test + fun `LineSegment#intersects with allowConnection true still finds intersections in a cross`() { + val segment1 = LineSegment(Point(-1.0, 0.0), Point(1.0, 0.0)) + val segment2 = LineSegment(Point(0.0, -1.0), Point(0.0, 1.0)) + + assertThat(segment1.intersects(segment2, allowConnection = true), equalTo(true)) + } + + @Test + fun `LineSegment#interpolate returns a point on the segment at a proportional distance`() { + val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 0.0)) + + assertThat(segment.interpolate(0.0), equalTo(Point(0.0, 0.0))) + assertThat(segment.interpolate(0.5), equalTo(Point(0.5, 0.0))) + assertThat(segment.interpolate(1.0), equalTo(Point(1.0, 0.0))) + } + + @Test + fun `LineSegment#interpolate returns a collinear point within the line's bounding box`() { + val segment = LineSegment(Point(0.0, 0.0), Point(1.0, 1.0)) + val interpolatedPoint = segment.interpolate(0.5) + + val orientation = orientation(interpolatedPoint, segment.start, segment.end) + assertThat(orientation, equalTo(Orientation.Collinear)) + assertThat(interpolatedPoint.within(segment), equalTo(true)) + } + + private fun getTraceGenerator(maxLength: Int = 10, maxCoordinate: Double = 100.0): Sequence { + return generateSequence { + val length = Random.nextInt(2, maxLength) + val trace = Trace(0.until(length).map { + Point( + Random.nextDouble(maxCoordinate * -1, maxCoordinate), + Random.nextDouble(maxCoordinate * -1, maxCoordinate) + ) + }) + + if (trace.isClosed()) { + trace + } else { + val shouldClose = Random.nextBoolean() + if (shouldClose) { + trace.copy(points = trace.points + trace.points.first()) + } else { + trace + } + } + } + } +}