Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions lib/src/layer/polygon_layer/painter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ class _PolygonPainter<R extends Object> extends CustomPainter
void paint(Canvas canvas, Size size) {
super.paint(canvas, size);

final nearClip = NearClip.of(camera);
List<Offset> inFront(List<Offset> ring) => nearClip?.ring(ring) ?? ring;

final trianglePoints = <Offset>[];

Path filledPath = Path();
Expand Down Expand Up @@ -328,16 +331,17 @@ class _PolygonPainter<R extends Object> extends CustomPainter
return WorldWorkControl.invisible;
}

unfillPolygon(fillOffsets);
unfillPolygon(inFront(fillOffsets));

if (_invertedHoles) {
for (final singleHolePoints in projectedPolygon.holePoints) {
final (holeOffsets, _) = _helper.getOffsetsXY(
points: singleHolePoints,
shift: shift,
);
unfillPolygon(holeOffsets);
invertFillPolygonHole(holeOffsets);
final holeInFront = inFront(holeOffsets);
unfillPolygon(holeInFront);
invertFillPolygonHole(holeInFront);
}
}
return WorldWorkControl.visible;
Expand Down Expand Up @@ -369,7 +373,7 @@ class _PolygonPainter<R extends Object> 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,
Expand All @@ -378,6 +382,15 @@ class _PolygonPainter<R extends Object> 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(
Expand Down Expand Up @@ -415,7 +428,7 @@ class _PolygonPainter<R extends Object> 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]]);
Expand All @@ -435,12 +448,12 @@ class _PolygonPainter<R extends Object> extends CustomPainter
);

if (borderPaint != null) {
addBorderToPath(_helper
addBorderToPath(inFront(_helper
.getOffsetsXY(
points: projectedPolygon.points,
shift: shift,
)
.$1);
.$1));
}

// Afterwards deal with more complicated holes.
Expand All @@ -453,9 +466,11 @@ class _PolygonPainter<R extends Object> 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);
}
}

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 @@ -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';
Expand Down
70 changes: 70 additions & 0 deletions lib/src/layer/shared/near_clip.dart
Original file line number Diff line number Diff line change
@@ -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<Offset> 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<Offset> ring(List<Offset> ring) {
if (contains(ring)) return ring;
final result = <Offset>[];
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;
}
}
178 changes: 178 additions & 0 deletions test/layer/behind_camera_test.dart
Original file line number Diff line number Diff line change
@@ -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<MapController> _pump(
WidgetTester tester, {
required List<Widget> 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<Color Function(int x, int y)> _render(WidgetTester tester) async {
await tester.pumpAndSettle();
final boundary = tester.renderObject<RenderRepaintBoundary>(
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));
});
}
Loading