Skip to content

Commit

Permalink
perf: expose isPointInPolygon and make 40% faster (at least in JIT …
Browse files Browse the repository at this point in the history
…mode) (fleaflet#1907)

Co-authored-by: Sebastian <[email protected]>
  • Loading branch information
ignatz and barfootsies authored Jun 6, 2024
1 parent b69a0d7 commit c321865
Show file tree
Hide file tree
Showing 7 changed files with 171 additions and 43 deletions.
76 changes: 76 additions & 0 deletions benchmark/point_in_polygon.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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<Result> 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);
}

List<Offset> 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
//
// If you run in JIT mode, the resulting execution times will be a lot more similar.
Future<void> main() async {
Logger.level = Level.all;
Logger.defaultFilter = NoFilter.new;
Logger.defaultPrinter = SimplePrinter.new;

final results = <Result>[];
const N = 3000000;

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, circle);
}

assert(yesPlease, 'should be in circle');
return yesPlease;
}));

results.add(await timedRun('Not in circle', () {
const point = math.Point(4, 4);

bool noSir = false;
for (int i = 0; i < N; ++i) {
noSir = noSir || isPointInPolygon(point, circle);
}

assert(!noSir, 'should not be in circle');
return noSir;
}));

Logger().i('Results:\n${results.map((r) => r.toString()).join('\n')}');
}
2 changes: 1 addition & 1 deletion lib/src/geo/crs.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
57 changes: 19 additions & 38 deletions lib/src/layer/polygon_layer/painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,42 +39,41 @@ base class _PolygonPainter<R extends Object>
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<List<Offset>> 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 = _isPointInPolygon(point, projectedCoords);
final isInHole = hasHoles &&
projectedHoleCoords
.map((c) => _isPointInPolygon(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
Expand Down Expand Up @@ -361,24 +360,6 @@ base class _PolygonPainter<R extends Object>
);
}

/// Checks whether point [p] is within the specified closed [polygon]
///
/// Uses the even-odd algorithm.
static bool _isPointInPolygon(math.Point p, List<Offset> 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<R> oldDelegate) =>
polygons != oldDelegate.polygons ||
Expand Down
1 change: 1 addition & 0 deletions lib/src/layer/polygon_layer/polygon_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 4 additions & 4 deletions lib/src/misc/offsets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ List<Offset> getOffsets(MapCamera camera, Offset origin, List<LatLng> points) {

// Optimization: monomorphize the Epsg3857-case to avoid the virtual function overhead.
if (crs case final Epsg3857 epsg3857) {
final v = List<Offset>.filled(len, Offset.zero);
final v = List<Offset>.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);
}
return v;
}

final v = List<Offset>.filled(len, Offset.zero);
final v = List<Offset>.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);
Expand Down Expand Up @@ -63,7 +63,7 @@ List<Offset> getOffsetsXY({
// Optimization: monomorphize the CrsWithStaticTransformation-case to avoid
// the virtual function overhead.
if (crs case final CrsWithStaticTransformation crs) {
final v = List<Offset>.filled(len, Offset.zero);
final v = List<Offset>.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);
Expand All @@ -72,7 +72,7 @@ List<Offset> getOffsetsXY({
return v;
}

final v = List<Offset>.filled(len, Offset.zero);
final v = List<Offset>.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);
Expand Down
29 changes: 29 additions & 0 deletions lib/src/misc/point_in_polygon.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'dart:math' as math;
import 'dart:ui';

/// Checks whether point [p] is within the specified closed [polygon]
///
/// Uses the even-odd algorithm and requires closed loop polygons, i.e.
/// `polygon.first == polygon.last`.
bool isPointInPolygon(math.Point p, List<Offset> 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 = len - 1; i < len; 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;
}
41 changes: 41 additions & 0 deletions test/misc/point_in_polygon_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'dart:math' as math;

import 'package:flutter_map/src/misc/point_in_polygon.dart';
import 'package:flutter_test/flutter_test.dart';

List<Offset> 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);
}

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(isPointInPolygon(p, circle), isTrue);
}

// Edge-case: check origin
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(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(isPointInPolygon(p, circle), isFalse);
}
});
}

0 comments on commit c321865

Please sign in to comment.