Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Paths, Polygons, and Donuts - Building a Freeha...

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

Paths, Polygons, and Donuts - Building a Freehand Drawing Tool for Maps (Droidcon Lisbon 2026 edition)

At Spoke, we build last-mile delivery tools for drivers, dispatchers, and retailers. One of our features lets drivers guide our route optimizer by grouping and ordering their stops with freehand lassoing. The first drawing tool only supported creating and undoing groups. Our users needed more control, so we built them a proper polygon editor.

In this talk, I'll share how finger-drags can become polygons on a map, and we'll dive into the tricky bits of editing them: self-intersecting loops, polygons with holes, and the machinery that decides which stops belong to a group. I'll cover finicky edge cases we caught, fixed, and screenshot-tested before release. There will be donuts!

By the end of the talk, you'll have a better grasp on:

- Turning freehand drawings into map polygons with Path and Path.Op
- How fill rules make or break boolean path operations
- Verifying drawing and grouping logic with screenshot tests

Avatar for István Juhos

István Juhos

October 20, 2026

More Decks by István Juhos

Other Decks in Programming

Transcript

  1. Paths, Polygons, and 🍩🍩 Building a Freehand Drawing Tool for

    Maps István Juhos Staff Android Engineer
  2. Composing the overlay . ? / / OrderStopGroupDrawerOverlay( … onDrawingUpdate

    = onOrderStopGroupDrawingUpdate, onDrawingEnd = onOrderStopGroupDrawn, enabled = state.value.orderStopGroupsState.isDrawingEnabled, mapProjectionProvider = { googleMap projection }, )
  3. Composing the overlay OrderStopGroupDrawerOverlay( … onDrawingUpdate = onOrderStopGroupDrawingUpdate, onDrawingEnd =

    onOrderStopGroupDrawn, enabled = state.value.orderStopGroupsState.isDrawingEnabled, mapProjectionProvider = { googleMap projection }, ) . ? / / developers.google.com/maps/documentation/android-sdk/reference/com/google/android/libraries/maps/Projection
  4. Composing the overlay . ? / / OrderStopGroupDrawerOverlay( … onDrawingUpdate

    = onOrderStopGroupDrawingUpdate, onDrawingEnd = onOrderStopGroupDrawn, enabled = state.value.orderStopGroupsState.isDrawingEnabled, mapProjectionProvider = { googleMap projection }, )
  5. Inside the drawing overlay / * / * / *

    * / * / * / / / / / @Composable fun OrderStopGroupDrawerOverlay( … ) { If not enabled we don't add anything if (!enabled) return … Canvas( modifier = modifier .sharePointerInputWithSiblings() .pointerInput(Unit) { record the drag onDraw = { draw the stroke }, ) } },
  6. Inside the drawing overlay / * / * / *

    * / * / * / / / / / @Composable fun OrderStopGroupDrawerOverlay( … ) { If not enabled we don't add anything if (!enabled) return … Canvas( modifier = modifier .sharePointerInputWithSiblings() .pointerInput(Unit) { record the drag onDraw = { draw the stroke }, ) } },
  7. Inside the drawing overlay / * / * / *

    * / * / * / / / / / @Composable fun OrderStopGroupDrawerOverlay( … ) { If not enabled we don't add anything if (!enabled) return … Canvas( modifier = modifier .sharePointerInputWithSiblings() .pointerInput(Unit) { record the drag onDraw = { draw the stroke }, ) } },
  8. Inside the drawing overlay / * / * / *

    * / * / * / / / / / @Composable fun OrderStopGroupDrawerOverlay( … ) { If not enabled we don't add anything if (!enabled) return … Canvas( modifier = modifier .sharePointerInputWithSiblings() .pointerInput(Unit) { record the drag onDraw = { draw the stroke }, ) } },
  9. Recording strokes val screenPoints = remember { mutableListOf<ScreenPoint>() } val

    offsets: MutableList<Offset> = remember { mutableStateListOf() } > - . ? > - Modifier.pointerInput(Unit) { eagerDetectDragGestures( onDrag = { change screenPoints.add(change.position.toPoint()) offsets.add(change.position) }, onDragEnd = { mapProjectionProvider() let { projection val latLngPoints = screenPoints.map { val latLng = projection.fromScreenLocation(it) Point(latLng.latitude, latLng.longitude) } onDrawingEnd(latLngPoints) } }, ) }
  10. Recording strokes val screenPoints = remember { mutableListOf<ScreenPoint>() } val

    offsets: MutableList<Offset> = remember { mutableStateListOf() } > - . ? > - Modifier.pointerInput(Unit) { eagerDetectDragGestures( onDrag = { change screenPoints.add(change.position.toPoint()) offsets.add(change.position) }, onDragEnd = { mapProjectionProvider() let { projection val latLngPoints = screenPoints.map { val latLng = projection.fromScreenLocation(it) Point(latLng.latitude, latLng.longitude) } onDrawingEnd(latLngPoints) } }, ) developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures#event-propagation }
  11. Recording strokes val screenPoints = remember { mutableListOf<ScreenPoint>() } val

    offsets: MutableList<Offset> = remember { mutableStateListOf() } > - . ? > - Modifier.pointerInput(Unit) { eagerDetectDragGestures( onDrag = { change screenPoints.add(change.position.toPoint()) offsets.add(change.position) }, onDragEnd = { mapProjectionProvider() let { projection val latLngPoints = screenPoints.map { val latLng = projection.fromScreenLocation(it) Point(latLng.latitude, latLng.longitude) } onDrawingEnd(latLngPoints) } }, ) }
  12. Recording strokes val screenPoints = remember { mutableListOf<ScreenPoint>() } val

    offsets: MutableList<Offset> = remember { mutableStateListOf() } > - . ? > - Modifier.pointerInput(Unit) { eagerDetectDragGestures( onDrag = { change screenPoints.add(change.position.toPoint()) offsets.add(change.position) }, onDragEnd = { mapProjectionProvider() let { projection val latLngPoints = screenPoints.map { val latLng = projection.fromScreenLocation(it) Point(latLng.latitude, latLng.longitude) } onDrawingEnd(latLngPoints) } }, ) }
  13. Drawing the stroke > - onDraw = { val strokePath

    = Path().apply { offsets.forEach { point if (isEmpty) moveTo(point.x, point.y) else lineTo(point.x, point.y) } } val fillPath = Path().apply { addPath(strokePath) close() } if (!isErasing) { drawPath(path = fillPath, brush = fillBrush, style = Fill) } drawPath(path = strokePath, brush = strokeBrush, style = strokeStyle) },
  14. Drawing the stroke > - onDraw = { val strokePath

    = Path().apply { offsets.forEach { point if (isEmpty) moveTo(point.x, point.y) else lineTo(point.x, point.y) } } val fillPath = Path().apply { addPath(strokePath) close() } if (!isErasing) { drawPath(path = fillPath, brush = fillBrush, style = Fill) } drawPath(path = strokePath, brush = strokeBrush, style = strokeStyle) },
  15. Drawing the stroke > - onDraw = { val strokePath

    = Path().apply { offsets.forEach { point if (isEmpty) moveTo(point.x, point.y) else lineTo(point.x, point.y) } } lineTo val fillPath = Path().apply { addPath(strokePath) close() } moveTo if (!isErasing) { drawPath(path = fillPath, brush = fillBrush, style = Fill) } drawPath(path = strokePath, brush = strokeBrush, style = strokeStyle) }, lineTo lineTo
  16. Drawing the stroke > - onDraw = { val strokePath

    = Path().apply { offsets.forEach { point if (isEmpty) moveTo(point.x, point.y) else lineTo(point.x, point.y) } } lineTo val fillPath = Path().apply { addPath(strokePath) close() } moveTo if (!isErasing) { drawPath(path = fillPath, brush = fillBrush, style = Fill) } drawPath(path = strokePath, brush = strokeBrush, style = strokeStyle) }, lineTo lineTo
  17. Drawing the stroke: Path basics > - onDraw = {

    val strokePath = Path().apply { offsets.forEach { point if (isEmpty) moveTo(point.x, point.y) else lineTo(point.x, point.y) } } val fillPath = Path().apply { addPath(strokePath) close() } if (!isErasing) { drawPath(path = fillPath, brush = fillBrush, style = Fill) } drawPath(path = strokePath, brush = strokeBrush, style = strokeStyle) },
  18. Rules for every tool • A tool acts only if

    it affects a stop • No empty groups, ever
  19. What each tool does Only takes ungrouped stops Can’t overlap

    an existing group Grows the rst group the stroke touches Adds every stop it covers Can steal stops from other groups fi Can reshape other groups
  20. What each tool does Only takes ungrouped stops Can’t overlap

    an existing group Grows the rst group the stroke touches Takes the stops it covers out of their groups Adds every stop it covers Can reshape the groups it touches Can steal stops from other groups fi Can reshape other groups
  21. Boolean operations on Paths path.op(other, Path.Op.[operation]) A B UNION A∪B

    developer.android.com/reference/android/graphics/Path.Op
  22. Boolean operations on Paths path.op(other, Path.Op.[operation]) A B UNION A∪B

    A B DIFFERENCE A−B developer.android.com/reference/android/graphics/Path.Op
  23. Boolean operations on Paths path.op(other, Path.Op.[operation]) A B UNION A∪B

    A B DIFFERENCE A−B A B INTERSECT A∩B developer.android.com/reference/android/graphics/Path.Op
  24. New group = DIFFERENCE • Works for ungrouped stops only

    • Can’t overlap with existing groups visually
  25. fi Expand = UNION, then DIFFERENCE • Missed a stop?

    Expand the group over it. • It grows the first group the stroke touches. • No-op if no stops are involved. nger down
  26. Erase = INTERSECT, then DIFFERENCE • Included stops by mistake?

    Erase them from groups. • It affects all groups it touches. • No-op if no stops are involved.
  27. A float keeps about seven digits 47.123456789 a coordinate, as

    a double 47.123455 fl the same coordinate, as a oat
  28. Floats broke Path.Op - normalization • Raw lat/lng as floats:

    stops landed on the wrong side • So only the polygons go to 0 – 10,000, and back Drawn shape Normalize to Path.Op Back to Check stops lat/lng 0 – 10,000 floats lat/lng lat/lng Stops lat/lng
  29. Normalizing, in code internal class CoordinateNormalizer(allPoints: List<Point>) { private val

    minLng = allPoints.minOf { it.longitude } private val rangeLng = (allPoints.maxOf { it.longitude } - minLng) .coerceAtLeast(MINIMUM_COORDINATE_VALUE) … minLat and rangeLat the same way fun normalizeX(lng: Double): Float = ((lng - minLng) / rangeLng * NORMALIZED_RANGE).toFloat() … normalizeY the same way fun denormalize(x: Double, y: Double): Point = Point( latitude = y / NORMALIZED_RANGE * rangeLat + minLat, longitude = x / NORMALIZED_RANGE * rangeLng + minLng, ) companion object { private const val NORMALIZED_RANGE = 10_000f private const val MINIMUM_COORDINATE_VALUE = 1e-9 } / / / / }
  30. Is this point inside? −1 +1 1 crossing +1 →

    inside +1 2 crossings −1 +1 = 0 → outside
  31. Fill rules Loop drawn twice, same way 1 +1 2

    +1 EVEN_ODD WINDING count crossings sum directions Arrows: the direction each outline is drawn
  32. From outlines to a Path, in code > - >

    > > - = = private fun List<List<Point .contoursToPath(normalizer: CoordinateNormalizer): Path = Path().apply { forEach { contour contour.forEachIndexed { index, point val x = normalizer.normalizeX(point.longitude) val y = normalizer.normalizeY(point.latitude) if (index 0) moveTo(x, y) else lineTo(x, y) } close() } fillType = Path.FillType.EVEN_ODD }
  33. From outlines to a Path, in code > - >

    > > - = = private fun List<List<Point .contoursToPath(normalizer: CoordinateNormalizer): Path = Path().apply { forEach { contour contour.forEachIndexed { index, point val x = normalizer.normalizeX(point.longitude) val y = normalizer.normalizeY(point.latitude) if (index 0) moveTo(x, y) else lineTo(x, y) } close() } fillType = Path.FillType.EVEN_ODD }
  34. One stroke, three answers Overlay fi Compose, NonZero · lled

    Stop containment check containsLocation · pins aren’t highlighted Google Maps polygon a hole
  35. One stroke, three answers Overlay fi Compose, NonZero · lled

    Stop containment check containsLocation · pins aren’t highlighted Google Maps polygon a hole
  36. One stroke, three answers Overlay fi Compose, NonZero · lled

    Stop containment check containsLocation · pins aren’t highlighted Google Maps polygon a hole
  37. The fix: untangle the shape UNION with itself The stroke

    as drawn it crosses itself One clean outline no crossings: every check agrees
  38. Untangle, in code private fun List<Point>.toPath(normalizer: CoordinateNormalizer): Path = Path().apply

    { forEachIndexed { index, point val x = normalizer.normalizeX(point.longitude) val y = normalizer.normalizeY(point.latitude) if (index 0) moveTo(x, y) else lineTo(x, y) } close() fillType = Path.FillType.WINDING } > > > - = = / / private fun normaliseDrawnShape(points: List<Point>): List<List<Point if (points.size < 3) return listOf(points) val normalizer = CoordinateNormalizer(points) val path = points.toPath(normalizer) … if (!path.op(path, Path.Op.UNION)) return listOf(points) return path.extractContours(normalizer).ifEmpty { listOf(points) } } {
  39. Untangle, in code private fun List<Point>.toPath(normalizer: CoordinateNormalizer): Path = Path().apply

    { forEachIndexed { index, point val x = normalizer.normalizeX(point.longitude) val y = normalizer.normalizeY(point.latitude) if (index 0) moveTo(x, y) else lineTo(x, y) } close() fillType = Path.FillType.WINDING } > > > - = = / / private fun normaliseDrawnShape(points: List<Point>): List<List<Point if (points.size < 3) return listOf(points) val normalizer = CoordinateNormalizer(points) val path = points.toPath(normalizer) … if (!path.op(path, Path.Op.UNION)) return listOf(points) return path.extractContours(normalizer).ifEmpty { listOf(points) } } {
  40. Untangle, in code private fun List<Point>.toPath(normalizer: CoordinateNormalizer): Path = Path().apply

    { forEachIndexed { index, point val x = normalizer.normalizeX(point.longitude) val y = normalizer.normalizeY(point.latitude) if (index 0) moveTo(x, y) else lineTo(x, y) } close() fillType = Path.FillType.WINDING } > > > - = = / / private fun normaliseDrawnShape(points: List<Point>): List<List<Point if (points.size < 3) return listOf(points) val normalizer = CoordinateNormalizer(points) val path = points.toPath(normalizer) … if (!path.op(path, Path.Op.UNION)) return listOf(points) return path.extractContours(normalizer).ifEmpty { listOf(points) } } {
  41. Untangle, in code private fun List<Point>.toPath(normalizer: CoordinateNormalizer): Path = Path().apply

    { forEachIndexed { index, point val x = normalizer.normalizeX(point.longitude) val y = normalizer.normalizeY(point.latitude) if (index 0) moveTo(x, y) else lineTo(x, y) } close() fillType = Path.FillType.WINDING } > > > - = = / / private fun normaliseDrawnShape(points: List<Point>): List<List<Point if (points.size < 3) return listOf(points) val normalizer = CoordinateNormalizer(points) val path = points.toPath(normalizer) … if (!path.op(path, Path.Op.UNION)) return listOf(points) return path.extractContours(normalizer).ifEmpty { listOf(points) } } {
  42. Untangle, in code private fun List<Point>.toPath(normalizer: CoordinateNormalizer): Path = Path().apply

    { forEachIndexed { index, point val x = normalizer.normalizeX(point.longitude) val y = normalizer.normalizeY(point.latitude) if (index 0) moveTo(x, y) else lineTo(x, y) } close() fillType = Path.FillType.WINDING } > > > - = = / / private fun normaliseDrawnShape(points: List<Point>): List<List<Point if (points.size < 3) return listOf(points) val normalizer = CoordinateNormalizer(points) val path = points.toPath(normalizer) … if (!path.op(path, Path.Op.UNION)) return listOf(points) return path.extractContours(normalizer).ifEmpty { listOf(points) } } {
  43. A group is a list of outlines [ Group 1:

    a list of outlines , outline 1 ] outline 2 On the map Google Maps draws the inner outline as a hole Nothing in the list says which outline is the hole
  44. EVEN_ODD only counts EVEN_ODD 1 1 2 2 2 crossings,

    outside Hole drawn the same way 2 crossings, outside Hole drawn the other way
  45. WINDING cares about direction WINDING +1 −1 +1 +1 +1

    +1 = 2, inside Hole drawn the same way +1 −1 = 0, outside Hole drawn the other way
  46. One Path: many outlines, one fill rule One Path outline

    1 outline 2 fillType = EVEN_ODD A donut The ll rule belongs to the Path, not to each outline fi addPath() copies outlines into another Path
  47. A new group around a hole The blue group: a

    donut A new group around the hole Expected a stop in its hole the stroke overlaps the ring the red group = the hole
  48. The donut became a disc +1 +1 The blue group’s

    Path After addPath EVEN_ODD into a WINDING Path
  49. The donut became a disc ∅ +1 +1 The blue

    group’s Path After addPath Red drawing − disc EVEN_ODD into a WINDING Path = nothing
  50. The donut became a disc ∅ +1 +1 The blue

    group’s Path After addPath Red drawing − disc Fallback EVEN_ODD into a WINDING Path = nothing the red drawing as it was
  51. The fix: the combined Path keeps the hole The blue

    group’s Path After addPath Red drawing − donut EVEN_ODD into an EVEN_ODD Path cut to the hole
  52. The fix, in code > > / / / /

    / / private fun subtractExistingGroups(newPoints: List<Point>): List<List<Point { val normalisedNewPoints = normaliseDrawnShape(newPoints) … val newPath = normalisedNewPoints.contoursToPath(normalizer) val existingCombined = Path().apply { fillType = Path.FillType.EVEN_ODD } for (group in state.stopGroups) { existingCombined.addPath(group.paths.contoursToPath(normalizer)) } … skipped when the bounding boxes don't intersect if (!newPath.op(existingCombined, Path.Op.DIFFERENCE)) return normalisedNewPoints val bounds = RectF() newPath.computeBounds(bounds, true) if (bounds.isEmpty) return normalisedNewPoints … }
  53. The fix, in code > > / / / /

    / / private fun subtractExistingGroups(newPoints: List<Point>): List<List<Point { val normalisedNewPoints = normaliseDrawnShape(newPoints) … val newPath = normalisedNewPoints.contoursToPath(normalizer) val existingCombined = Path().apply { fillType = Path.FillType.EVEN_ODD } for (group in state.stopGroups) { existingCombined.addPath(group.paths.contoursToPath(normalizer)) } … skipped when the bounding boxes don't intersect if (!newPath.op(existingCombined, Path.Op.DIFFERENCE)) return normalisedNewPoints val bounds = RectF() newPath.computeBounds(bounds, true) if (bounds.isEmpty) return normalisedNewPoints … }
  54. The donut holes of infinite possibilities A new group in

    a hole Around a group Two groups in a hole A donut in a donut cut to the hole the new group gets a hole a hole holds several groups the red group has a hole too
  55. The donut holes of infinite possibilities A new group in

    a hole Around a group Two groups in a hole A donut in a donut cut to the hole the new group gets a hole a hole holds several groups the red group has a hole too
  56. What do we need to test? • The shapes each

    tool produces • Which stop ends up in which group • A correct grouping can still mean a shape is wrong • The bugs look like “this is wrong visually”
  57. Side-by-side screenshots • a test-only renderer • before | after

    • real Skia on the JVM • Roborazzi + Dropbox differ
  58. The logic only knows coordinates • A controller class for

    the logic • Independent from the UI • Lat/lng in, lat/lng out
  59. Rendering before and after / / / / / /

    @Composable fun BeforeAfterStopGroupRenderer( before: List<StopGroup>, after: List<StopGroup>, … ) { … Row { Canvas(modifier = Modifier.size(PanelSize).background(Color.White)) { val mapper = CoordinateMapper(allPoints, size.width, size.height) drawPanel(before, stopPositions, mapper, operationPolygons = emptyList()) } … Canvas(modifier = Modifier.size(PanelSize).background(Color.White)) { val mapper = CoordinateMapper(allPoints, size.width, size.height) drawPanel(after, stopPositions, mapper, operationPolygons) } } }
  60. Rendering before and after / / / / / /

    @Composable fun BeforeAfterStopGroupRenderer( before: List<StopGroup>, after: List<StopGroup>, … ) { … Row { Canvas(modifier = Modifier.size(PanelSize).background(Color.White)) { val mapper = CoordinateMapper(allPoints, size.width, size.height) drawPanel(before, stopPositions, mapper, operationPolygons = emptyList()) } … Canvas(modifier = Modifier.size(PanelSize).background(Color.White)) { val mapper = CoordinateMapper(allPoints, size.width, size.height) drawPanel(after, stopPositions, mapper, operationPolygons) } } }
  61. Rendering before and after / / / / / /

    @Composable fun BeforeAfterStopGroupRenderer( before: List<StopGroup>, after: List<StopGroup>, … ) { … Row { Canvas(modifier = Modifier.size(PanelSize).background(Color.White)) { val mapper = CoordinateMapper(allPoints, size.width, size.height) drawPanel(before, stopPositions, mapper, operationPolygons = emptyList()) } … Canvas(modifier = Modifier.size(PanelSize).background(Color.White)) { val mapper = CoordinateMapper(allPoints, size.width, size.height) drawPanel(after, stopPositions, mapper, operationPolygons) } } }
  62. 34 geometry scenarios new self intersecting new squiggly circle erase

    self intersecting erase squiggly circle new around hole of existing edge expand from inner group in hole expand steals one erase splits two 8 New · 12 Expand · 10 Erase · 4 edge cases
  63. A test @Test fun new_drawsAroundHoleOfExistingGroup() { val stops = mapOf<StopId,

    Point>( stopId(1) to Point(0.4, 0.35), stopId(2) to Point(2.7, 2.65), stopId(3) to Point(1.5, 1.45), ) controller.onStopGroupDrawn( rect(0.0, 0.0, 3.0, 3.0), listOf(stopId(1), stopId(2), stopId(3)) ) val mockRouteSteps = routeSteps(stops) controller.eraseFromGroups(rect(1.0, 1.0, 2.0, 2.0), mockRouteSteps) val before = controller.getStopGroups() val drawn = rect(0.8, 0.8, 2.2, 2.2) val stopIds = controller.getUngroupedStopsInPolygon(mockRouteSteps, drawn) controller.onStopGroupDrawn(drawn, stopIds) capture("order_stop_groups_geometry_new_around_hole_of_existing", before, operationPolygons = listOf(drawn), stops = stops) }
  64. A test @Test fun new_drawsAroundHoleOfExistingGroup() { val stops = mapOf<StopId,

    Point>( stopId(1) to Point(0.4, 0.35), stopId(2) to Point(2.7, 2.65), stopId(3) to Point(1.5, 1.45), ) controller.onStopGroupDrawn( rect(0.0, 0.0, 3.0, 3.0), listOf(stopId(1), stopId(2), stopId(3)) ) val mockRouteSteps = routeSteps(stops) controller.eraseFromGroups(rect(1.0, 1.0, 2.0, 2.0), mockRouteSteps) val before = controller.getStopGroups() val drawn = rect(0.8, 0.8, 2.2, 2.2) val stopIds = controller.getUngroupedStopsInPolygon(mockRouteSteps, drawn) controller.onStopGroupDrawn(drawn, stopIds) capture("order_stop_groups_geometry_new_around_hole_of_existing", before, operationPolygons = listOf(drawn), stops = stops) }
  65. A test @Test fun new_drawsAroundHoleOfExistingGroup() { val stops = mapOf<StopId,

    Point>( stopId(1) to Point(0.4, 0.35), stopId(2) to Point(2.7, 2.65), stopId(3) to Point(1.5, 1.45), ) controller.onStopGroupDrawn( rect(0.0, 0.0, 3.0, 3.0), listOf(stopId(1), stopId(2), stopId(3)) ) val mockRouteSteps = routeSteps(stops) controller.eraseFromGroups(rect(1.0, 1.0, 2.0, 2.0), mockRouteSteps) val before = controller.getStopGroups() val drawn = rect(0.8, 0.8, 2.2, 2.2) val stopIds = controller.getUngroupedStopsInPolygon(mockRouteSteps, drawn) controller.onStopGroupDrawn(drawn, stopIds) capture("order_stop_groups_geometry_new_around_hole_of_existing", before, operationPolygons = listOf(drawn), stops = stops) }
  66. A test @Test fun new_drawsAroundHoleOfExistingGroup() { val stops = mapOf<StopId,

    Point>( stopId(1) to Point(0.4, 0.35), stopId(2) to Point(2.7, 2.65), stopId(3) to Point(1.5, 1.45), ) controller.onStopGroupDrawn( rect(0.0, 0.0, 3.0, 3.0), listOf(stopId(1), stopId(2), stopId(3)) ) val mockRouteSteps = routeSteps(stops) controller.eraseFromGroups(rect(1.0, 1.0, 2.0, 2.0), mockRouteSteps) val before = controller.getStopGroups() val drawn = rect(0.8, 0.8, 2.2, 2.2) val stopIds = controller.getUngroupedStopsInPolygon(mockRouteSteps, drawn) controller.onStopGroupDrawn(drawn, stopIds) capture("order_stop_groups_geometry_new_around_hole_of_existing", before, operationPolygons = listOf(drawn), stops = stops) }
  67. Wrapping up 📦 • We can draw “on” a Map

    without drawing on the Map • Path.op gives boolean operations on paths, built into Android • Mind float precision in precise calculations • Mind the fill rules when you combine Paths • Screenshot tests can check drawing logic. Get creative!