From 7b19a6ae04cc1104807b4ca4c12055a3df194158 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 5 Jun 2024 22:12:45 +0200 Subject: [PATCH 1/4] Make isPointInPolygon 40% faster (at least in JIT mode). I mostly looked at the function because I wanted to use it myself but it was private. So I thought, if I expose it then I could at least put a ribbon on it by adding a benchmark. Before: (duration: 0:00:05.998949, name: In circle) (duration: 0:00:06.866919, name: Not in circle) After: (duration: 0:00:03.649496, name: In circle) (duration: 0:00:04.611599, name: Not in circle) Note, I opportunistically touched crs to remove the dart:ui dependency. This way it can be compiled with dart (w/o flutter) rendering the instructions in the benchmark correct again. Unfortunately, pointInPolygon is not so fortunate and needs flutter due to the dependency on ui.Offset. That's why I could only run it with "flutter test" in JIT mode. --- benchmark/point_in_polygon.dart | 75 +++++++++++++++++++ lib/src/geo/crs.dart | 2 +- lib/src/layer/polygon_layer/painter.dart | 24 +----- .../layer/polygon_layer/polygon_layer.dart | 1 + lib/src/misc/point_in_polygon.dart | 25 +++++++ 5 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 benchmark/point_in_polygon.dart create mode 100644 lib/src/misc/point_in_polygon.dart diff --git a/benchmark/point_in_polygon.dart b/benchmark/point_in_polygon.dart new file mode 100644 index 000000000..cbd940a89 --- /dev/null +++ b/benchmark/point_in_polygon.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_map/src/misc/point_in_polygon.dart'; +import 'package:logger/logger.dart'; + +class NoFilter extends LogFilter { + @override + bool shouldLog(LogEvent event) => true; +} + +typedef Result = ({ + String name, + Duration duration, +}); + +Future timedRun(String name, dynamic Function() body) async { + Logger().i('running $name...'); + final watch = Stopwatch()..start(); + await body(); + watch.stop(); + + return (name: name, duration: watch.elapsed); +} + +// NOTE: to have a more prod like comparison, run with: +// $ dart compile exe benchmark/crs.dart && ./benchmark/crs.exe +// +// If you run in JIT mode, the resulting execution times will be a lot more similar. +Future main() async { + Logger.level = Level.all; + Logger.defaultFilter = NoFilter.new; + Logger.defaultPrinter = SimplePrinter.new; + + final results = []; + const N = 3000000; + const points = 1000; + + results.add(await timedRun('In circle', () { + final polygon = List.generate(points, (i) { + final angle = math.pi * 2 / points * i; + return Offset(math.cos(angle), math.sin(angle)); + }); + + const point = math.Point(0, 0); + + bool yesPlease = true; + for (int i = 0; i < N; ++i) { + yesPlease = yesPlease && pointInPolygon(point, polygon); + } + + assert(yesPlease, 'should be in circle'); + return yesPlease; + })); + + results.add(await timedRun('Not in circle', () { + final polygon = List.generate(points, (i) { + final angle = math.pi * 2 / points * i; + return Offset(math.cos(angle), math.sin(angle)); + }); + + const point = math.Point(4, 4); + + bool noSir = false; + for (int i = 0; i < N; ++i) { + noSir = noSir || pointInPolygon(point, polygon); + } + + assert(!noSir, 'should not be in circle'); + return noSir; + })); + + Logger().i('Results:\n${results.map((r) => r.toString()).join('\n')}'); +} diff --git a/lib/src/geo/crs.dart b/lib/src/geo/crs.dart index 9b97aa1c5..63b5e1936 100644 --- a/lib/src/geo/crs.dart +++ b/lib/src/geo/crs.dart @@ -1,7 +1,7 @@ import 'dart:math' as math hide Point; import 'dart:math' show Point; -import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/misc/bounds.dart'; import 'package:latlong2/latlong.dart'; import 'package:meta/meta.dart'; import 'package:proj4dart/proj4dart.dart' as proj4; diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index 315fe0402..111d4d38d 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -70,11 +70,9 @@ base class _PolygonPainter } } - final isInPolygon = _isPointInPolygon(point, projectedCoords); + final isInPolygon = pointInPolygon(point, projectedCoords); final isInHole = hasHoles && - projectedHoleCoords - .map((c) => _isPointInPolygon(point, c)) - .any((e) => e); + projectedHoleCoords.map((c) => pointInPolygon(point, c)).any((e) => e); // Second check handles case where polygon outline intersects a hole, // ensuring that the hit matches with the visual representation @@ -361,24 +359,6 @@ base class _PolygonPainter ); } - /// Checks whether point [p] is within the specified closed [polygon] - /// - /// Uses the even-odd algorithm. - static bool _isPointInPolygon(math.Point p, List polygon) { - bool isInPolygon = false; - - for (int i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - if ((((polygon[i].dy <= p.y) && (p.y < polygon[j].dy)) || - ((polygon[j].dy <= p.y) && (p.y < polygon[i].dy))) && - (p.x < - (polygon[j].dx - polygon[i].dx) * - (p.y - polygon[i].dy) / - (polygon[j].dy - polygon[i].dy) + - polygon[i].dx)) isInPolygon = !isInPolygon; - } - return isInPolygon; - } - @override bool shouldRepaint(_PolygonPainter oldDelegate) => polygons != oldDelegate.polygons || diff --git a/lib/src/layer/polygon_layer/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index ce20d0c99..ac959ed10 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -9,6 +9,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_detectable.dart'; import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart'; import 'package:flutter_map/src/misc/offsets.dart'; +import 'package:flutter_map/src/misc/point_in_polygon.dart'; import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart' hide Path; import 'package:polylabel/polylabel.dart'; diff --git a/lib/src/misc/point_in_polygon.dart b/lib/src/misc/point_in_polygon.dart new file mode 100644 index 000000000..0d0974cd8 --- /dev/null +++ b/lib/src/misc/point_in_polygon.dart @@ -0,0 +1,25 @@ +import 'dart:math' as math; +import 'dart:ui'; + +/// Checks whether point [p] is within the specified closed [polygon] +/// +/// Uses the even-odd algorithm. +bool pointInPolygon(math.Point p, List polygon) { + final double px = p.x.toDouble(); + final double py = p.y.toDouble(); + + bool isInPolygon = false; + for (int i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + final double poIx = polygon[i].dx; + final double poIy = polygon[i].dy; + + final double poJx = polygon[j].dx; + final double poJy = polygon[j].dy; + + if ((((poIy <= py) && (py < poJy)) || ((poJy <= py) && (py < poIy))) && + (px < (poJx - poIx) * (py - poIy) / (poJy - poIy) + poIx)) { + isInPolygon = !isInPolygon; + } + } + return isInPolygon; +} From 306338c09551ec3bdb4a369267d23b4d33f15998 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 6 Jun 2024 09:01:24 +0200 Subject: [PATCH 2/4] Also add a test for "good measure". --- test/misc/point_in_polygon_test.dart | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/misc/point_in_polygon_test.dart diff --git a/test/misc/point_in_polygon_test.dart b/test/misc/point_in_polygon_test.dart new file mode 100644 index 000000000..8560e9fc6 --- /dev/null +++ b/test/misc/point_in_polygon_test.dart @@ -0,0 +1,38 @@ +import 'dart:math' as math; + +import 'package:flutter_map/src/misc/point_in_polygon.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List makeCircle(int points, double radius, double phase) { + return List.generate(points, (i) { + final angle = math.pi * 2 / points * i + phase; + return Offset(radius * math.cos(angle), radius * math.sin(angle)); + }, growable: false); +} + +void main() { + test('Smoke test for points in and out of polygons', () { + final circle = makeCircle(100, 1, 0); + + // Inside points + for (final point in makeCircle(32, 0.8, 0.0001)) { + final p = math.Point(point.dx, point.dy); + expect(pointInPolygon(p, circle), isTrue); + } + + // Edge-case: check origin + expect(pointInPolygon(const math.Point(0, 0), circle), isTrue); + + // Outside points: small radius + for (final point in makeCircle(32, 1.1, 0.0001)) { + final p = math.Point(point.dx, point.dy); + expect(pointInPolygon(p, circle), isFalse); + } + + // Outside points: large radius + for (final point in makeCircle(32, 100000, 0.0001)) { + final p = math.Point(point.dx, point.dy); + expect(pointInPolygon(p, circle), isFalse); + } + }); +} From 0f01a8b1e309b2a0cb26fc15db6cf000d3a47017 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 6 Jun 2024 10:57:13 +0200 Subject: [PATCH 3/4] Rename pointInPolygon to isPointInPolygon, reduce copies and fix "is point in hole" computation. --- benchmark/point_in_polygon.dart | 4 +-- lib/src/layer/polygon_layer/painter.dart | 37 ++++++++++++------------ lib/src/misc/offsets.dart | 8 ++--- lib/src/misc/point_in_polygon.dart | 2 +- test/misc/point_in_polygon_test.dart | 8 ++--- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/benchmark/point_in_polygon.dart b/benchmark/point_in_polygon.dart index cbd940a89..ebe8570bb 100644 --- a/benchmark/point_in_polygon.dart +++ b/benchmark/point_in_polygon.dart @@ -47,7 +47,7 @@ Future main() async { bool yesPlease = true; for (int i = 0; i < N; ++i) { - yesPlease = yesPlease && pointInPolygon(point, polygon); + yesPlease = yesPlease && isPointInPolygon(point, polygon); } assert(yesPlease, 'should be in circle'); @@ -64,7 +64,7 @@ Future main() async { bool noSir = false; for (int i = 0; i < N; ++i) { - noSir = noSir || pointInPolygon(point, polygon); + noSir = noSir || isPointInPolygon(point, polygon); } assert(!noSir, 'should not be in circle'); diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index 111d4d38d..1952ee8ca 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -39,40 +39,41 @@ base class _PolygonPainter required LatLng coordinate, }) { final polygon = projectedPolygon.polygon; - - if (!polygon.boundingBox.contains(coordinate)) return false; + if (!polygon.boundingBox.contains(coordinate)) { + return false; + } final projectedCoords = getOffsetsXY( camera: camera, origin: hitTestCameraOrigin, points: projectedPolygon.points, - ).toList(); + ); if (projectedCoords.first != projectedCoords.last) { projectedCoords.add(projectedCoords.first); } + final isInPolygon = isPointInPolygon(point, projectedCoords); final hasHoles = projectedPolygon.holePoints.isNotEmpty; - late final List> projectedHoleCoords; - if (hasHoles) { - projectedHoleCoords = projectedPolygon.holePoints - .map( - (points) => getOffsetsXY( + final isInHole = hasHoles && + () { + for (final points in projectedPolygon.holePoints) { + final projectedHoleCoords = getOffsetsXY( camera: camera, origin: hitTestCameraOrigin, points: points, - ).toList(), - ) - .toList(); + ); - if (projectedHoleCoords.firstOrNull != projectedHoleCoords.lastOrNull) { - projectedHoleCoords.add(projectedHoleCoords.first); - } - } + if (projectedHoleCoords.first != projectedHoleCoords.last) { + projectedHoleCoords.add(projectedHoleCoords.first); + } - final isInPolygon = pointInPolygon(point, projectedCoords); - final isInHole = hasHoles && - projectedHoleCoords.map((c) => pointInPolygon(point, c)).any((e) => e); + if (isPointInPolygon(point, projectedHoleCoords)) { + return true; + } + } + return false; + }(); // Second check handles case where polygon outline intersects a hole, // ensuring that the hit matches with the visual representation diff --git a/lib/src/misc/offsets.dart b/lib/src/misc/offsets.dart index 1db896931..c22c216de 100644 --- a/lib/src/misc/offsets.dart +++ b/lib/src/misc/offsets.dart @@ -25,7 +25,7 @@ List getOffsets(MapCamera camera, Offset origin, List points) { // Optimization: monomorphize the Epsg3857-case to avoid the virtual function overhead. if (crs case final Epsg3857 epsg3857) { - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final (x, y) = epsg3857.latLngToXY(points[i], zoomScale); v[i] = Offset(x + ox, y + oy); @@ -33,7 +33,7 @@ List getOffsets(MapCamera camera, Offset origin, List points) { return v; } - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final (x, y) = crs.latLngToXY(points[i], zoomScale); v[i] = Offset(x + ox, y + oy); @@ -63,7 +63,7 @@ List getOffsetsXY({ // Optimization: monomorphize the CrsWithStaticTransformation-case to avoid // the virtual function overhead. if (crs case final CrsWithStaticTransformation crs) { - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final p = realPoints.elementAt(i); final (x, y) = crs.transform(p.x, p.y, zoomScale); @@ -72,7 +72,7 @@ List getOffsetsXY({ return v; } - final v = List.filled(len, Offset.zero); + final v = List.filled(len, Offset.zero, growable: true); for (int i = 0; i < len; ++i) { final p = realPoints.elementAt(i); final (x, y) = crs.transform(p.x, p.y, zoomScale); diff --git a/lib/src/misc/point_in_polygon.dart b/lib/src/misc/point_in_polygon.dart index 0d0974cd8..1d5c08b17 100644 --- a/lib/src/misc/point_in_polygon.dart +++ b/lib/src/misc/point_in_polygon.dart @@ -4,7 +4,7 @@ import 'dart:ui'; /// Checks whether point [p] is within the specified closed [polygon] /// /// Uses the even-odd algorithm. -bool pointInPolygon(math.Point p, List polygon) { +bool isPointInPolygon(math.Point p, List polygon) { final double px = p.x.toDouble(); final double py = p.y.toDouble(); diff --git a/test/misc/point_in_polygon_test.dart b/test/misc/point_in_polygon_test.dart index 8560e9fc6..d5bf921b9 100644 --- a/test/misc/point_in_polygon_test.dart +++ b/test/misc/point_in_polygon_test.dart @@ -17,22 +17,22 @@ void main() { // Inside points for (final point in makeCircle(32, 0.8, 0.0001)) { final p = math.Point(point.dx, point.dy); - expect(pointInPolygon(p, circle), isTrue); + expect(isPointInPolygon(p, circle), isTrue); } // Edge-case: check origin - expect(pointInPolygon(const math.Point(0, 0), circle), isTrue); + expect(isPointInPolygon(const math.Point(0, 0), circle), isTrue); // Outside points: small radius for (final point in makeCircle(32, 1.1, 0.0001)) { final p = math.Point(point.dx, point.dy); - expect(pointInPolygon(p, circle), isFalse); + expect(isPointInPolygon(p, circle), isFalse); } // Outside points: large radius for (final point in makeCircle(32, 100000, 0.0001)) { final p = math.Point(point.dx, point.dy); - expect(pointInPolygon(p, circle), isFalse); + expect(isPointInPolygon(p, circle), isFalse); } }); } From bb3f8be1822f146a4529a562881c8cc2e49f75d4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 6 Jun 2024 11:27:46 +0200 Subject: [PATCH 4/4] Add an assert (open to other means of handling) to validate isPointInPolygon inputs. Would have caught previous misuse. --- benchmark/point_in_polygon.dart | 27 ++++++++++++++------------- lib/src/misc/point_in_polygon.dart | 8 ++++++-- test/misc/point_in_polygon_test.dart | 5 ++++- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/benchmark/point_in_polygon.dart b/benchmark/point_in_polygon.dart index ebe8570bb..d75a643af 100644 --- a/benchmark/point_in_polygon.dart +++ b/benchmark/point_in_polygon.dart @@ -24,6 +24,16 @@ Future timedRun(String name, dynamic Function() body) async { return (name: name, duration: watch.elapsed); } +List makeCircle(int points, double radius, double phase) { + final slice = math.pi * 2 / (points - 1); + return List.generate(points, (i) { + // Note the modulo is only there to deal with floating point imprecision + // and ensure first == last. + final angle = slice * (i % (points - 1)) + phase; + return Offset(radius * math.cos(angle), radius * math.sin(angle)); + }, growable: false); +} + // NOTE: to have a more prod like comparison, run with: // $ dart compile exe benchmark/crs.dart && ./benchmark/crs.exe // @@ -35,19 +45,15 @@ Future main() async { final results = []; const N = 3000000; - const points = 1000; - results.add(await timedRun('In circle', () { - final polygon = List.generate(points, (i) { - final angle = math.pi * 2 / points * i; - return Offset(math.cos(angle), math.sin(angle)); - }); + final circle = makeCircle(1000, 1, 0); + results.add(await timedRun('In circle', () { const point = math.Point(0, 0); bool yesPlease = true; for (int i = 0; i < N; ++i) { - yesPlease = yesPlease && isPointInPolygon(point, polygon); + yesPlease = yesPlease && isPointInPolygon(point, circle); } assert(yesPlease, 'should be in circle'); @@ -55,16 +61,11 @@ Future main() async { })); results.add(await timedRun('Not in circle', () { - final polygon = List.generate(points, (i) { - final angle = math.pi * 2 / points * i; - return Offset(math.cos(angle), math.sin(angle)); - }); - const point = math.Point(4, 4); bool noSir = false; for (int i = 0; i < N; ++i) { - noSir = noSir || isPointInPolygon(point, polygon); + noSir = noSir || isPointInPolygon(point, circle); } assert(!noSir, 'should not be in circle'); diff --git a/lib/src/misc/point_in_polygon.dart b/lib/src/misc/point_in_polygon.dart index 1d5c08b17..9f0192da0 100644 --- a/lib/src/misc/point_in_polygon.dart +++ b/lib/src/misc/point_in_polygon.dart @@ -3,13 +3,17 @@ import 'dart:ui'; /// Checks whether point [p] is within the specified closed [polygon] /// -/// Uses the even-odd algorithm. +/// Uses the even-odd algorithm and requires closed loop polygons, i.e. +/// `polygon.first == polygon.last`. bool isPointInPolygon(math.Point p, List polygon) { + final len = polygon.length; + assert(len >= 3, 'not a polygon'); + assert(polygon.first == polygon.last, 'polygon not closed'); final double px = p.x.toDouble(); final double py = p.y.toDouble(); bool isInPolygon = false; - for (int i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + for (int i = 0, j = len - 1; i < len; j = i++) { final double poIx = polygon[i].dx; final double poIy = polygon[i].dy; diff --git a/test/misc/point_in_polygon_test.dart b/test/misc/point_in_polygon_test.dart index d5bf921b9..b9f697125 100644 --- a/test/misc/point_in_polygon_test.dart +++ b/test/misc/point_in_polygon_test.dart @@ -4,8 +4,11 @@ import 'package:flutter_map/src/misc/point_in_polygon.dart'; import 'package:flutter_test/flutter_test.dart'; List makeCircle(int points, double radius, double phase) { + final slice = math.pi * 2 / (points - 1); return List.generate(points, (i) { - final angle = math.pi * 2 / points * i + phase; + // Note the modulo is only there to deal with floating point imprecision + // and ensure first == last. + final angle = slice * (i % (points - 1)) + phase; return Offset(radius * math.cos(angle), radius * math.sin(angle)); }, growable: false); }