diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index 10d6a5f7b..378243f6e 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -145,6 +145,9 @@ class _PolygonPainter extends CustomPainter void paint(Canvas canvas, Size size) { super.paint(canvas, size); + final nearClip = NearClip.of(camera); + List inFront(List ring) => nearClip?.ring(ring) ?? ring; + final trianglePoints = []; Path filledPath = Path(); @@ -328,7 +331,7 @@ class _PolygonPainter extends CustomPainter return WorldWorkControl.invisible; } - unfillPolygon(fillOffsets); + unfillPolygon(inFront(fillOffsets)); if (_invertedHoles) { for (final singleHolePoints in projectedPolygon.holePoints) { @@ -336,8 +339,9 @@ class _PolygonPainter extends CustomPainter points: singleHolePoints, shift: shift, ); - unfillPolygon(holeOffsets); - invertFillPolygonHole(holeOffsets); + final holeInFront = inFront(holeOffsets); + unfillPolygon(holeInFront); + invertFillPolygonHole(holeInFront); } } return WorldWorkControl.visible; @@ -369,7 +373,7 @@ class _PolygonPainter extends CustomPainter /// Draws on a "single-world" WorldWorkControl drawIfVisible(double shift) { - final (fillOffsets, addedWorldWidthForHoles) = _helper.getOffsetsXY( + var (fillOffsets, addedWorldWidthForHoles) = _helper.getOffsetsXY( points: projectedPolygon.points, holePoints: polygonTriangles != null ? projectedPolygon.holePoints : null, @@ -378,6 +382,15 @@ class _PolygonPainter extends CustomPainter if (!areOffsetsVisible(fillOffsets)) { return WorldWorkControl.invisible; } + // The triangles index the unclipped points, so a clipped polygon is + // filled through its path. + final clipped = nearClip != null && !nearClip.contains(fillOffsets); + if (clipped) { + fillOffsets = nearClip.ring(_helper + .getOffsetsXY(points: projectedPolygon.points, shift: shift) + .$1); + if (fillOffsets.isEmpty) return WorldWorkControl.invisible; + } if (debugAltRenderer) { const offsetsLabelStyle = TextStyle( @@ -415,7 +428,7 @@ class _PolygonPainter extends CustomPainter // First add fills and borders to path. if (polygon.color != null) { - if (polygonTriangles != null) { + if (polygonTriangles != null && !clipped) { final len = polygonTriangles.length; for (int i = 0; i < len; ++i) { trianglePoints.add(fillOffsets[polygonTriangles[i]]); @@ -435,12 +448,12 @@ class _PolygonPainter extends CustomPainter ); if (borderPaint != null) { - addBorderToPath(_helper + addBorderToPath(inFront(_helper .getOffsetsXY( points: projectedPolygon.points, shift: shift, ) - .$1); + .$1)); } // Afterwards deal with more complicated holes. @@ -453,9 +466,11 @@ class _PolygonPainter extends CustomPainter shift: shift, forcedAddedWorldWidth: addedWorldWidthForHoles, ); - unfillPolygon(holeOffsets); + final holeInFront = inFront(holeOffsets); + if (holeInFront.isEmpty) continue; + unfillPolygon(holeInFront); if (!polygon.disableHolesBorder && borderPaint != null) { - addBorderToPath(holeOffsets); + addBorderToPath(holeInFront); } } diff --git a/lib/src/layer/polygon_layer/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index 9baec9ad5..924ee4f95 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -10,6 +10,7 @@ import 'package:flutter_map/src/layer/shared/layer_interactivity/internal_hit_de import 'package:flutter_map/src/layer/shared/layer_projection_simplification/state.dart'; import 'package:flutter_map/src/layer/shared/layer_projection_simplification/widget.dart'; import 'package:flutter_map/src/layer/shared/line_patterns/pixel_hiker.dart'; +import 'package:flutter_map/src/layer/shared/near_clip.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'; diff --git a/lib/src/layer/shared/near_clip.dart b/lib/src/layer/shared/near_clip.dart new file mode 100644 index 000000000..4162cd97e --- /dev/null +++ b/lib/src/layer/shared/near_clip.dart @@ -0,0 +1,70 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; +import 'package:meta/meta.dart'; + +/// The part of a pitched map's layer canvas in front of the camera, to which +/// the polygon painter clips polygons before filling them. Flutter 3.27 fills +/// nothing of a path with a vertex behind the camera. +/// +/// It is where the perspective divisor is at least half of its value at the +/// bottom edge of the map view. The divisor is linear in canvas coordinates, +/// so the boundary is a line; every visible ground point is in front of it, +/// and the boundary is below the map view, so edges that clipping adds aren't +/// visible. +@internal +class NearClip { + final double _a; + final double _b; + final double _c; + + const NearClip._(this._a, this._b, this._c); + + /// The clip of [camera]'s layer canvas, or null if [camera] is unpitched. + static NearClip? of(MapCamera camera) { + if (camera.pitch == 0) return null; + final size = camera.nonRotatedSize; + final bottom = + camera.groundOffsetFromCenter(Offset(size.width / 2, size.height)); + if (bottom == null) return null; + final origin = camera.pixelOrigin; + final m = (camera.pixelToScreenTransform + ..multiply(Matrix4.translationValues(origin.dx, origin.dy, 0))) + .storage; + final a = m[3]; + final b = m[7]; + final c = m[15]; + final canvasBottom = camera.projectAtZoom(camera.center) + bottom - origin; + final threshold = (a * canvasBottom.dx + b * canvasBottom.dy + c) / 2; + if (!threshold.isFinite || threshold <= 0) return null; + return NearClip._(a, b, c - threshold); + } + + double _value(Offset p) => _a * p.dx + _b * p.dy + _c; + + bool _inFront(Offset p) => _value(p) >= 0; + + Offset _boundary(Offset p, Offset q) { + final vp = _value(p); + return p + (q - p) * (vp / (vp - _value(q))); + } + + /// Whether all of [points] are in front of the boundary. + bool contains(List points) => points.every(_inFront); + + /// The part of the polygon [ring] in front of the boundary: [ring] itself if + /// it is all in front, and empty if none of it is. + List ring(List ring) { + if (contains(ring)) return ring; + final result = []; + for (var i = 0; i < ring.length; i++) { + final current = ring[i]; + final previous = ring[(i + ring.length - 1) % ring.length]; + final currentInFront = _inFront(current); + if (currentInFront != _inFront(previous)) { + result.add(_boundary(previous, current)); + } + if (currentInFront) result.add(current); + } + return result; + } +} diff --git a/test/layer/behind_camera_test.dart b/test/layer/behind_camera_test.dart new file mode 100644 index 000000000..6941a3994 --- /dev/null +++ b/test/layer/behind_camera_test.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/shared/near_clip.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +const _size = Size(400, 300); +const _center = LatLng(45.5, -122.6); +const _background = Color(0xFF0000FF); +const _red = Color(0xFFFF0000); + +final _boundary = GlobalKey(); + +MapCamera _camera({double pitch = 85, double rotation = 30}) => MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: 12, + rotation: rotation, + nonRotatedSize: _size, + pitch: pitch, + ); + +/// [camera]'s transform from its layer canvas to the screen. +Matrix4 _canvasToScreen(MapCamera camera) { + final origin = camera.pixelOrigin; + return camera.pixelToScreenTransform + ..multiply(Matrix4.translationValues(origin.dx, origin.dy, 0)); +} + +double _divisor(Matrix4 m, Offset p) => + m.storage[3] * p.dx + m.storage[7] * p.dy + m.storage[15]; + +Offset _canvas(MapCamera camera, LatLng point) => + camera.projectAtZoom(point) - camera.pixelOrigin; + +Future _pump( + WidgetTester tester, { + required List children, +}) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: _boundary, + child: SizedBox.fromSize( + size: _size, + child: FlutterMap( + mapController: controller, + options: const MapOptions( + initialCenter: _center, + initialZoom: 12, + initialRotation: 30, + initialPitch: 85, + maxPitch: 85, + backgroundColor: _background, + ), + children: children, + ), + ), + ), + ), + )); + return controller; +} + +Future _render(WidgetTester tester) async { + await tester.pumpAndSettle(); + final boundary = tester.renderObject( + find.byKey(_boundary), + ); + final data = (await tester.runAsync(() async { + final image = await boundary.toImage(); + return image.toByteData(); + }))!; + return (int x, int y) { + final value = data.getUint32((y * _size.width.toInt() + x) * 4); + return Color(((value & 0xFF) << 24) | (value >> 8)); + }; +} + +void main() { + group('near clip', () { + test('is null at pitch 0', () { + expect(NearClip.of(_camera(pitch: 0)), isNull); + }); + + test('keeps a polygon in front of the camera', () { + final camera = _camera(); + final clip = NearClip.of(camera)!; + final ring = [ + for (final (lat, lng) in const [ + (45.6, -122.7), + (45.6, -122.5), + (45.45, -122.5), + (45.45, -122.7), + ]) + _canvas(camera, LatLng(lat, lng)), + ]; + expect(clip.contains(ring), isTrue); + expect(identical(clip.ring(ring), ring), isTrue); + }); + + for (final rotation in [0.0, 30.0]) { + test('clips a polygon behind the camera, rotation $rotation', () { + final camera = _camera(rotation: rotation); + final clip = NearClip.of(camera)!; + final toScreen = _canvasToScreen(camera); + final behind = _canvas(camera, const LatLng(40, -122.6)); + expect(_divisor(toScreen, behind), lessThan(0)); + + final ring = [ + _canvas(camera, const LatLng(46, -122.7)), + _canvas(camera, const LatLng(46, -122.5)), + _canvas(camera, const LatLng(40, -122.5)), + _canvas(camera, const LatLng(40, -122.7)), + ]; + final clipped = clip.ring(ring); + expect(clipped, hasLength(4)); + for (final p in clipped) { + expect(_divisor(toScreen, p), greaterThan(0)); + } + for (final p in clipped.where((p) => !ring.contains(p))) { + expect(MatrixUtils.transformPoint(toScreen, p).dy, + greaterThan(_size.height)); + } + expect( + clip.ring([behind, behind + const Offset(10, 0), behind]), isEmpty); + }); + } + }); + + testWidgets('polygon reaching behind the camera', (tester) async { + final controller = await _pump(tester, children: [ + PolygonLayer(polygons: [ + Polygon( + points: const [ + LatLng(65, -122.62), + LatLng(65, -122.58), + LatLng(40, -122.58), + LatLng(40, -122.62), + ], + color: _red, + ), + ]), + ]); + final pixel = await _render(tester); + final camera = controller.camera; + bool inside(Offset p) { + final c = camera.screenOffsetToLatLng(p); + return c.longitude > -122.62 && c.longitude < -122.58; + } + + var red = 0; + for (var y = 0; y < 300; y += 3) { + if ((y - camera.farLimit).abs() < 2) continue; + for (var x = 0; x < 400; x += 2) { + final Color expected; + if (y < camera.farLimit) { + expected = _background; + } else { + final center = Offset(x + 0.5, y + 0.5); + final neighbors = { + for (final d in const [-3.0, 0.0, 3.0]) + for (final e in const [-3.0, 0.0, 3.0]) + inside(center + Offset(d, e)), + }; + if (neighbors.length > 1) continue; + expected = neighbors.single ? _red : _background; + if (neighbors.single) red++; + } + expect(pixel(x, y), expected, reason: '($x, $y)'); + } + } + expect(red, greaterThan(50)); + }); +}