Share
Paths, Polygons, and Donuts - Building a Freehand Drawing Tool for Maps (DevFest Venezia 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
István Juhos
Transcript
112 slides
-
Paths, Polygons, and 🍩🍩 Building a Freehand Drawing Tool for
Maps István Juhos Staff Android Engineer -
Composing the overlay . ? / / OrderStopGroupDrawerOverlay( … onDrawingUpdate
= onOrderStopGroupDrawingUpdate, onDrawingEnd = onOrderStopGroupDrawn, enabled = state.value.orderStopGroupsState.isDrawingEnabled, mapProjectionProvider = { googleMap projection }, ) -
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 -
Composing the overlay . ? / / OrderStopGroupDrawerOverlay( … onDrawingUpdate
= onOrderStopGroupDrawingUpdate, onDrawingEnd = onOrderStopGroupDrawn, enabled = state.value.orderStopGroupsState.isDrawingEnabled, mapProjectionProvider = { googleMap projection }, ) -
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 }, ) } }, -
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 }, ) } }, -
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 }, ) } }, -
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) } }, ) } -
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 } -
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) } }, ) } -
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) } }, ) } -
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) }, -
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) }, -
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 -
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 -
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) }, -
Rules for every tool • A tool acts only if
it affects a stop • No empty groups, ever -
What each tool does Only takes ungrouped stops Can’t overlap
an existing group -
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 -
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 -
Boolean operations on Paths path.op(other, Path.Op.[operation]) A B UNION A∪B
developer.android.com/reference/android/graphics/Path.Op -
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 -
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 -
New group = DIFFERENCE • Works for ungrouped stops only
• Can’t overlap with existing groups visually -
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 -
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. -
Floats broke Path.Op Before After Expand should be in, ends
up out Erase should be out, stays in -
A float keeps about seven digits 47.123456789 a coordinate, as
a double 47.123455 fl the same coordinate, as a oat -
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 -
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 } / / / / } -
. The demo was a lie. tion a c i
f i l p m i s -
Is this point inside? −1 +1 1 crossing +1 →
inside +1 2 crossings −1 +1 = 0 → outside -
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 -
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 } -
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 } -
One stroke, three answers Overlay fi Compose, NonZero · lled
Stop containment check containsLocation · pins aren’t highlighted Google Maps polygon a hole -
One stroke, three answers Overlay fi Compose, NonZero · lled
Stop containment check containsLocation · pins aren’t highlighted Google Maps polygon a hole -
One stroke, three answers Overlay fi Compose, NonZero · lled
Stop containment check containsLocation · pins aren’t highlighted Google Maps polygon a hole -
The fix: untangle the shape The stroke as drawn it
crosses itself -
The fix: untangle the shape UNION with itself The stroke
as drawn it crosses itself One clean outline no crossings: every check agrees -
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 -
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 -
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 -
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 -
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 -
The donut became a disc +1 +1 The blue group’s
Path After addPath EVEN_ODD into a WINDING Path -
The donut became a disc ∅ +1 +1 The blue
group’s Path After addPath Red drawing − disc EVEN_ODD into a WINDING Path = nothing -
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 -
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 -
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 … } -
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 … } -
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 -
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 -
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” -
Side-by-side screenshots • a test-only renderer • before | after
• real Skia on the JVM • Roborazzi + Dropbox differ -
The logic only knows coordinates • A controller class for
the logic • Independent from the UI • Lat/lng in, lat/lng out -
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) } } } -
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) } } } -
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) } } } -
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 -
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) } -
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) } -
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) } -
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) } -
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! -
🍩 Grab a donut! 🍩 y r t s a
p l a c o l e m o s