Skip to content

Commit 1bb9188

Browse files
committed
Implement polygonTangent
1 parent dae97ec commit 1bb9188

20 files changed

+2070
-0
lines changed

lib/polygon_tangents.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
library turf_polygon_tangents;
2+
3+
export 'package:geotypes/geotypes.dart';
4+
export 'src/polygon_tangents.dart';

lib/src/polygon_tangents.dart

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import 'package:turf/turf.dart';
2+
import 'package:turf/bbox.dart' as b;
3+
import 'package:turf/nearest_point.dart' as np;
4+
5+
/// Finds the tangents of a [Polygon] or [MultiPolygon] from a [Point].
6+
///
7+
/// This function calculates the two tangent points on the boundary of the given
8+
/// polygon (or multipolygon) starting from the external [point]. If the point
9+
/// lies within the polygon's bounding box, the nearest vertex is used as a
10+
/// reference to determine the tangents.
11+
///
12+
/// Returns a [FeatureCollection] containing two [Point] features:
13+
/// - The right tangent point.
14+
/// - The left tangent point.
15+
///
16+
/// Example:
17+
///
18+
/// ```dart
19+
/// // Create a polygon
20+
/// final polygon = Feature<Polygon>(
21+
/// geometry: Polygon(coordinates: [
22+
/// [
23+
/// Position.of([11, 0]),
24+
/// Position.of([22, 4]),
25+
/// Position.of([31, 0]),
26+
/// Position.of([31, 11]),
27+
/// Position.of([21, 15]),
28+
/// Position.of([11, 11]),
29+
/// Position.of([11, 0])
30+
/// ]
31+
/// ]),
32+
/// properties: {},
33+
/// );
34+
///
35+
/// // Create a point
36+
/// final point = Point(coordinates: Position.of([61, 5]));
37+
///
38+
/// // Calculate tangents
39+
/// final tangents = polygonTangents(point, polygon);
40+
///
41+
/// // The FeatureCollection 'tangents' now contains the two tangent points.
42+
/// ```
43+
44+
FeatureCollection<Point> polygonTangents(Point point, GeoJSONObject inputPolys) {
45+
46+
if (inputPolys is! Feature<Polygon> && inputPolys is! Feature<MultiPolygon>) {
47+
throw Exception("Input must be a Polygon or MultiPolygon feature.");
48+
}
49+
50+
final pointCoords = getCoord(point);
51+
final polyCoords = getCoords(inputPolys);
52+
53+
Position rtan = Position.of([0, 0]);
54+
Position ltan = Position.of([0, 0]);
55+
double eprev = 0;
56+
final bbox = b.bbox(inputPolys);
57+
int nearestPtIndex = 0;
58+
Feature<Point>? nearest;
59+
60+
// If the external point lies within the polygon's bounding box, find the nearest vertex.
61+
if (pointCoords[0]! > bbox[0]! &&
62+
pointCoords[0]! < bbox[2]! &&
63+
pointCoords[1]! > bbox[1]! &&
64+
pointCoords[1]! < bbox[3]!) {
65+
final nearestFeature =
66+
np.nearestPoint(Feature<Point>(geometry: point), explode(inputPolys));
67+
nearest = nearestFeature;
68+
nearestPtIndex = nearest.properties!['featureIndex'] as int;
69+
}
70+
71+
geomEach(inputPolys, (GeometryType? geom, featureIndex, featureProperties,
72+
featureBBox, featureId) {
73+
switch (geom?.type) {
74+
case GeoJSONObjectType.polygon:
75+
rtan = polyCoords[0][nearestPtIndex];
76+
ltan = polyCoords[0][0];
77+
if (nearest != null) {
78+
if (nearest.geometry!.coordinates[1]! < pointCoords[1]!) {
79+
ltan = polyCoords[0][nearestPtIndex];
80+
}
81+
}
82+
eprev = isLeft(
83+
polyCoords[0][0],
84+
polyCoords[0][polyCoords[0].length - 1],
85+
pointCoords,
86+
).toDouble();
87+
final processed = processPolygon(
88+
polyCoords[0],
89+
pointCoords,
90+
eprev,
91+
rtan,
92+
ltan,
93+
);
94+
rtan = processed[0];
95+
ltan = processed[1];
96+
break;
97+
case GeoJSONObjectType.multiPolygon:
98+
var closestFeature = 0;
99+
var closestVertex = 0;
100+
var verticesCounted = 0;
101+
for (int i = 0; i < polyCoords[0].length; i++) {
102+
closestFeature = i;
103+
var verticeFound = false;
104+
for (var j = 0; j < polyCoords[0][i].length; j++) {
105+
closestVertex = j;
106+
if (verticesCounted == nearestPtIndex) {
107+
verticeFound = true;
108+
break;
109+
}
110+
verticesCounted++;
111+
}
112+
if (verticeFound) break;
113+
}
114+
rtan = polyCoords[0][closestFeature][closestVertex];
115+
ltan = polyCoords[0][closestFeature][closestVertex];
116+
eprev = isLeft(
117+
polyCoords[0][0][0],
118+
polyCoords[0][0][polyCoords[0][0].length - 1],
119+
pointCoords,
120+
).toDouble();
121+
polyCoords[0].forEach((polygon) {
122+
final processed = processPolygon(
123+
polygon,
124+
pointCoords,
125+
eprev,
126+
rtan,
127+
ltan,
128+
);
129+
rtan = processed[0];
130+
ltan = processed[1];
131+
});
132+
break;
133+
default:
134+
throw Exception("Unsupported geometry type: ${geom?.type}");
135+
}
136+
});
137+
138+
return FeatureCollection(features: [
139+
Feature<Point>(geometry: Point(coordinates: rtan)),
140+
Feature<Point>(geometry: Point(coordinates: ltan)),
141+
]);
142+
}
143+
144+
/// Processes a polygon to determine the right and left tangents.
145+
List<Position> processPolygon(List<Position> polygonCoords,
146+
Position pointCoords, double eprev, Position rtan, Position ltan) {
147+
for (int i = 0; i < polygonCoords.length; i++) {
148+
final currentCoords = polygonCoords[i];
149+
var nextCoords = polygonCoords[(i + 1) % polygonCoords.length];
150+
final enext = isLeft(currentCoords, nextCoords, pointCoords);
151+
if (eprev <= 0 && enext > 0) {
152+
if (!isBelow(pointCoords, currentCoords, rtan)) {
153+
rtan = currentCoords;
154+
}
155+
} else if (eprev > 0 && enext <= 0) {
156+
if (!isAbove(pointCoords, currentCoords, ltan)) {
157+
ltan = currentCoords;
158+
}
159+
} else if (eprev > 0 && enext <= 0) {
160+
if (!isAbove(pointCoords, currentCoords, ltan)) {
161+
ltan = currentCoords;
162+
}
163+
}
164+
eprev = enext.toDouble();
165+
}
166+
return [rtan, ltan];
167+
}
168+
169+
/// Returns a positive value if [p3] is to the left of the line from [p1] to [p2],
170+
/// negative if to the right, and 0 if collinear.
171+
num isLeft(Position p1, Position p2, Position p3) {
172+
return ((p2[0]! - p1[0]!) * (p3[1]! - p1[1]!) -
173+
(p3[0]! - p1[0]!) * (p2[1]! - p1[1]!));
174+
}
175+
176+
/// Returns true if [p3] is above the line from [p1] to [p2].
177+
bool isAbove(Position p1, Position p2, Position p3) {
178+
return isLeft(p1, p2, p3) > 0;
179+
}
180+
181+
/// Returns true if [p3] is below the line from [p1] to [p2].
182+
bool isBelow(Position p1, Position p2, Position p3) {
183+
return isLeft(p1, p2, p3) < 0;
184+
}

lib/turf.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export 'nearest_point_on_line.dart';
3232
export 'nearest_point.dart';
3333
export 'point_to_line_distance.dart';
3434
export 'polygon_smooth.dart';
35+
export 'polygon_tangents.dart';
3536
export 'polygon_to_line.dart';
3637
export 'polygonize.dart';
3738
export 'polyline.dart';
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
import 'package:test/test.dart';
4+
import 'package:turf/turf.dart';
5+
import '../context/helper.dart';
6+
7+
void main() {
8+
group('Polygon Tangents', () {
9+
// Unit tests for specific scenarios
10+
test('Calculates Tangents for Valid Geometries', () {
11+
final pt = point([61, 5]);
12+
final poly = polygon([
13+
[
14+
[11, 0],
15+
[22, 4],
16+
[31, 0],
17+
[31, 11],
18+
[21, 15],
19+
[11, 11],
20+
[11, 0],
21+
],
22+
]);
23+
24+
final result = polygonTangents(pt.geometry!, poly);
25+
26+
expect(result, isNotNull);
27+
expect(result.features.length, equals(2));
28+
});
29+
30+
test('Ensures Input Immutability', () {
31+
final pt = point([61, 5]);
32+
final poly = polygon([
33+
[
34+
[11, 0],
35+
[22, 4],
36+
[31, 0],
37+
[31, 11],
38+
[21, 15],
39+
[11, 11],
40+
[11, 0],
41+
],
42+
]);
43+
44+
final beforePoly = jsonEncode(poly.toJson());
45+
final beforePt = jsonEncode(pt.toJson());
46+
47+
polygonTangents(pt.geometry!, poly);
48+
49+
expect(jsonEncode(poly.toJson()), equals(beforePoly),
50+
reason: 'poly should not mutate');
51+
expect(jsonEncode(pt.toJson()), equals(beforePt),
52+
reason: 'pt should not mutate');
53+
});
54+
55+
test('Detailed Polygon', () {
56+
final coordinates = Position.of([8.725, 51.57]);
57+
final pt = Feature<Point>(
58+
geometry: Point(coordinates: coordinates),
59+
properties: {},
60+
);
61+
62+
final poly = polygon([
63+
[
64+
[8.788482103824089, 51.56063487730164],
65+
[8.788583, 51.561554],
66+
[8.78839, 51.562241],
67+
[8.78705, 51.563616],
68+
[8.785483, 51.564445],
69+
[8.785481, 51.564446],
70+
[8.785479, 51.564447],
71+
[8.785479, 51.564449],
72+
[8.785478, 51.56445],
73+
[8.785478, 51.564452],
74+
[8.785479, 51.564454],
75+
[8.78548, 51.564455],
76+
[8.785482, 51.564457],
77+
[8.786358, 51.565053],
78+
[8.787022, 51.565767],
79+
[8.787024, 51.565768],
80+
[8.787026, 51.565769],
81+
[8.787028, 51.56577],
82+
[8.787031, 51.565771],
83+
[8.787033, 51.565771],
84+
[8.789951649580397, 51.56585502173034],
85+
[8.789734, 51.563604],
86+
[8.788482103824089, 51.56063487730164],
87+
],
88+
]);
89+
90+
try {
91+
final result = polygonTangents(pt.geometry!, poly);
92+
expect(result, isNotNull);
93+
} catch (e) {
94+
print('Detailed Polygon test failed: $e');
95+
fail('Test should not throw an exception');
96+
}
97+
});
98+
99+
// File-based tests for real-world scenarios
100+
group('File-based Real-world Scenario Tests', () {
101+
var inDir = Directory('./test/examples/polygonTangents/in');
102+
for (var file in inDir.listSync(recursive: false)) {
103+
if (file is File && file.path.endsWith('.geojson')) {
104+
test(file.path, () {
105+
final inSource = file.readAsStringSync();
106+
final collection = FeatureCollection.fromJson(jsonDecode(inSource));
107+
108+
final rawPoly = collection.features[0];
109+
final rawPt = collection.features[1];
110+
111+
late Feature polyFeature;
112+
// Handle Polygon or MultiPolygon
113+
if (rawPoly.geometry?.type == GeoJSONObjectType.multiPolygon) {
114+
polyFeature = Feature<MultiPolygon>.fromJson(rawPoly.toJson());
115+
} else if (rawPoly.geometry?.type == GeoJSONObjectType.polygon) {
116+
polyFeature = Feature<Polygon>.fromJson(rawPoly.toJson());
117+
} else {
118+
throw ArgumentError(
119+
'Unsupported geometry type: ${rawPoly.geometry?.type}');
120+
}
121+
122+
final ptFeature = Feature<Point>.fromJson(rawPt.toJson());
123+
final FeatureCollection results = FeatureCollection(
124+
features: [
125+
...polygonTangents(ptFeature.geometry!, polyFeature).features,
126+
polyFeature,
127+
ptFeature,
128+
],
129+
);
130+
131+
// Prepare output path
132+
var outPath = file.path.replaceAll('/in', '/out');
133+
var outFile = File(outPath);
134+
if (!outFile.existsSync()) {
135+
print('Warning: Output file not found at $outPath');
136+
return;
137+
}
138+
139+
// Regenerate output if REGEN environment variable is set
140+
if (Platform.environment.containsKey('REGEN')) {
141+
outFile.writeAsStringSync(jsonEncode(results.toJson()));
142+
}
143+
144+
if (!outFile.existsSync()) {
145+
print('Warning: Output file not found at $outPath');
146+
return;
147+
} else {
148+
var outSource = outFile.readAsStringSync();
149+
var expected = jsonDecode(outSource);
150+
151+
expect(results.toJson(), equals(expected),
152+
reason: 'Result should match expected output');
153+
}
154+
});
155+
}
156+
}
157+
});
158+
});
159+
}

0 commit comments

Comments
 (0)