feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+125
View File
@@ -0,0 +1,125 @@
package geodata
import (
"math"
"testing"
)
func benchmarkRingGrid(count, points int, centerLatitude, centerLongitude, latitudeStep float64) [][]GeoPoint {
rings := make([][]GeoPoint, count)
for index := range rings {
rings[index] = SphericalCircle(GeoPoint{
Longitude: centerLongitude + 1.2*float64(index%10),
Latitude: centerLatitude + latitudeStep*float64(index/10),
}, 2, points)
}
return rings
}
func BenchmarkUnionPolygons100x64Lat0(b *testing.B) {
rings := benchmarkRingGrid(100, 64, 0, 0, 1.2)
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
if _, err := UnionPolygons(rings); err != nil {
b.Fatalf("UnionPolygons: %v", err)
}
}
}
func BenchmarkUnionPolygons100x64Lat80(b *testing.B) {
rings := benchmarkRingGrid(100, 64, 80, 0, 1.2)
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
if _, err := UnionPolygons(rings); err != nil {
b.Fatalf("UnionPolygons: %v", err)
}
}
}
func BenchmarkUnionPolygons100x64Lat80Cap(b *testing.B) {
rings := benchmarkRingGrid(99, 64, 78, 0, 1)
rings = append(rings, SphericalCircle(GeoPoint{Longitude: 0, Latitude: 87}, 5, 64))
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
if _, err := UnionPolygons(rings); err != nil {
b.Fatalf("UnionPolygons: %v", err)
}
}
}
func BenchmarkSweepBridgeTouchingPolygons120x128(b *testing.B) {
rings := make([][]GeoPoint, 0, 120)
for row := 0; row < 10; row++ {
for col := 0; col < 12; col++ {
rings = append(rings, SphericalCircle(GeoPoint{
Longitude: 15 * float64(col),
Latitude: 10 + 5*float64(row),
}, 0.5, 128))
}
}
// 最近的一对相距约 32 米:大于 0.01 公里的桥接门限,且排在扫描顺序最后。
rings[119] = SphericalCircle(GeoPoint{Longitude: 150.0005, Latitude: 55}, 0.5, 128)
for iteration := 0; iteration < b.N; iteration++ {
input := make([][]GeoPoint, len(rings))
for index, ring := range rings {
input[index] = append([]GeoPoint(nil), ring...)
}
result, err := sweepBridgeTouchingPolygons(input)
if err != nil {
b.Fatalf("sweepBridgeTouchingPolygons: %v", err)
}
if len(result) != len(input) {
b.Fatalf("ring count=%d, want %d", len(result), len(input))
}
}
}
func benchmarkCycleGridLines(size int) [][]GeoPoint {
lines := make([][]GeoPoint, 0, 2*size*(size-1))
for row := 0; row < size; row++ {
for col := 0; col < size; col++ {
x, y := float64(col), float64(row)
if col < size-1 {
lines = append(lines, []GeoPoint{{x, y}, {x + 1, y}})
}
if row < size-1 {
lines = append(lines, []GeoPoint{{x, y}, {x, y + 1}})
}
}
}
return lines
}
func benchmarkWheelLines(spokes int) [][]GeoPoint {
lines := make([][]GeoPoint, 0, 2*spokes)
ring := make([]GeoPoint, spokes)
for index := range ring {
angle := 2 * math.Pi * float64(index) / float64(spokes)
ring[index] = GeoPoint{Longitude: 10 * math.Cos(angle), Latitude: 10 * math.Sin(angle)}
}
for index := range ring {
lines = append(lines, []GeoPoint{{Longitude: 0, Latitude: 0}, ring[index]})
lines = append(lines, []GeoPoint{ring[index], ring[(index+1)%spokes]})
}
return lines
}
func BenchmarkEnumerateVisibleLineworkCyclesWheel(b *testing.B) {
lines := benchmarkWheelLines(720)
chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}}
nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1)
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
enumerateVisibleLineworkCycles(nodes, edges)
}
}
func BenchmarkEnumerateVisibleLineworkCycles30(b *testing.B) {
lines := benchmarkCycleGridLines(30)
chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}}
nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1)
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
enumerateVisibleLineworkCycles(nodes, edges)
}
}
File diff suppressed because it is too large Load Diff
+354
View File
@@ -0,0 +1,354 @@
package geodata
import (
"math"
"testing"
)
func TestVisibleLineworkFacesSeparateTouchingLoops(t *testing.T) {
lines := [][]GeoPoint{
{{0, 0}, {-2, 0}, {-2, 2}, {0, 2}, {0, 0}},
{{0, 0}, {2, 0}, {2, -2}, {0, -2}, {0, 0}},
{{0, 0}, {1, 1}},
}
chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}}
nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1)
cycles := enumerateVisibleLineworkCycles(nodes, edges)
if len(cycles) != 2 {
t.Fatalf("got %d cycles, want two lobes without the dangling bridge", len(cycles))
}
for _, ring := range cycles {
if len(ring) != 4 || math.Abs(math.Abs(visibleLineworkSignedArea(ring))-4) > 1e-10 {
t.Fatalf("face contains a repeated articulation or bridge: %+v", ring)
}
}
}
func TestVisibleLineworkFaceTraversalIsBounded(t *testing.T) {
var lines [][]GeoPoint
for row := 0; row < 12; row++ {
for col := 0; col < 12; col++ {
x, y := float64(col), float64(row)
if col < 11 {
lines = append(lines, []GeoPoint{{x, y}, {x + 1, y}})
}
if row < 11 {
lines = append(lines, []GeoPoint{{x, y}, {x, y + 1}})
}
}
}
chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}}
nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1)
cycles := enumerateVisibleLineworkCycles(nodes, edges)
if len(cycles) != 122 {
t.Fatalf("got %d cycles, want 121 cells and one exterior", len(cycles))
}
}
func TestVisibleLineworkKeepsDistinctClosedJunctions(t *testing.T) {
var lines [][]GeoPoint
for _, longitude := range []float64{0, 1.001} {
ring := []GeoPoint{{longitude, 0}, {longitude + 1, 0}, {longitude + 1, 1}, {longitude, 1}}
for i, point := range ring {
lines = append(lines, []GeoPoint{point, ring[(i+1)%len(ring)]})
}
}
chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}}
nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 25)
if len(nodes) != 8 || len(edges) != 8 {
t.Fatalf("independent closed junctions merged: nodes=%d edges=%d", len(nodes), len(edges))
}
}
func TestVisibleLineworkProjectionPreservesSphericalArc(t *testing.T) {
chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}}
start, end := GeoPoint{-20, 60}, GeoPoint{20, 60}
line := projectVisibleLine(chart, []GeoPoint{start, end})
for i := 0; i <= 100; i++ {
point := chart.project(sphericalInterpolate(start, end, float64(i)/100))
minimum := math.Inf(1)
for j := 1; j < len(line); j++ {
minimum = math.Min(minimum, visibleLineworkPointSegmentDistanceSquared(point, line[j-1], line[j]))
}
if minimum > 0.003*0.003 {
t.Fatalf("projected spherical arc leaves line by %.6f degrees", math.Sqrt(minimum))
}
}
}
func TestVisibleLineworkPolygonsSelectsFilledFaces(t *testing.T) {
lines := [][]GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}},
{{Longitude: 4, Latitude: 0}, {Longitude: 4, Latitude: 4}},
{{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}},
{{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0}},
{{Longitude: 2, Latitude: 0}, {Longitude: 2, Latitude: 4}},
}
fill := [][]GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: 0, Latitude: 4},
}}
polygons, err := VisibleLineworkPolygons(lines, fill, fill, 1)
if err != nil {
t.Fatalf("VisibleLineworkPolygons: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polygon count=%d, want the two selected faces merged", len(polygons))
}
for _, point := range []GeoPoint{{Longitude: 1, Latitude: 2}, {Longitude: 3, Latitude: 2}} {
if !sphericalPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("merged visible linework misses %+v", point)
}
}
}
func TestVisibleLineworkBoundaryBridgePreservesReverseDirection(t *testing.T) {
line := []GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 1, Latitude: 0},
{Longitude: 2, Latitude: 0},
{Longitude: 3, Latitude: 0},
}
start, end := line[len(line)-1], line[0]
bridge, ok := visibleLineworkBoundaryBridge(start, end, [][]GeoPoint{line}, 120)
if !ok {
t.Fatal("visibleLineworkBoundaryBridge() returned no bridge")
}
if !SameGeoPoint(bridge[0], start) || !SameGeoPoint(bridge[len(bridge)-1], end) {
t.Fatalf("bridge endpoints=%+v -> %+v, want %+v -> %+v", bridge[0], bridge[len(bridge)-1], start, end)
}
if len(bridge) != len(line) {
t.Fatalf("bridge points=%d, want %d", len(bridge), len(line))
}
for index, point := range bridge {
if !SameGeoPoint(point, line[len(line)-1-index]) {
t.Fatalf("bridge[%d]=%+v, want reverse source point %+v", index, point, line[len(line)-1-index])
}
}
}
func TestVisibleLineworkPolygonsSnapsNearbyJunctions(t *testing.T) {
lines := [][]GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}},
{{Longitude: 4.001, Latitude: 0}, {Longitude: 4, Latitude: 4}},
{{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}},
{{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0.001}},
}
fill := [][]GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: 0, Latitude: 4},
}}
if _, err := VisibleLineworkPolygons(lines, fill, fill, 1); err != nil {
t.Fatalf("VisibleLineworkPolygons did not snap sub-kilometer junctions: %v", err)
}
}
func TestVisibleLineworkPolygonsKeepsSnapAndFillAuditTolerancesIndependent(t *testing.T) {
lines := [][]GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}},
{{Longitude: 4, Latitude: 0}, {Longitude: 4, Latitude: 4}},
{{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}},
{{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0}},
}
fill := [][]GeoPoint{{
{Longitude: -0.02, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: -0.02, Latitude: 4},
}}
coverage := [][]GeoPoint{{{Longitude: 2, Latitude: 2}}}
if _, err := VisibleLineworkPolygons(lines, fill, coverage, 1); err == nil {
t.Fatal("default fill audit unexpectedly accepted a 2 km source residual")
}
polygons, err := VisibleLineworkPolygonsWithAuditTolerance(lines, fill, coverage, 1, 3)
if err != nil {
t.Fatalf("independent fill audit rejected a bounded source residual: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polygon count=%d, want one without increasing graph snap distance", len(polygons))
}
}
func TestVisibleLineworkPolygonIndexMatchesPointInPolygon(t *testing.T) {
polygon := []GeoPoint{
{Longitude: -2, Latitude: -2},
{Longitude: 2, Latitude: -2},
{Longitude: 2, Latitude: 2},
{Longitude: -2, Latitude: 2},
}
index := newVisibleLineworkPolygonIndex(polygon)
for longitude := -3.0; longitude <= 3; longitude += 0.25 {
for latitude := -3.0; latitude <= 3; latitude += 0.25 {
point := GeoPoint{Longitude: longitude, Latitude: latitude}
want := visibleLineworkContainsWithin(polygon, point, 0.05)
got := visibleLineworkContainsWithinIndexed(&index, point, 0.05)
if got != want {
t.Fatalf("indexed containment for %+v=%v, want %v", point, got, want)
}
}
}
}
func TestSplitVisibleLineworkIntersectionsCreatesSharedGraphNode(t *testing.T) {
lines := [][]GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 10, Latitude: 10}},
{{Longitude: 0, Latitude: 10}, {Longitude: 10, Latitude: 0}},
}
split := splitVisibleLineworkIntersections(lines)
if len(split) != 4 {
t.Fatalf("split line count=%d, want four half-edges: %#v", len(split), split)
}
junctionCount := 0
for _, line := range split {
for _, point := range []GeoPoint{line[0], line[len(line)-1]} {
if math.Abs(point.Longitude-5) <= 1e-9 && math.Abs(point.Latitude-5) <= 1e-9 {
junctionCount++
}
}
}
if junctionCount != 4 {
t.Fatalf("intersection endpoint count=%d, want four", junctionCount)
}
}
func TestVisibleLineworkArcIndexMatchesSourceEdgeDistance(t *testing.T) {
line := []GeoPoint{
{Longitude: 179, Latitude: 70},
{Longitude: -179, Latitude: 71},
}
index := newVisibleLineworkArcIndex([][]GeoPoint{line})
query := sphericalInterpolate(line[0], line[1], 0.5)
query.Latitude += 0.05
want := sphericalPointArcDistanceKM(query, line[0], line[1])
for _, endpoints := range [][2]GeoPoint{
{line[0], line[1]},
{line[1], line[0]},
{
{Longitude: line[0].Longitude + 2e-9, Latitude: line[0].Latitude},
{Longitude: line[1].Longitude, Latitude: line[1].Latitude - 2e-9},
},
} {
got, matched := index.edgePointDistanceKM(endpoints[0], endpoints[1], query)
if !matched {
t.Fatalf("indexed source edge was not matched: %+v", endpoints)
}
if math.Abs(got-want) > 1e-6 {
t.Fatalf("indexed edge distance=%.12f km, want %.12f km", got, want)
}
}
if _, matched := index.edgePointDistanceKM(
line[0], GeoPoint{Longitude: -178, Latitude: 71}, query,
); matched {
t.Fatal("non-source edge unexpectedly matched the source index")
}
}
func TestVisibleLineworkBoundarySourceMissDistanceDoesNotHideLongChord(t *testing.T) {
boundaryLines := [][]GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 0.5, Latitude: 0}},
{{Longitude: 1.5, Latitude: 0}, {Longitude: 2, Latitude: 0}},
}
// Both endpoints are source vertices, but the connecting edge is not a
// source arc. Its midpoint is about 55 km from either source segment.
polygons := [][]GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 2, Latitude: 0},
}}
if miss := visibleLineworkBoundarySourceMissDistanceKM(polygons, boundaryLines); miss < 50 {
t.Fatalf("source miss distance=%.1f km, want midpoint deviation to be retained", miss)
}
}
func TestVisibleLineworkNodesTJunctionEndpoint(t *testing.T) {
// 端点落在另一段内部(T 型交点):参数解会被 1e-9 的分数容差拒绝,而图幅旋转
// 带来的误差约 2.4e-6 度,于是两个面会被并成一个。这里用一个 8e-7 度(约 9 厘米)
// 的偏离复现该量级。
// An endpoint on another segment's interior: the parametric solve rejects it with its
// 1e-9 fraction tolerance while chart rotation carries about 2.4e-6 degrees of error, so
// the two faces used to merge. The offset below (8e-7 degrees, about 9 cm) reproduces
// that magnitude.
horizontal := []GeoPoint{{Longitude: -1, Latitude: 0}, {Longitude: 1, Latitude: 0}}
// 端点离水平段 8e-7 度(约 9 厘米):参数解把它算成 −8e-7 的分数,被 1e-9 容差拒绝。
// The endpoint sits 8e-7 degrees (about 9 cm) off the horizontal line, which the
// parametric solve turns into a fraction of -8e-7 and rejects against its 1e-9
// tolerance.
vertical := []GeoPoint{{Longitude: 8e-7, Latitude: 8e-7}, {Longitude: 8e-7, Latitude: 1}}
// 水平段在 T 点被切成两段,竖直段保持一条 ⇒ 2 条线变 3 条线。
// The horizontal line is cut at the T point while the vertical one stays whole, so two
// input lines become three.
split := splitVisibleLineworkIntersections([][]GeoPoint{horizontal, vertical})
if len(split) != 3 {
t.Fatalf("lines=%d (%v), want the horizontal line split at the T junction", len(split), split)
}
outerEnds := 0
for _, line := range split {
if len(line) != 2 {
continue
}
for _, point := range line {
// 水平段被切成两半后,每半各保留一个 lon=±1、lat=0 的外端。
// After the cut each half keeps exactly one outer end at lon=±1, lat=0.
if math.Abs(math.Abs(point.Longitude)-1) < 1e-12 && math.Abs(point.Latitude) < 1e-12 {
outerEnds++
}
}
}
if outerEnds != 2 {
t.Fatalf("horizontal halves keeping an outer end = %d, want 2 (%v)", outerEnds, split)
}
// 真分离(起点离水平段约 1.1 公里)不得被打成节点。
// A genuine separation (the endpoint sits about 1.1 km off the horizontal line) must
// not be noded.
far := []GeoPoint{{Longitude: 0.01, Latitude: 0.01}, {Longitude: 0.01, Latitude: 1}}
splitFar := splitVisibleLineworkIntersections([][]GeoPoint{horizontal, far})
if len(splitFar) != 2 {
t.Fatalf("a 1 km separation was noded: %v", splitFar)
}
}
func TestVisibleLineworkPolygonsRejectsInfiniteTolerances(t *testing.T) {
lines := [][]GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}},
{{Longitude: 4, Latitude: 0}, {Longitude: 4, Latitude: 4}},
{{Longitude: 4, Latitude: 4}, {Longitude: 0, Latitude: 4}},
{{Longitude: 0, Latitude: 4}, {Longitude: 0, Latitude: 0}},
}
fill := [][]GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: 0, Latitude: 4},
}}
if _, err := VisibleLineworkPolygons(lines, fill, fill, math.Inf(1)); err == nil {
t.Fatal("infinite snap distance was accepted")
}
if _, err := VisibleLineworkPolygonsWithAuditTolerance(lines, fill, fill, 1, math.Inf(1)); err == nil {
t.Fatal("infinite fill audit tolerance was accepted")
}
}
func TestVisibleLineworkContainsWithinIndexedHandlesNilIndex(t *testing.T) {
if visibleLineworkContainsWithinIndexed(nil, GeoPoint{Longitude: 1, Latitude: 1}, 0.5) {
t.Fatal("nil index reported containment")
}
}
func TestVisibleLineworkCyclesDeduplicateReversedFaces(t *testing.T) {
lines := [][]GeoPoint{
{{Longitude: 0, Latitude: 0}, {Longitude: 2, Latitude: 0}},
{{Longitude: 2, Latitude: 0}, {Longitude: 2, Latitude: 2}},
{{Longitude: 2, Latitude: 2}, {Longitude: 0, Latitude: 2}},
{{Longitude: 0, Latitude: 2}, {Longitude: 0, Latitude: 0}},
}
chart := polygonUnionChart{xAxis: geoVector3{x: 1}, yAxis: geoVector3{y: 1}, zAxis: geoVector3{z: 1}}
nodes, edges, _ := buildVisibleLineworkGraph(lines, chart, 1)
if cycles := enumerateVisibleLineworkCycles(nodes, edges); len(cycles) != 1 {
t.Fatalf("got %d cycles, want one square counted once for both directions", len(cycles))
}
}
+16
View File
@@ -9,8 +9,24 @@ const (
ProjectionEquirectangular Projection = "equirectangular"
ProjectionNorthPolar Projection = "north-polar"
ProjectionSouthPolar Projection = "south-polar"
// ProjectionOrthographic 是正射(球面)投影,只画朝向视点的半个地球。
// ProjectionOrthographic is the orthographic (globe) projection, drawing only the hemisphere facing the view point.
ProjectionOrthographic Projection = "orthographic"
)
// ClipView 是裁剪所依赖的投影及其参数 / ClipView carries the projection and the parameters clipping depends on.
type ClipView struct {
Projection Projection
// Center 是正射投影的视点,也就是可见半球的中心;其他投影忽略它。
// Center is the orthographic view point, the middle of the visible hemisphere; other projections ignore it.
Center GeoPoint
}
// Orthographic 判断视图是否为正射投影 / Orthographic reports whether the view uses the orthographic projection.
func (view ClipView) Orthographic() bool {
return view.Projection == ProjectionOrthographic
}
// GeoPoint 是以度表示的地理点,东经为正 / GeoPoint is a geographic point in degrees, with east longitude positive.
type GeoPoint struct {
Longitude float64
+96
View File
@@ -0,0 +1,96 @@
package geodata
import (
"math"
"testing"
)
// 裁剪入口对非有限坐标必须快速失败而不能死循环;超出 ±180 的有限经度按同一子午线归一化。
func TestTopologyRejectsNonFiniteCoordinates(t *testing.T) {
cases := []struct {
name string
points []GeoPoint
}{
{"huge-longitude", []GeoPoint{{0, 0}, {1e300, 10}, {20, 20}}},
{"positive-infinity-longitude", []GeoPoint{{0, 0}, {math.Inf(1), 10}, {20, 20}}},
{"negative-infinity-longitude", []GeoPoint{{0, 0}, {math.Inf(-1), 10}, {20, 20}}},
{"nan-longitude", []GeoPoint{{0, 0}, {math.NaN(), 10}, {20, 20}}},
{"nan-latitude", []GeoPoint{{0, 0}, {10, math.NaN()}, {20, 20}}},
{"infinity-latitude", []GeoPoint{{0, 0}, {10, math.Inf(1)}, {20, 20}}},
}
views := []ClipView{
{},
{Projection: ProjectionEquirectangular},
{Projection: ProjectionEquirectangular, Center: GeoPoint{Longitude: 100}},
{Projection: ProjectionNorthPolar},
{Projection: ProjectionSouthPolar},
{Projection: ProjectionOrthographic, Center: GeoPoint{Longitude: 104, Latitude: -1.5}},
}
for _, testCase := range cases {
wantsRejection := testCase.name != "huge-longitude"
for _, view := range views {
fragments := PolygonFragments(testCase.points, view)
segments := PolylineSegments(testCase.points, view)
if wantsRejection {
if len(fragments) != 0 || len(segments) != 0 {
t.Fatalf("%s %s: fragments=%d segments=%d, want none",
testCase.name, view.Projection, len(fragments), len(segments))
}
continue
}
checkFinite := func(points []GeoPoint) {
for _, point := range points {
if math.IsNaN(point.Longitude) || math.IsNaN(point.Latitude) ||
math.IsInf(point.Longitude, 0) || math.IsInf(point.Latitude, 0) ||
math.Abs(point.Longitude) > 180 {
t.Fatalf("%s %s: non-finite or unnormalized point %+v", testCase.name, view.Projection, point)
}
}
}
for _, fragment := range fragments {
checkFinite(fragment)
}
for _, segment := range segments {
checkFinite(segment)
}
}
}
}
func TestTopologyNormalizesOutOfRangeLongitudeToSameMeridian(t *testing.T) {
views := []ClipView{
{},
{Projection: ProjectionEquirectangular, Center: GeoPoint{Longitude: 30}},
{Projection: ProjectionNorthPolar},
}
cases := [][2][]GeoPoint{
{
{{0, 0}, {190, 10}, {20, 20}},
{{0, 0}, {-170, 10}, {20, 20}},
},
{
{{350, -5}, {190, 10}, {181, 20}},
{{-10, -5}, {-170, 10}, {-179, 20}},
},
}
for _, pair := range cases {
for _, view := range views {
got := PolygonFragments(pair[0], view)
want := PolygonFragments(pair[1], view)
if len(got) != len(want) {
t.Fatalf("%s: fragment count %d, want %d", view.Projection, len(got), len(want))
}
for index := range got {
if len(got[index]) != len(want[index]) {
t.Fatalf("%s: fragment %d length %d, want %d", view.Projection, index, len(got[index]), len(want[index]))
}
for point := range got[index] {
if got[index][point] != want[index][point] {
t.Fatalf("%s: fragment %d point %d = %+v, want %+v",
view.Projection, index, point, got[index][point], want[index][point])
}
}
}
}
}
}
+239
View File
@@ -0,0 +1,239 @@
package geodata
import "math"
// rad 是度到弧度的换算因子 / rad converts degrees to radians.
const rad = math.Pi / 180
const (
// orthographicRimSteps 是视界闭合弧与整盘回退环的加密段数。
orthographicRimSteps = 180
// orthographicCrossingIterations 是视界交点的二分次数,1e-12 弧度量级足够。
orthographicCrossingIterations = 48
// orthographicRunCapacity 是单个可见段的初始容量:段长与环长无关,
// 按环长预分配会让反复穿越视界的环退化成 O(段数×环长) 的内存。
orthographicRunCapacity = 8
)
// OrthographicDiskPoint 把点正射投影到可见半球的单位圆盘,x 向东、y 向北。
// 第二个返回值是深度余弦;false 表示点落在背面,不与可见半球构成一一映射。
// OrthographicDiskPoint projects a point onto the unit disk of the visible hemisphere, x east and y north.
func OrthographicDiskPoint(point, center GeoPoint) (float64, float64, bool) {
longitude := (point.Longitude - center.Longitude) * rad
latitude := point.Latitude * rad
centerLatitude := center.Latitude * rad
cosLatitude, sinLatitude := math.Cos(latitude), math.Sin(latitude)
sinCenter, cosCenter := math.Sin(centerLatitude), math.Cos(centerLatitude)
cosine := sinCenter*sinLatitude + cosCenter*cosLatitude*math.Cos(longitude)
// 视界本身(余弦为 0)映射到圆盘边界,必须可投影;只有严格背面才折叠到盘内。
if cosine < -1e-9 {
return 0, 0, false
}
return cosLatitude * math.Sin(longitude),
cosCenter*sinLatitude - sinCenter*cosLatitude*math.Cos(longitude), true
}
// orthographicDepth 返回点相对视点的深度余弦,正值表示在可见半球上。
func orthographicDepth(point, center GeoPoint) float64 {
return geoVectorDot(geoPointVector(point), geoPointVector(center))
}
// orthographicCrossing 二分求线段与视界大圆的交点;两端同侧时返回 false。
func orthographicCrossing(first, second GeoPoint, center GeoPoint) (GeoPoint, bool) {
firstDepth := orthographicDepth(first, center)
secondDepth := orthographicDepth(second, center)
if firstDepth == 0 {
return first, true
}
if secondDepth == 0 {
return second, true
}
if (firstDepth > 0) == (secondDepth > 0) {
return GeoPoint{}, false
}
// 收敛到起点那一侧的边界:可见性仍与起点相同就往后挪,翻转了就往前收。
firstVisible := firstDepth > 0
low, high := 0.0, 1.0
for iteration := 0; iteration < orthographicCrossingIterations; iteration++ {
middle := (low + high) / 2
if (orthographicDepth(InterpolateGreatCircle(first, second, middle), center) > 0) == firstVisible {
low = middle
} else {
high = middle
}
}
return InterpolateGreatCircle(first, second, (low+high)/2), true
}
// clipPolylineOrthographic 把折线裁到可见半球,并在视界处插入精确交点。
func clipPolylineOrthographic(points []GeoPoint, center GeoPoint) [][]GeoPoint {
if len(points) == 0 {
return nil
}
segments := make([][]GeoPoint, 0, 2)
current := make([]GeoPoint, 0, len(points))
for index, point := range points {
if orthographicDepth(point, center) > 0 {
if len(current) == 0 && index > 0 {
if crossing, ok := orthographicCrossing(points[index-1], point, center); ok {
current = append(current, crossing)
}
}
current = append(current, point)
continue
}
if len(current) > 0 {
if crossing, ok := orthographicCrossing(points[index-1], point, center); ok {
current = append(current, crossing)
}
if len(current) >= 2 {
segments = append(segments, current)
}
current = nil
}
}
if len(current) >= 2 {
segments = append(segments, current)
}
return segments
}
// orthographicRimArc 沿视界大圆从起点加密到终点;long 为 true 时走另一侧的长弧。
// 视界大圆的法线就是视点方向,必须绕它旋转:两端接近对径时 cross(起点, 终点) 会退化成零向量,
// 那样闭合弧会塌成一条横穿圆盘的直线弦。
func orthographicRimArc(from, to GeoPoint, center GeoPoint, long bool) []GeoPoint {
axis := geoPointVector(center)
startVector, ok := geoVectorNormalize(geoVectorAdd(
geoPointVector(from),
geoVectorScale(axis, -geoVectorDot(geoPointVector(from), axis)),
))
if !ok {
return []GeoPoint{from, to}
}
tangent := geoVectorCross(axis, startVector)
endVector := geoPointVector(to)
signed := math.Atan2(geoVectorDot(endVector, tangent), geoVectorDot(endVector, startVector))
begin, span := 0.0, signed
if long {
turn := 2 * math.Pi
if signed < 0 {
turn = -2 * math.Pi
}
begin, span = signed, turn-signed
}
arc := make([]GeoPoint, 0, orthographicRimSteps+1)
for step := 0; step <= orthographicRimSteps; step++ {
angle := begin + span*float64(step)/orthographicRimSteps
arc = append(arc, geoVectorPoint(geoVectorAdd(
geoVectorScale(startVector, math.Cos(angle)),
geoVectorScale(tangent, math.Sin(angle)),
)))
}
return arc
}
// orthographicRimInside 判断某段视界弧是否紧邻环的内部:把弧中点朝可见半球内侧挪一点再看它落在哪一侧。
func orthographicRimInside(arc []GeoPoint, ring []GeoPoint, center GeoPoint) bool {
if len(arc) == 0 {
return false
}
middle := geoPointVector(arc[len(arc)/2])
inside := geoVectorAdd(middle, geoVectorScale(geoPointVector(center), 1e-3))
probe, ok := geoVectorNormalize(inside)
if !ok {
return false
}
return sphericalPolygonContainsOrTouches(ring, geoVectorPoint(probe))
}
// closeOrthographicRun 把一段可见折线沿视界大圆闭合回起点,闭合弧取紧邻环内部的那一侧。
func closeOrthographicRun(run, ring []GeoPoint, center GeoPoint) []GeoPoint {
if len(run) < 2 {
return nil
}
exit, entry := run[len(run)-1], run[0]
shortArc := orthographicRimArc(exit, entry, center, false)
longArc := orthographicRimArc(exit, entry, center, true)
arc := shortArc
switch {
case orthographicRimInside(shortArc, ring, center):
case orthographicRimInside(longArc, ring, center):
arc = longArc
}
closed := make([]GeoPoint, 0, len(run)+len(arc))
closed = append(closed, run...)
closed = append(closed, arc[1:len(arc)-1]...)
return closed
}
// polygonFragmentsOrthographic 把环裁到可见半球,并沿视界大圆闭合被切断的部分。
func polygonFragmentsOrthographic(points []GeoPoint, center GeoPoint) [][]GeoPoint {
if len(points) < 3 {
return nil
}
visible := 0
for _, point := range points {
if orthographicDepth(point, center) > 0 {
visible++
}
}
if visible == len(points) {
return [][]GeoPoint{points}
}
if visible == 0 {
// 整环都在背面:只有把视点包在环内的环,其内部才会覆盖整个可见半球——否则可见部分为空。
if sphericalPolygonContainsOrTouches(points, center) {
return [][]GeoPoint{SphericalCircle(center, 90, orthographicRimSteps)}
}
return nil
}
// 逐边展开成"顶点 + 视界交点"序列,再按可见性切段;闭合环首尾相接,所以按环遍历。
type rimNode struct {
point GeoPoint
visible bool
}
nodes := make([]rimNode, 0, 2*len(points))
for index := 0; index < len(points); index++ {
first := points[index]
second := points[(index+1)%len(points)]
firstVisible := orthographicDepth(first, center) > 0
secondVisible := orthographicDepth(second, center) > 0
nodes = append(nodes, rimNode{point: first, visible: firstVisible})
if firstVisible != secondVisible {
if crossing, ok := orthographicCrossing(first, second, center); ok {
// 交点落在视界上,两侧的可见段都要以它收尾/起头,所以它恒属于可见段。
nodes = append(nodes, rimNode{point: crossing, visible: true})
}
}
}
runs := make([][]GeoPoint, 0, 4)
current := make([]GeoPoint, 0, orthographicRunCapacity)
for _, node := range nodes {
if node.visible {
current = append(current, node.point)
continue
}
if len(current) >= 2 {
runs = append(runs, current)
}
current = make([]GeoPoint, 0, orthographicRunCapacity)
}
if len(current) >= 2 {
runs = append(runs, current)
}
// 环首尾相接:起点本身可见时,同一段可见区间会被切成首尾两段,必须先接回来再闭合。
if len(runs) >= 2 && nodes[0].visible && nodes[len(nodes)-1].visible {
merged := make([]GeoPoint, 0, len(runs[0])+len(runs[len(runs)-1]))
merged = append(merged, runs[len(runs)-1]...)
merged = append(merged, runs[0]...)
runs[0] = merged
runs = runs[:len(runs)-1]
}
fragments := make([][]GeoPoint, 0, len(runs))
for _, run := range runs {
if closed := closeOrthographicRun(run, points, center); len(closed) >= 3 {
fragments = append(fragments, closed)
}
}
return fragments
}
+207
View File
@@ -0,0 +1,207 @@
package geodata
import (
"math"
"runtime"
"testing"
)
func orthographicTestContainsRing(ring []GeoPoint, point GeoPoint) bool {
return sphericalPolygonContainsOrTouches(ring, point)
}
// 采样点若紧贴环边界,容差内外的判定会摇摆;这类点不参与比对。
func orthographicTestBorderline(ring []GeoPoint, point GeoPoint) bool {
base := orthographicTestContainsRing(ring, point)
for _, bearing := range []float64{0, 90, 180, 270} {
offset := InterpolateGreatCircle(point, GeoPoint{
Longitude: normalizeLongitude(point.Longitude + 0.05*math.Cos(bearing*rad)),
Latitude: point.Latitude + 0.05*math.Sin(bearing*rad),
}, 0.001)
if orthographicTestContainsRing(ring, offset) != base {
return true
}
}
return false
}
func orthographicTestPlanarContains(polygon [][2]float64, x, y float64) bool {
inside := false
for index, current := range polygon {
previous := polygon[(index+len(polygon)-1)%len(polygon)]
if (current[1] > y) != (previous[1] > y) {
span := previous[1] - current[1]
if span != 0 {
if current[0]+(y-current[1])/span*(previous[0]-current[0]) < x {
inside = !inside
}
}
}
}
return inside
}
func orthographicTestFillMatches(t *testing.T, name string, ring []GeoPoint, center GeoPoint) {
t.Helper()
fragments := polygonFragmentsOrthographic(ring, center)
projected := make([][][2]float64, 0, len(fragments))
for _, fragment := range fragments {
polygon := make([][2]float64, 0, len(fragment))
for _, point := range fragment {
x, y, ok := OrthographicDiskPoint(point, center)
if !ok {
t.Fatalf("%s: fragment point %v is on the back hemisphere", name, point)
}
polygon = append(polygon, [2]float64{x, y})
}
projected = append(projected, polygon)
}
checked, mismatches := 0, 0
state := uint64(20260916)
for sample := 0; sample < 4000; sample++ {
state = state*6364136223846793005 + 1442695040888963407
latitude := float64(int64(state>>11)%18000)/100 - 90
state = state*6364136223846793005 + 1442695040888963407
longitude := float64(int64(state>>11)%36000)/100 - 180
point := GeoPoint{Longitude: longitude, Latitude: latitude}
x, y, visible := OrthographicDiskPoint(point, center)
if !visible {
continue
}
// 视界闭合弧是折线,紧贴视界的一薄层(约 1°)落在弦与圆弧之间,判定本就有歧义;
// 在 600 像素的球面图上这一层不足 0.1 像素,不参与比对。
if orthographicDepth(point, center) < 0.02 {
continue
}
if orthographicTestBorderline(ring, point) {
continue
}
expected := orthographicTestContainsRing(ring, point)
got := false
for _, polygon := range projected {
if orthographicTestPlanarContains(polygon, x, y) {
got = !got
}
}
checked++
if expected != got {
mismatches++
if mismatches <= 3 {
t.Errorf("%s: point %.3f,%.3f expected %v got %v", name, longitude, latitude, expected, got)
}
}
}
if checked < 200 {
t.Fatalf("%s: only %d samples usable", name, checked)
}
t.Logf("%s: %d samples, %d mismatches, fragments=%d", name, checked, mismatches, len(fragments))
if mismatches != 0 {
t.Fatalf("%s: %d/%d samples disagree with spherical containment", name, mismatches, checked)
}
}
// 裁剪后的填充必须与"球面包含且位于可见半球"完全一致,这同时验证了视界闭合弧的取侧。
func TestOrthographicPolygonFragmentsFillMatchesContainment(t *testing.T) {
center := GeoPoint{Longitude: 144.1, Latitude: 24.2}
for _, fixture := range []struct {
name string
ring []GeoPoint
}{
{name: "visible", ring: SphericalCircle(GeoPoint{Longitude: 140, Latitude: 30}, 25, 180)},
{name: "straddling", ring: SphericalCircle(GeoPoint{Longitude: 60, Latitude: 40}, 35, 180)},
{name: "behind", ring: SphericalCircle(GeoPoint{Longitude: -40, Latitude: -30}, 20, 180)},
{name: "encircling", ring: SphericalCircle(center, 100, 240)},
{name: "antipodal-cap", ring: SphericalCircle(GeoPoint{Longitude: -35.9, Latitude: -24.2}, 20, 180)},
{name: "limb-hugging", ring: SphericalCircle(GeoPoint{Longitude: 100, Latitude: 60}, 60, 240)},
} {
t.Run(fixture.name, func(t *testing.T) {
orthographicTestFillMatches(t, fixture.name, fixture.ring, center)
})
}
}
// 折线裁剪只保留可见段,且两端恰好落在视界上。
func TestOrthographicPolylineSegmentsEndOnLimb(t *testing.T) {
center := GeoPoint{Longitude: 0, Latitude: 0}
points := []GeoPoint{
{Longitude: -120, Latitude: 10},
{Longitude: 0, Latitude: 0},
{Longitude: 120, Latitude: -10},
}
segments := clipPolylineOrthographic(points, center)
if len(segments) != 1 {
t.Fatalf("expected one visible segment, got %d", len(segments))
}
segment := segments[0]
first, last := segment[0], segment[len(segment)-1]
if depth := math.Abs(orthographicDepth(first, center)); depth > 1e-9 {
t.Fatalf("segment start is not on the limb: depth=%g", depth)
}
if depth := math.Abs(orthographicDepth(last, center)); depth > 1e-9 {
t.Fatalf("segment end is not on the limb: depth=%g", depth)
}
for _, point := range segment {
if orthographicDepth(point, center) < -1e-12 {
t.Fatalf("segment keeps a back-hemisphere point: %v", point)
}
}
}
// 视点自身投影到盘心,与之相距 90° 的点落在盘边。
func TestOrthographicDiskPointReferenceCases(t *testing.T) {
center := GeoPoint{Longitude: 144.1, Latitude: 24.2}
if x, y, ok := OrthographicDiskPoint(center, center); !ok || math.Hypot(x, y) > 1e-12 {
t.Fatalf("center projects to %g,%g ok=%v", x, y, ok)
}
// 视界是与视点相距 90° 的大圆;同纬度加 90° 经度并不等于 90° 球面距离。
centerVector := geoPointVector(center)
axis := geoVector3{x: 0, y: 0, z: 1}
if math.Abs(geoVectorDot(axis, centerVector)) > 0.9 {
axis = geoVector3{x: 1, y: 0, z: 0}
}
east, ok := geoVectorNormalize(geoVectorAdd(axis, geoVectorScale(centerVector, -geoVectorDot(axis, centerVector))))
if !ok {
t.Fatal("degenerate tangent basis")
}
onLimbPoints := []GeoPoint{geoVectorPoint(east), geoVectorPoint(geoVectorScale(east, -1))}
north := geoVectorCross(east, centerVector)
onLimbPoints = append(onLimbPoints, geoVectorPoint(north), geoVectorPoint(geoVectorScale(north, -1)))
for _, onLimb := range onLimbPoints {
x, y, ok := OrthographicDiskPoint(onLimb, center)
if !ok || math.Abs(math.Hypot(x, y)-1) > 1e-9 {
t.Fatalf("%v should sit on the limb, got %g,%g ok=%v", onLimb, x, y, ok)
}
}
if _, _, ok := OrthographicDiskPoint(GeoPoint{Longitude: -35.9, Latitude: -24.2}, center); ok {
t.Fatal("the antipode must not be visible")
}
}
// 反复穿越视界的环会产生大量短可见段;单段预分配若按环长给容量,分配量会退化成 O(段数×环长)。
func TestOrthographicFragmentsBoundAllocationForManyShortRuns(t *testing.T) {
const cycles = 500
ring := make([]GeoPoint, 0, 4*cycles)
for index := 0; index < cycles; index++ {
ring = append(ring,
GeoPoint{Longitude: 80, Latitude: 0},
GeoPoint{Longitude: 80, Latitude: 0.5},
GeoPoint{Longitude: 100, Latitude: 0},
GeoPoint{Longitude: 100, Latitude: 0.5},
)
}
center := GeoPoint{}
if fragments := polygonFragmentsOrthographic(ring, center); len(fragments) < cycles/2 {
t.Fatalf("synthetic ring produced %d fragments, want at least %d", len(fragments), cycles/2)
}
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
fragments := polygonFragmentsOrthographic(ring, center)
runtime.ReadMemStats(&after)
allocated := after.TotalAlloc - before.TotalAlloc
limit := uint64(12 << 20)
if allocated > limit {
t.Fatalf("fragments with %d runs allocated %d bytes, limit %d", len(fragments), allocated, limit)
}
t.Logf("%d runs allocated %d bytes", len(fragments), allocated)
}
+902
View File
@@ -0,0 +1,902 @@
package geodata
import (
"fmt"
"math"
"sort"
)
const polygonUnionEpsilon = 1e-9
const polygonUnionProbeOffset = 1e-7
const polygonUnionSnapGrid = 1e-7
const polygonUnionNodeGrid = 1e-7
const polygonUnionContainmentToleranceKM = 1.0
const polygonUnionLocationBinCount = 64
type polygonUnionPoint struct {
x float64
y float64
}
type polygonUnionRing struct {
points []polygonUnionPoint
edges []polygonUnionSourceEdge
locationEdges []polygonUnionSourceEdge
locationBins [][]polygonUnionSourceEdge
params [][]float64
minX float64
maxX float64
minY float64
maxY float64
}
type polygonUnionSourceEdge struct {
start polygonUnionPoint
end polygonUnionPoint
minX float64
maxX float64
minY float64
maxY float64
}
type polygonUnionSourceEdgeRef struct {
ringIndex int
edgeIndex int
edge polygonUnionSourceEdge
}
type polygonUnionEdge struct {
start polygonUnionPoint
end polygonUnionPoint
}
type polygonUnionNode struct {
x int64
y int64
}
type polygonUnionEdgeKey struct {
start polygonUnionNode
end polygonUnionNode
}
// UnionPolygons 合并相互重叠的地理多边形环,不相交的输入保持分离。
// UnionPolygons merges overlapping geographic polygon rings. Ordinary small
// regions use the fast equirectangular branch; polar or antimeridian regions
// are rotated to a local spherical chart first, so the planar edge splitter
// never sees a coordinate singularity. Disjoint inputs remain disjoint.
func UnionPolygons(polygons [][]GeoPoint) ([][]GeoPoint, error) {
if polygonUnionNeedsSphericalChart(polygons) {
// Preserve the numerically stable legacy result when it already retains
// every input boundary and pole cap. The rotated chart is a recovery
// path for the singular cases; applying it to an ordinary antimeridian
// band can unnecessarily change the displayed equirectangular chord.
// 绕极环在平面图幅里无法闭合,平面并集必然失败,不必先算一遍。
if !polygonUnionWindsAroundPole(polygons) {
if planar, err := unionPolygonsPlanar(polygons); err == nil &&
polygonUnionContainsInputs(planar, polygons) {
return planar, nil
}
}
result, err := unionPolygonsSphericalChart(polygons)
if err == nil {
return result, nil
}
if snapped := snapPolygonUnionInputs(polygons); snapped != nil {
if result, retryErr := unionPolygonsSphericalChart(snapped); retryErr == nil {
return result, nil
}
}
return nil, err
}
result, err := unionPolygonsPlanar(polygons)
if err == nil {
return result, nil
}
if snapped := snapPolygonUnionInputs(polygons); snapped != nil {
if result, retryErr := unionPolygonsPlanar(snapped); retryErr == nil {
return result, nil
}
}
return nil, err
}
// snapPolygonUnionInputs retries a failed boolean join after quantizing input
// vertices to a centimetre-scale angular grid. Intersections generated from
// adjacent temporal footprints can differ by a few nanodegrees; the primary
// union keeps full precision, while this bounded retry only closes that
// numerical seam when the exact join cannot form a ring.
func snapPolygonUnionInputs(polygons [][]GeoPoint) [][]GeoPoint {
if len(polygons) == 0 {
return nil
}
result := make([][]GeoPoint, len(polygons))
changed := false
for polygonIndex, polygon := range polygons {
if len(polygon) < 3 {
return nil
}
result[polygonIndex] = make([]GeoPoint, len(polygon))
for pointIndex, point := range polygon {
longitude := math.Round(point.Longitude/polygonUnionSnapGrid) * polygonUnionSnapGrid
latitude := math.Round(point.Latitude/polygonUnionSnapGrid) * polygonUnionSnapGrid
result[polygonIndex][pointIndex] = GeoPoint{Longitude: longitude, Latitude: latitude}
changed = changed || longitude != point.Longitude || latitude != point.Latitude
}
}
if !changed {
return nil
}
return result
}
func polygonUnionContainsInputs(result, inputs [][]GeoPoint) bool {
if !SphericalPolygonsContainPathsWithinKM(
result, inputs, true, polygonUnionContainmentToleranceKM,
) {
return false
}
for _, input := range inputs {
open := openGeoRing(input)
if !polygonUnionRingWindsAroundPole(open) {
continue
}
for _, pole := range []GeoPoint{{Longitude: 0, Latitude: 90}, {Longitude: 0, Latitude: -90}} {
if sphericalPolygonContainsOrTouches(open, pole) &&
!SphericalPolygonsContainPaths(result, [][]GeoPoint{{pole}}, false) {
return false
}
}
}
return true
}
// polygonUnionWindsAroundPole 报告是否有环绕极点一圈。
func polygonUnionWindsAroundPole(polygons [][]GeoPoint) bool {
for _, polygon := range polygons {
if polygonUnionRingWindsAroundPole(openGeoRing(polygon)) {
return true
}
}
return false
}
// polygonUnionRingWindsAroundPole 用与 polygonUnionRings 相同的展开闭合判定识别绕极环。
func polygonUnionRingWindsAroundPole(ring []GeoPoint) bool {
if len(ring) < 3 {
return false
}
last := ring[0].Longitude
for _, point := range ring[1:] {
last += math.Remainder(point.Longitude-last, 360)
}
closure := last + math.Remainder(ring[0].Longitude-last, 360)
return math.Abs(closure-ring[0].Longitude) > 180
}
func unionPolygonsPlanar(polygons [][]GeoPoint) ([][]GeoPoint, error) {
rings, err := polygonUnionRings(polygons)
if err != nil {
return nil, err
}
polygonUnionAddIntersections(rings)
edges := polygonUnionOuterEdges(rings)
if len(edges) == 0 {
return nil, fmt.Errorf("polygon union has no outer edges")
}
result, err := polygonUnionJoinEdges(edges)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, fmt.Errorf("polygon union has no usable rings")
}
return result, nil
}
type polygonUnionChart struct {
xAxis geoVector3
yAxis geoVector3
zAxis geoVector3
}
func polygonUnionNeedsSphericalChart(polygons [][]GeoPoint) bool {
for _, polygon := range polygons {
if len(polygon) < 3 {
continue
}
open := openGeoRing(polygon)
for index, point := range open {
if math.Abs(point.Latitude) >= 70 {
return true
}
next := open[(index+1)%len(open)]
if math.Abs(next.Longitude-point.Longitude) > 180 {
return true
}
}
}
return false
}
func unionPolygonsSphericalChart(polygons [][]GeoPoint) ([][]GeoPoint, error) {
chart, ok := newPolygonUnionChart(polygons)
if !ok {
return unionPolygonsPlanar(polygons)
}
projected := make([][]GeoPoint, len(polygons))
for polygonIndex, polygon := range polygons {
projected[polygonIndex] = make([]GeoPoint, len(polygon))
for pointIndex, point := range polygon {
projected[polygonIndex][pointIndex] = chart.project(point)
}
}
merged, err := unionPolygonsPlanar(projected)
if err != nil {
return nil, err
}
result := make([][]GeoPoint, len(merged))
for polygonIndex, polygon := range merged {
result[polygonIndex] = make([]GeoPoint, len(polygon))
for pointIndex, point := range polygon {
result[polygonIndex][pointIndex] = chart.unproject(point)
}
}
return result, nil
}
func newPolygonUnionChart(polygons [][]GeoPoint) (polygonUnionChart, bool) {
center := geoVector3{}
var first geoVector3
haveFirst := false
for _, polygon := range polygons {
for _, point := range openGeoRing(polygon) {
vector := geoPointVector(point)
center = geoVectorAdd(center, vector)
if !haveFirst {
first = vector
haveFirst = true
}
}
}
center, ok := geoVectorNormalize(center)
if !ok {
center, ok = geoVectorNormalize(first)
if !ok {
return polygonUnionChart{}, false
}
}
globalNorth := geoVector3{z: 1}
zAxis, ok := geoVectorNormalize(geoVectorAdd(globalNorth, geoVectorScale(center, -geoVectorDot(globalNorth, center))))
if !ok {
zAxis, ok = geoVectorNormalize(geoVectorAdd(geoVector3{x: 1}, geoVectorScale(center, -center.x)))
if !ok {
return polygonUnionChart{}, false
}
}
yAxis, ok := geoVectorNormalize(geoVectorCross(zAxis, center))
if !ok {
return polygonUnionChart{}, false
}
return polygonUnionChart{xAxis: center, yAxis: yAxis, zAxis: zAxis}, true
}
func (chart polygonUnionChart) project(point GeoPoint) GeoPoint {
vector := geoPointVector(point)
return GeoPoint{
Longitude: math.Atan2(geoVectorDot(vector, chart.yAxis), geoVectorDot(vector, chart.xAxis)) * 180 / math.Pi,
Latitude: math.Asin(math.Max(-1, math.Min(1, geoVectorDot(vector, chart.zAxis)))) * 180 / math.Pi,
}
}
func (chart polygonUnionChart) unproject(point GeoPoint) GeoPoint {
latitude := point.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
cosLatitude := math.Cos(latitude)
vector := geoVectorAdd(
geoVectorScale(chart.xAxis, cosLatitude*math.Cos(longitude)),
geoVectorAdd(
geoVectorScale(chart.yAxis, cosLatitude*math.Sin(longitude)),
geoVectorScale(chart.zAxis, math.Sin(latitude)),
),
)
return geoVectorPoint(vector)
}
func polygonUnionRings(polygons [][]GeoPoint) ([]polygonUnionRing, error) {
rings := make([]polygonUnionRing, 0, len(polygons))
reference := 0.0
haveReference := false
for polygonIndex, source := range polygons {
source = openGeoRing(source)
if len(source) < 3 {
return nil, fmt.Errorf("polygon %d requires at least three points", polygonIndex)
}
points := make([]polygonUnionPoint, len(source))
points[0] = polygonUnionPoint{x: source[0].Longitude, y: source[0].Latitude}
for index := 1; index < len(source); index++ {
points[index] = polygonUnionPoint{
x: points[index-1].x + math.Remainder(source[index].Longitude-points[index-1].x, 360),
y: source[index].Latitude,
}
}
// A pole-winding ring closes one full longitude turn away. Joining it
// directly in this chart creates an artificial chord through its interior;
// let UnionPolygons retry in the rotated spherical chart instead.
closure := points[len(points)-1].x + math.Remainder(points[0].x-points[len(points)-1].x, 360)
if math.Abs(closure-points[0].x) > 180 {
return nil, fmt.Errorf("polygon %d winds around a chart pole", polygonIndex)
}
mean := polygonUnionMeanLongitude(points)
if !haveReference {
reference = mean
haveReference = true
} else {
shift := math.Round((reference-mean)/360) * 360
for index := range points {
points[index].x += shift
}
}
ring := polygonUnionRingForPoints(points)
ring.params = make([][]float64, len(points))
for index := range ring.params {
ring.params[index] = []float64{0, 1}
}
rings = append(rings, ring)
}
if len(rings) == 0 {
return nil, fmt.Errorf("polygon union requires at least one polygon")
}
return rings, nil
}
func polygonUnionRingForPoints(points []polygonUnionPoint) polygonUnionRing {
if len(points) == 0 {
return polygonUnionRing{points: points}
}
edges := make([]polygonUnionSourceEdge, len(points))
for index, start := range points {
end := points[(index+1)%len(points)]
edges[index] = polygonUnionSourceEdge{
start: start, end: end,
minX: math.Min(start.x, end.x), maxX: math.Max(start.x, end.x),
minY: math.Min(start.y, end.y), maxY: math.Max(start.y, end.y),
}
}
minX, maxX := points[0].x, points[0].x
minY, maxY := points[0].y, points[0].y
for _, point := range points[1:] {
minX, maxX = math.Min(minX, point.x), math.Max(maxX, point.x)
minY, maxY = math.Min(minY, point.y), math.Max(maxY, point.y)
}
locationEdges := append([]polygonUnionSourceEdge(nil), edges...)
sort.Slice(locationEdges, func(first, second int) bool {
if locationEdges[first].minY != locationEdges[second].minY {
return locationEdges[first].minY < locationEdges[second].minY
}
return locationEdges[first].maxY < locationEdges[second].maxY
})
return polygonUnionRing{
points: points, edges: edges, locationEdges: locationEdges,
locationBins: polygonUnionBuildLocationBins(edges, minY, maxY),
minX: minX, maxX: maxX, minY: minY, maxY: maxY,
}
}
func polygonUnionAddIntersections(rings []polygonUnionRing) {
edges := make([]polygonUnionSourceEdgeRef, 0)
for ringIndex, ring := range rings {
for edgeIndex, edge := range ring.edges {
edges = append(edges, polygonUnionSourceEdgeRef{
ringIndex: ringIndex,
edgeIndex: edgeIndex,
edge: edge,
})
}
}
sort.Slice(edges, func(first, second int) bool {
a, b := edges[first], edges[second]
if a.edge.minX != b.edge.minX {
return a.edge.minX < b.edge.minX
}
if a.edge.maxX != b.edge.maxX {
return a.edge.maxX < b.edge.maxX
}
if a.ringIndex != b.ringIndex {
return a.ringIndex < b.ringIndex
}
return a.edgeIndex < b.edgeIndex
})
for firstIndex, first := range edges {
for secondIndex := firstIndex + 1; secondIndex < len(edges); secondIndex++ {
second := edges[secondIndex]
if second.edge.minX > first.edge.maxX+polygonUnionEpsilon {
break
}
if first.ringIndex == second.ringIndex && polygonUnionEdgesAdjacent(
first.edgeIndex, second.edgeIndex, len(rings[first.ringIndex].points),
) {
continue
}
if !polygonUnionEdgeBoundsOverlap(first.edge, second.edge) {
continue
}
firstParams, secondParams := polygonUnionSegmentIntersections(
first.edge.start, first.edge.end, second.edge.start, second.edge.end,
)
firstRing := &rings[first.ringIndex]
secondRing := &rings[second.ringIndex]
firstRing.params[first.edgeIndex] = append(firstRing.params[first.edgeIndex], firstParams...)
secondRing.params[second.edgeIndex] = append(secondRing.params[second.edgeIndex], secondParams...)
}
}
}
func polygonUnionEdgeBoundsOverlap(first, second polygonUnionSourceEdge) bool {
return first.minX <= second.maxX+polygonUnionEpsilon && second.minX <= first.maxX+polygonUnionEpsilon &&
first.minY <= second.maxY+polygonUnionEpsilon && second.minY <= first.maxY+polygonUnionEpsilon
}
func polygonUnionEdgesAdjacent(first, second, count int) bool {
return (first+1)%count == second || (second+1)%count == first
}
func polygonUnionSegmentIntersections(
firstStart, firstEnd, secondStart, secondEnd polygonUnionPoint,
) ([]float64, []float64) {
firstDelta := polygonUnionSubtract(firstEnd, firstStart)
secondDelta := polygonUnionSubtract(secondEnd, secondStart)
offset := polygonUnionSubtract(secondStart, firstStart)
denominator := polygonUnionCross(firstDelta, secondDelta)
if math.Abs(denominator) > polygonUnionEpsilon {
firstParam := polygonUnionCross(offset, secondDelta) / denominator
secondParam := polygonUnionCross(offset, firstDelta) / denominator
if firstParam < -polygonUnionEpsilon || firstParam > 1+polygonUnionEpsilon ||
secondParam < -polygonUnionEpsilon || secondParam > 1+polygonUnionEpsilon {
return nil, nil
}
return []float64{polygonUnionClampParam(firstParam)}, []float64{polygonUnionClampParam(secondParam)}
}
if math.Abs(polygonUnionCross(offset, firstDelta)) > polygonUnionEpsilon {
return nil, nil
}
firstParams := make([]float64, 0, 2)
secondParams := make([]float64, 0, 2)
for _, point := range []polygonUnionPoint{secondStart, secondEnd} {
if value, ok := polygonUnionPointSegmentParam(point, firstStart, firstEnd); ok {
firstParams = append(firstParams, value)
}
}
for _, point := range []polygonUnionPoint{firstStart, firstEnd} {
if value, ok := polygonUnionPointSegmentParam(point, secondStart, secondEnd); ok {
secondParams = append(secondParams, value)
}
}
return firstParams, secondParams
}
func polygonUnionPointSegmentParam(point, start, end polygonUnionPoint) (float64, bool) {
delta := polygonUnionSubtract(end, start)
lengthSquared := delta.x*delta.x + delta.y*delta.y
if lengthSquared <= polygonUnionEpsilon*polygonUnionEpsilon {
return 0, false
}
value := ((point.x-start.x)*delta.x + (point.y-start.y)*delta.y) / lengthSquared
if value < -polygonUnionEpsilon || value > 1+polygonUnionEpsilon {
return 0, false
}
projected := polygonUnionInterpolate(start, end, value)
if math.Hypot(projected.x-point.x, projected.y-point.y) > polygonUnionEpsilon {
return 0, false
}
return polygonUnionClampParam(value), true
}
func polygonUnionOuterEdges(rings []polygonUnionRing) []polygonUnionEdge {
edges := make(map[polygonUnionEdgeKey]polygonUnionEdge)
for ringIndex, ring := range rings {
for edgeIndex, start := range ring.points {
end := ring.points[(edgeIndex+1)%len(ring.points)]
params := polygonUnionUniqueParams(ring.params[edgeIndex])
for index := 1; index < len(params); index++ {
firstParam, secondParam := params[index-1], params[index]
if secondParam-firstParam <= polygonUnionEpsilon {
continue
}
pieceStart := polygonUnionInterpolate(start, end, firstParam)
pieceEnd := polygonUnionInterpolate(start, end, secondParam)
delta := polygonUnionSubtract(pieceEnd, pieceStart)
length := math.Hypot(delta.x, delta.y)
if length <= polygonUnionEpsilon {
continue
}
middle := polygonUnionInterpolate(pieceStart, pieceEnd, 0.5)
normal := polygonUnionPoint{
x: -delta.y / length * polygonUnionProbeOffset,
y: delta.x / length * polygonUnionProbeOffset,
}
leftInside := polygonUnionInsideAnyRing(polygonUnionAdd(middle, normal), rings, ringIndex)
rightInside := polygonUnionInsideAnyRing(polygonUnionSubtract(middle, normal), rings, ringIndex)
if leftInside == rightInside {
continue
}
if rightInside {
pieceStart, pieceEnd = pieceEnd, pieceStart
}
edge := polygonUnionEdge{start: pieceStart, end: pieceEnd}
key := polygonUnionKey(edge)
edges[key] = edge
}
}
}
result := make([]polygonUnionEdge, 0, len(edges))
for _, edge := range edges {
result = append(result, edge)
}
sort.Slice(result, func(first, second int) bool {
a, b := result[first], result[second]
if a.start.x != b.start.x {
return a.start.x < b.start.x
}
if a.start.y != b.start.y {
return a.start.y < b.start.y
}
if a.end.x != b.end.x {
return a.end.x < b.end.x
}
return a.end.y < b.end.y
})
return result
}
func polygonUnionInsideAnyRing(point polygonUnionPoint, rings []polygonUnionRing, preferred int) bool {
if preferred >= 0 && preferred < len(rings) && polygonUnionRingContainsPoint(point, rings[preferred]) {
return true
}
for index, ring := range rings {
if index != preferred && polygonUnionRingContainsPoint(point, ring) {
return true
}
}
return false
}
func polygonUnionRingContainsPoint(point polygonUnionPoint, ring polygonUnionRing) bool {
margin := polygonUnionProbeOffset + polygonUnionEpsilon
if point.x < ring.minX-margin || point.x > ring.maxX+margin ||
point.y < ring.minY-margin || point.y > ring.maxY+margin {
return false
}
return polygonUnionPointLocationInRing(point, ring) >= 0
}
func polygonUnionJoinEdges(edges []polygonUnionEdge) ([][]GeoPoint, error) {
outgoing := make(map[polygonUnionNode][]int, len(edges))
for index, edge := range edges {
outgoing[polygonUnionNodeForPoint(edge.start)] = append(
outgoing[polygonUnionNodeForPoint(edge.start)], index,
)
}
used := make([]bool, len(edges))
type resultRing struct {
points []GeoPoint
area float64
}
results := make([]resultRing, 0, 2)
// chartRings 保留图幅坐标,用于并集后检测孔洞。
chartRings := make([][]polygonUnionPoint, 0, 2)
for firstEdgeIndex := range edges {
if used[firstEdgeIndex] {
continue
}
firstNode := polygonUnionNodeForPoint(edges[firstEdgeIndex].start)
currentEdgeIndex := firstEdgeIndex
points := make([]polygonUnionPoint, 0, len(edges))
for step := 0; step <= len(edges); step++ {
if used[currentEdgeIndex] {
return nil, fmt.Errorf("polygon union outer edges form a repeated branch")
}
current := edges[currentEdgeIndex]
used[currentEdgeIndex] = true
points = append(points, current.start)
nextNode := polygonUnionNodeForPoint(current.end)
if nextNode == firstNode {
break
}
candidates := outgoing[nextNode]
nextEdgeIndex := -1
for _, candidate := range candidates {
if used[candidate] {
continue
}
if nextEdgeIndex < 0 || polygonUnionClockwiseTurn(
current, edges[candidate],
) < polygonUnionClockwiseTurn(current, edges[nextEdgeIndex]) {
nextEdgeIndex = candidate
}
}
if nextEdgeIndex < 0 {
return nil, fmt.Errorf("polygon union outer edges are open at %.9f, %.9f", current.end.x, current.end.y)
}
currentEdgeIndex = nextEdgeIndex
}
points = polygonUnionDeduplicatePoints(points)
area := polygonUnionSignedArea(points)
if len(points) < 3 || math.Abs(area) <= polygonUnionEpsilon {
continue
}
if area < 0 {
polygonUnionReversePoints(points)
area = -area
}
geographic := make([]GeoPoint, len(points))
for index, point := range points {
geographic[index] = GeoPoint{
Longitude: polygonUnionNormalizeLongitude(point.x),
Latitude: point.y,
}
}
results = append(results, resultRing{points: geographic, area: area})
chartRings = append(chartRings, points)
}
// 环列表无法表达孔洞(内外边界都会当成实体面),检测到严格包含就报错。
if inner, outer, ratio, found := polygonUnionNestedRing(chartRings); found && ratio >= polygonUnionHoleAreaFraction {
return nil, fmt.Errorf(
"polygon union produced a hole (ring %d, area %.6g lies strictly inside ring %d, area %.6g); a ring list cannot represent it",
inner, math.Abs(polygonUnionSignedArea(chartRings[inner])),
outer, math.Abs(polygonUnionSignedArea(chartRings[outer])),
)
}
sort.Slice(results, func(first, second int) bool { return results[first].area > results[second].area })
polygons := make([][]GeoPoint, len(results))
for index, result := range results {
polygons[index] = result.points
}
return polygons, nil
}
// polygonUnionHoleAreaFraction 是判定真孔洞的相对面积下限:低于它按数值细条处理,达到或超过则报错。
const polygonUnionHoleAreaFraction = 0.01
// polygonUnionNestedRing 找出被另一个环严格包含的环(孔洞);贴边不算包含。
func polygonUnionNestedRing(rings [][]polygonUnionPoint) (inner, outer int, ratio float64, found bool) {
bestRatio := 0.0
for candidate := range rings {
candidateArea := math.Abs(polygonUnionSignedArea(rings[candidate]))
for container := range rings {
if candidate == container {
continue
}
strictlyInside := false
contained := true
for _, vertex := range rings[candidate] {
switch polygonUnionPointLocation(vertex, rings[container]) {
case 1:
strictlyInside = true
case 0:
// 贴边:不改变判定
default:
contained = false
}
if !contained {
break
}
}
if !contained || !strictlyInside {
continue
}
containerArea := math.Abs(polygonUnionSignedArea(rings[container]))
if containerArea <= 0 {
continue
}
if candidateRatio := candidateArea / containerArea; candidateRatio > bestRatio {
bestRatio, inner, outer, found = candidateRatio, candidate, container, true
}
}
}
return inner, outer, bestRatio, found
}
func polygonUnionClockwiseTurn(incoming, outgoing polygonUnionEdge) float64 {
incomingAngle := math.Atan2(incoming.end.y-incoming.start.y, incoming.end.x-incoming.start.x)
reverseAngle := incomingAngle + math.Pi
outgoingAngle := math.Atan2(outgoing.end.y-outgoing.start.y, outgoing.end.x-outgoing.start.x)
turn := math.Mod(reverseAngle-outgoingAngle, 2*math.Pi)
if turn < 0 {
turn += 2 * math.Pi
}
return turn
}
// polygonUnionPointLocation returns 1 inside, 0 on the boundary, and -1 outside.
func polygonUnionPointLocation(point polygonUnionPoint, ring []polygonUnionPoint) int {
inside := false
for index, start := range ring {
end := ring[(index+1)%len(ring)]
if _, ok := polygonUnionPointSegmentParam(point, start, end); ok {
return 0
}
if (start.y > point.y) != (end.y > point.y) {
intersectionX := start.x + (end.x-start.x)*(point.y-start.y)/(end.y-start.y)
if point.x < intersectionX {
inside = !inside
}
}
}
if inside {
return 1
}
return -1
}
func polygonUnionPointLocationOnEdges(
point polygonUnionPoint,
edges []polygonUnionSourceEdge,
) int {
inside := false
for _, edge := range edges {
if edge.minY > point.y+polygonUnionEpsilon {
break
}
if edge.maxY < point.y-polygonUnionEpsilon {
continue
}
if point.x >= edge.minX-polygonUnionEpsilon && point.x <= edge.maxX+polygonUnionEpsilon &&
point.y >= edge.minY-polygonUnionEpsilon && point.y <= edge.maxY+polygonUnionEpsilon {
if _, ok := polygonUnionPointSegmentParam(point, edge.start, edge.end); ok {
return 0
}
}
start := edge.start
end := edge.end
if (start.y > point.y) != (end.y > point.y) {
intersectionX := start.x + (end.x-start.x)*(point.y-start.y)/(end.y-start.y)
if point.x < intersectionX {
inside = !inside
}
}
}
if inside {
return 1
}
return -1
}
func polygonUnionBuildLocationBins(
edges []polygonUnionSourceEdge,
minimumY, maximumY float64,
) [][]polygonUnionSourceEdge {
bins := make([][]polygonUnionSourceEdge, polygonUnionLocationBinCount)
for _, edge := range edges {
first := polygonUnionLocationBin(edge.minY-polygonUnionEpsilon, minimumY, maximumY)
last := polygonUnionLocationBin(edge.maxY+polygonUnionEpsilon, minimumY, maximumY)
for index := first; index <= last; index++ {
bins[index] = append(bins[index], edge)
}
}
for index := range bins {
sort.Slice(bins[index], func(first, second int) bool {
if bins[index][first].minY != bins[index][second].minY {
return bins[index][first].minY < bins[index][second].minY
}
return bins[index][first].maxY < bins[index][second].maxY
})
}
return bins
}
func polygonUnionPointLocationInRing(point polygonUnionPoint, ring polygonUnionRing) int {
if len(ring.locationBins) != polygonUnionLocationBinCount {
return polygonUnionPointLocationOnEdges(point, ring.locationEdges)
}
index := polygonUnionLocationBin(point.y, ring.minY, ring.maxY)
return polygonUnionPointLocationOnEdges(point, ring.locationBins[index])
}
func polygonUnionLocationBin(value, minimum, maximum float64) int {
if maximum <= minimum {
return 0
}
index := int((value - minimum) / (maximum - minimum) * polygonUnionLocationBinCount)
if index < 0 {
return 0
}
if index >= polygonUnionLocationBinCount {
return polygonUnionLocationBinCount - 1
}
return index
}
func polygonUnionUniqueParams(values []float64) []float64 {
sort.Float64s(values)
result := values[:0]
for _, value := range values {
value = polygonUnionClampParam(value)
if len(result) == 0 || value-result[len(result)-1] > polygonUnionEpsilon {
result = append(result, value)
}
}
return result
}
func polygonUnionDeduplicatePoints(points []polygonUnionPoint) []polygonUnionPoint {
result := points[:0]
for _, point := range points {
if len(result) == 0 || polygonUnionNodeForPoint(result[len(result)-1]) != polygonUnionNodeForPoint(point) {
result = append(result, point)
}
}
if len(result) > 1 && polygonUnionNodeForPoint(result[0]) == polygonUnionNodeForPoint(result[len(result)-1]) {
result = result[:len(result)-1]
}
return result
}
func polygonUnionSignedArea(points []polygonUnionPoint) float64 {
area := 0.0
for index, current := range points {
next := points[(index+1)%len(points)]
area += current.x*next.y - next.x*current.y
}
return area / 2
}
func polygonUnionMeanLongitude(points []polygonUnionPoint) float64 {
value := 0.0
for _, point := range points {
value += point.x
}
return value / float64(len(points))
}
func polygonUnionReversePoints(points []polygonUnionPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}
func polygonUnionInterpolate(start, end polygonUnionPoint, fraction float64) polygonUnionPoint {
return polygonUnionPoint{
x: start.x + (end.x-start.x)*fraction,
y: start.y + (end.y-start.y)*fraction,
}
}
func polygonUnionSubtract(first, second polygonUnionPoint) polygonUnionPoint {
return polygonUnionPoint{x: first.x - second.x, y: first.y - second.y}
}
func polygonUnionAdd(first, second polygonUnionPoint) polygonUnionPoint {
return polygonUnionPoint{x: first.x + second.x, y: first.y + second.y}
}
func polygonUnionCross(first, second polygonUnionPoint) float64 {
return first.x*second.y - first.y*second.x
}
func polygonUnionClampParam(value float64) float64 {
return math.Max(0, math.Min(1, value))
}
func polygonUnionNodeForPoint(point polygonUnionPoint) polygonUnionNode {
return polygonUnionNode{
x: int64(math.Round(point.x / polygonUnionNodeGrid)),
y: int64(math.Round(point.y / polygonUnionNodeGrid)),
}
}
func polygonUnionKey(edge polygonUnionEdge) polygonUnionEdgeKey {
return polygonUnionEdgeKey{
start: polygonUnionNodeForPoint(edge.start),
end: polygonUnionNodeForPoint(edge.end),
}
}
func polygonUnionNormalizeLongitude(value float64) float64 {
value = math.Mod(value+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
+351
View File
@@ -0,0 +1,351 @@
package geodata
import (
"math"
"strings"
"testing"
)
func TestPolygonUnionLatitudeIndexMatchesExactPointLocation(t *testing.T) {
rings, err := polygonUnionRings([][]GeoPoint{{
{Longitude: -7, Latitude: -2},
{Longitude: -1, Latitude: -6},
{Longitude: 5, Latitude: -3},
{Longitude: 8, Latitude: 2},
{Longitude: 3, Latitude: 7},
{Longitude: -4, Latitude: 5},
}})
if err != nil {
t.Fatalf("polygonUnionRings: %v", err)
}
ring := rings[0]
for latitudeIndex := -80; latitudeIndex <= 80; latitudeIndex++ {
for longitudeIndex := -100; longitudeIndex <= 100; longitudeIndex++ {
point := polygonUnionPoint{
x: float64(longitudeIndex) / 10,
y: float64(latitudeIndex) / 10,
}
got := polygonUnionPointLocationOnEdges(point, ring.locationEdges)
want := polygonUnionPointLocation(point, ring.points)
if got != want {
t.Fatalf("indexed point location at %.1f, %.1f = %d, want %d", point.x, point.y, got, want)
}
if got := polygonUnionPointLocationInRing(point, ring); got != want {
t.Fatalf("binned point location at %.1f, %.1f = %d, want %d", point.x, point.y, got, want)
}
}
}
for _, point := range ring.points {
if got := polygonUnionPointLocationOnEdges(point, ring.locationEdges); got != 0 {
t.Fatalf("indexed point location at boundary %+v = %d, want 0", point, got)
}
if got := polygonUnionPointLocationInRing(point, ring); got != 0 {
t.Fatalf("binned point location at boundary %+v = %d, want 0", point, got)
}
}
}
func TestUnionPolygonsMergesOverlappingRingsWithoutInternalEdges(t *testing.T) {
polygons, err := UnionPolygons([][]GeoPoint{
{
{Longitude: 0, Latitude: 0},
{Longitude: 3, Latitude: 0},
{Longitude: 3, Latitude: 2},
{Longitude: 0, Latitude: 2},
},
{
{Longitude: 2, Latitude: 1},
{Longitude: 4, Latitude: 1},
{Longitude: 4, Latitude: 3},
{Longitude: 2, Latitude: 3},
},
})
if err != nil {
t.Fatalf("UnionPolygons: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polygon count=%d, want one", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: 1, Latitude: 1},
{Longitude: 2.5, Latitude: 2.5},
{Longitude: 3.5, Latitude: 1.5},
} {
if !sweepPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("union does not contain %+v", point)
}
}
if sweepPolygonContainsOrTouches(polygons[0], GeoPoint{Longitude: 1, Latitude: 2.5}) {
t.Fatal("union contains a point outside both inputs")
}
// 并集边界恰为两环的外轮廓:被另一环吞掉的角点 (3,2) 与 (2,1) 不得作为顶点留下,
// 否则说明内部边被当成边界保留。
wantVertices := []GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 3, Latitude: 0},
{Longitude: 3, Latitude: 1},
{Longitude: 4, Latitude: 1},
{Longitude: 4, Latitude: 3},
{Longitude: 2, Latitude: 3},
{Longitude: 2, Latitude: 2},
{Longitude: 0, Latitude: 2},
}
for _, want := range wantVertices {
if !ringHasVertex(polygons[0], want) {
t.Fatalf("union boundary lost the outline vertex %+v: %+v", want, polygons[0])
}
}
for _, swallowed := range []GeoPoint{
{Longitude: 3, Latitude: 2},
{Longitude: 2, Latitude: 1},
} {
if ringHasVertex(polygons[0], swallowed) {
t.Fatalf("union kept the swallowed corner %+v as a boundary vertex (internal edge): %+v", swallowed, polygons[0])
}
}
}
// ringHasVertex 判断环上是否存在与目标重合(1e-9 度)的顶点;环首尾重复只算一次。
func ringHasVertex(ring []GeoPoint, want GeoPoint) bool {
for _, point := range ring {
if math.Abs(point.Longitude-want.Longitude) <= 1e-9 && math.Abs(point.Latitude-want.Latitude) <= 1e-9 {
return true
}
}
return false
}
func TestUnionPolygonsPreservesDisjointAndContainedRings(t *testing.T) {
polygons, err := UnionPolygons([][]GeoPoint{
{
{Longitude: 0, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: 0, Latitude: 4},
},
{
{Longitude: 1, Latitude: 1},
{Longitude: 2, Latitude: 1},
{Longitude: 2, Latitude: 2},
{Longitude: 1, Latitude: 2},
},
{
{Longitude: 10, Latitude: 0},
{Longitude: 11, Latitude: 0},
{Longitude: 11, Latitude: 1},
{Longitude: 10, Latitude: 1},
},
})
if err != nil {
t.Fatalf("UnionPolygons: %v", err)
}
if len(polygons) != 2 {
t.Fatalf("polygon count=%d, want containing and disjoint rings", len(polygons))
}
}
func TestPolygonUnionContainmentChecksInputEdgeMidpoints(t *testing.T) {
result := [][]GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: 3, Latitude: 4},
{Longitude: 3, Latitude: 1},
{Longitude: 1, Latitude: 1},
{Longitude: 1, Latitude: 4},
{Longitude: 0, Latitude: 4},
}}
input := [][]GeoPoint{{
{Longitude: 0.5, Latitude: 3.5},
{Longitude: 3.5, Latitude: 3.5},
{Longitude: 0.5, Latitude: 3},
}}
for _, point := range input[0] {
if !sphericalPolygonContainsOrTouches(result[0], point) {
t.Fatalf("fixture input vertex is outside result: %+v", point)
}
}
if polygonUnionContainsInputs(result, input) {
t.Fatal("containment accepted an input edge crossing the result's open notch")
}
}
func TestUnionPolygonsMergesAcrossAntimeridian(t *testing.T) {
polygons, err := UnionPolygons([][]GeoPoint{
{
{Longitude: 170, Latitude: -5},
{Longitude: -175, Latitude: -5},
{Longitude: -175, Latitude: 5},
{Longitude: 170, Latitude: 5},
},
{
{Longitude: 175, Latitude: 0},
{Longitude: -170, Latitude: 0},
{Longitude: -170, Latitude: 10},
{Longitude: 175, Latitude: 10},
},
})
if err != nil {
t.Fatalf("UnionPolygons across antimeridian: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("antimeridian polygon count=%d, want one", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: 172, Latitude: 0},
{Longitude: 179, Latitude: 3},
{Longitude: -172, Latitude: 7},
} {
if !sphericalPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("antimeridian union does not contain %+v", point)
}
}
}
func TestUnionPolygonsMergesOverlappingPolarCaps(t *testing.T) {
first := SphericalCircle(GeoPoint{Longitude: 0, Latitude: 86}, 7, 72)
second := SphericalCircle(GeoPoint{Longitude: 120, Latitude: 86}, 7, 72)
polygons, err := UnionPolygons([][]GeoPoint{first, second})
if err != nil {
t.Fatalf("UnionPolygons at north pole: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polar polygon count=%d, want one", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: 0, Latitude: 86},
{Longitude: 120, Latitude: 86},
{Longitude: 0, Latitude: 90},
} {
if !sphericalPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("polar union does not contain %+v", point)
}
}
}
func TestUnionPolygonsPreservesPoleWindingBoundary(t *testing.T) {
// The minimum lies just after the ring's start. A planar closure across
// 360 degrees cuts off that minimum as a separate thin polygon.
source := []GeoPoint{
{Longitude: 31.55, Latitude: 3.79},
{Longitude: 31.78, Latitude: 3.76},
{Longitude: 32.02, Latitude: 3.74},
{Longitude: 32.51, Latitude: 3.77},
{Longitude: 33.02, Latitude: 3.86},
{Longitude: 60, Latitude: 20},
{Longitude: 120, Latitude: 50},
{Longitude: 179, Latitude: 70},
{Longitude: -120, Latitude: 75},
{Longitude: -60, Latitude: 55},
{Longitude: 0, Latitude: 15},
{Longitude: 30, Latitude: 4.14},
{Longitude: 31.45, Latitude: 3.81},
}
for _, south := range []bool{false, true} {
for _, reverse := range []bool{false, true} {
ring := append([]GeoPoint(nil), source...)
if south {
for index := range ring {
ring[index].Latitude = -ring[index].Latitude
}
}
if reverse {
reverseGeoPoints(ring)
}
if _, err := unionPolygonsPlanar([][]GeoPoint{ring}); err == nil {
t.Errorf("south=%v reverse=%v: planar union accepted a pole-winding ring", south, reverse)
}
polygons, err := UnionPolygons([][]GeoPoint{ring})
if err != nil || len(polygons) != 1 {
t.Fatalf("south=%v reverse=%v: rings=%d err=%v, want one intact ring", south, reverse, len(polygons), err)
}
if len(openGeoRing(polygons[0])) != len(ring) {
t.Fatalf("union changed a single pole-winding boundary: points=%d want=%d", len(openGeoRing(polygons[0])), len(ring))
}
line := append(append([]GeoPoint(nil), ring...), ring[0])
if miss := visibleLineworkBoundarySourceMissDistanceKM(polygons, [][]GeoPoint{line}); miss > 1e-3 {
t.Fatalf("pole-winding union leaves source boundary by %.6f km", miss)
}
}
}
}
func TestUnionPolygonsRejectsHoles(t *testing.T) {
// 4 根条围成方环:并集的外边界面积 4.0、内边界(洞)1.0,真实并集面积 3.0。
// 环列表表达不了孔洞,因此必须报错,而不是把内边界也当成实体面返回 4+1=5 的超集。
// Four bars forming a square frame: the union's outer boundary has area 4.0 and the
// inner one (a hole) 1.0, so the true union area is 3.0. A ring list cannot express
// the hole, so the union must fail instead of returning the inner boundary as a solid
// face and reporting the superset 4+1=5.
ring := func(x0, y0, x1, y1 float64) []GeoPoint {
return []GeoPoint{
{Longitude: x0, Latitude: y0},
{Longitude: x1, Latitude: y0},
{Longitude: x1, Latitude: y1},
{Longitude: x0, Latitude: y1},
}
}
bars := [][]GeoPoint{
ring(-1, 0.5, 1, 1),
ring(-1, -1, 1, -0.5),
ring(-1, -0.5, -0.5, 0.5),
ring(0.5, -0.5, 1, 0.5),
}
if _, err := UnionPolygons(bars); err == nil {
t.Fatal("UnionPolygons accepted a union with a hole instead of reporting it")
}
// 正对照:两个分离的方块仍是两个环,不应报错。
// Positive control: two disjoint squares stay two rings and must not fail.
disjoint := [][]GeoPoint{ring(0, 0, 1, 1), ring(3, 0, 4, 1)}
result, err := UnionPolygons(disjoint)
if err != nil {
t.Fatalf("disjoint union failed: %v", err)
}
if len(result) != 2 {
t.Fatalf("disjoint union rings=%d, want 2", len(result))
}
}
func TestPolygonUnionWindingPredictsPlanarFailure(t *testing.T) {
rings := map[string][]GeoPoint{
"square": {{0, 0}, {4, 0}, {4, 4}, {0, 4}},
"equatorial": SphericalCircle(GeoPoint{Longitude: 0, Latitude: 0}, 30, 64),
"north cap": SphericalCircle(GeoPoint{Longitude: 0, Latitude: 86}, 7, 64),
"south cap": SphericalCircle(GeoPoint{Longitude: 120, Latitude: -86}, 7, 64),
}
for name, ring := range rings {
_, err := unionPolygonsPlanar([][]GeoPoint{ring})
reported := err != nil && strings.Contains(err.Error(), "winds around a chart pole")
if winding := polygonUnionRingWindsAroundPole(ring); winding != reported {
t.Fatalf("%s: winding=%v planar error=%v", name, winding, err)
}
}
}
func TestUnionPolygonsWindingInputMatchesSphericalChart(t *testing.T) {
cap := SphericalCircle(GeoPoint{Longitude: 40, Latitude: 86}, 8, 72)
want, err := unionPolygonsSphericalChart([][]GeoPoint{cap})
if err != nil {
t.Fatalf("unionPolygonsSphericalChart: %v", err)
}
got, err := UnionPolygons([][]GeoPoint{cap})
if err != nil {
t.Fatalf("UnionPolygons: %v", err)
}
if len(got) != len(want) {
t.Fatalf("ring count=%d, want %d", len(got), len(want))
}
for index := range want {
if len(got[index]) != len(want[index]) {
t.Fatalf("ring %d points=%d, want %d", index, len(got[index]), len(want[index]))
}
for pointIndex := range want[index] {
if !SameGeoPoint(got[index][pointIndex], want[index][pointIndex]) {
t.Fatalf("ring %d point %d=%+v, want %+v",
index, pointIndex, got[index][pointIndex], want[index][pointIndex])
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
package geodata
import (
"math"
"testing"
)
// 居中后的等经纬接缝必须落在视图中心对面;旋转前后地理点集合应当一致。
func TestPolygonFragmentsCentredSeamKeepsGeometry(t *testing.T) {
// 密集采样,避免矩形边在大圆上鼓向极点——库按大圆处理边,稀疏顶点会放大这个效应。
ring := make([]GeoPoint, 0, 240)
for longitude := -60.0; longitude <= 60; longitude += 2 {
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: -30})
}
for longitude := 60.0; longitude >= -60; longitude -= 2 {
ring = append(ring, GeoPoint{Longitude: longitude, Latitude: 30})
}
plain := PolygonFragments(ring, ClipView{Projection: ProjectionEquirectangular})
if len(plain) != 1 {
t.Fatalf("uncentred: got %d fragments, want 1", len(plain))
}
// 中心放在 176.269°E 时接缝落到 -3.73°,这个环横跨接缝,应当被切成两段。
centred := PolygonFragments(ring, ClipView{
Projection: ProjectionEquirectangular,
Center: GeoPoint{Longitude: 176.269},
})
if len(centred) != 2 {
t.Fatalf("centred: got %d fragments, want 2", len(centred))
}
for _, fragment := range centred {
if len(fragment) < 3 {
t.Fatalf("fragment has %d points", len(fragment))
}
for _, point := range fragment {
if point.Longitude < -60-1e-6 || point.Longitude > 60+1e-6 {
t.Fatalf("fragment longitude %.3f escaped the ring", point.Longitude)
}
if math.Abs(point.Latitude) > 30+0.5 {
t.Fatalf("fragment latitude %.3f escaped the ring", point.Latitude)
}
}
}
// 两段拼起来的经度跨度和应等于原环的 120°。
total := 0.0
for _, fragment := range centred {
minLon, maxLon := fragment[0].Longitude, fragment[0].Longitude
for _, point := range fragment[1:] {
minLon = math.Min(minLon, point.Longitude)
maxLon = math.Max(maxLon, point.Longitude)
}
total += maxLon - minLon
}
if math.Abs(total-120) > 4 {
t.Fatalf("fragment longitude span %.3f, want about 120", total)
}
}
+369 -117
View File
@@ -5,7 +5,8 @@ import "math"
// SphericalCircle 返回球面小圆上的等间隔采样点 / SphericalCircle returns evenly spaced points on a small circle on the
// 球面小圆;方位角从地理北方顺时针采样 / sphere. Bearings are sampled clockwise from geographic north.
func SphericalCircle(center GeoPoint, radiusDegrees float64, points int) []GeoPoint {
if points < 3 {
// r=0 时采样点重合、r=180 时只剩对跖点副本,都不构成环。
if points < 3 || !(radiusDegrees > 0 && radiusDegrees < 180) {
return nil
}
latitude := center.Latitude * math.Pi / 180
@@ -28,6 +29,361 @@ func SphericalCircle(center GeoPoint, radiusDegrees float64, points int) []GeoPo
return result
}
type geoVector3 struct {
x, y, z float64
}
func geoPointVector(point GeoPoint) geoVector3 {
latitude := point.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
cosLatitude := math.Cos(latitude)
return geoVector3{
x: cosLatitude * math.Cos(longitude),
y: cosLatitude * math.Sin(longitude),
z: math.Sin(latitude),
}
}
func geoVectorPoint(vector geoVector3) GeoPoint {
length := math.Sqrt(vector.x*vector.x + vector.y*vector.y + vector.z*vector.z)
if length == 0 || math.IsNaN(length) || math.IsInf(length, 0) {
return GeoPoint{Longitude: math.NaN(), Latitude: math.NaN()}
}
vector.x /= length
vector.y /= length
vector.z /= length
return GeoPoint{
Longitude: normalizeLongitude(math.Atan2(vector.y, vector.x) * 180 / math.Pi),
Latitude: math.Asin(math.Max(-1, math.Min(1, vector.z))) * 180 / math.Pi,
}
}
func geoVectorDot(first, second geoVector3) float64 {
return first.x*second.x + first.y*second.y + first.z*second.z
}
func geoVectorCross(first, second geoVector3) geoVector3 {
return geoVector3{
x: first.y*second.z - first.z*second.y,
y: first.z*second.x - first.x*second.z,
z: first.x*second.y - first.y*second.x,
}
}
func geoVectorScale(vector geoVector3, scale float64) geoVector3 {
return geoVector3{x: vector.x * scale, y: vector.y * scale, z: vector.z * scale}
}
func geoVectorAdd(first, second geoVector3) geoVector3 {
return geoVector3{x: first.x + second.x, y: first.y + second.y, z: first.z + second.z}
}
func geoVectorNormalize(vector geoVector3) (geoVector3, bool) {
length := math.Sqrt(geoVectorDot(vector, vector))
if length <= 1e-15 || math.IsNaN(length) || math.IsInf(length, 0) {
return geoVector3{}, false
}
return geoVectorScale(vector, 1/length), true
}
// InterpolateGreatCircle 在较短大圆弧上按分数插值,分数超出 [0,1] 时沿弧延长 / interpolates the shorter great-circle arc, extending it beyond an endpoint.
func InterpolateGreatCircle(first, second GeoPoint, fraction float64) GeoPoint {
return sphericalInterpolate(first, second, fraction)
}
func sphericalInterpolate(first, second GeoPoint, fraction float64) GeoPoint {
a := geoPointVector(first)
b := geoPointVector(second)
dot := math.Max(-1, math.Min(1, geoVectorDot(a, b)))
if dot > 1-1e-14 {
return geoVectorPoint(geoVectorAdd(geoVectorScale(a, 1-fraction), geoVectorScale(b, fraction)))
}
if dot < -1+1e-14 {
// Antipodal endpoints have no unique great circle. The path samplers
// never intentionally create one, but retain a finite fallback for
// malformed caller input.
return GeoPoint{
Longitude: normalizeLongitude(first.Longitude + fraction*normalizeLongitude(second.Longitude-first.Longitude)),
Latitude: first.Latitude + fraction*(second.Latitude-first.Latitude),
}
}
angle := math.Acos(dot)
sine := math.Sin(angle)
value := geoVectorAdd(
geoVectorScale(a, math.Sin((1-fraction)*angle)/sine),
geoVectorScale(b, math.Sin(fraction*angle)/sine),
)
return geoVectorPoint(value)
}
func sphericalPointOnArc(point, first, second GeoPoint) bool {
firstVector := geoPointVector(first)
secondVector := geoPointVector(second)
pointVector := geoPointVector(point)
arc := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, secondVector))))
if arc <= 1e-14 {
return math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, pointVector)))) <= 1e-9
}
firstDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(firstVector, pointVector))))
secondDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, secondVector))))
return math.Abs(firstDistance+secondDistance-arc) <= 1e-9
}
// sphericalPolygonContainsOrTouches tests a simple ring using its minor great
// circle edges. It is intentionally internal: map encoders still own the
// projection-specific winding rules, while topology code needs a seam-free
// containment predicate for pole and antimeridian decisions.
func sphericalPolygonContainsOrTouches(polygon []GeoPoint, point GeoPoint) bool {
if len(polygon) < 3 {
return false
}
if SameGeoPoint(polygon[0], polygon[len(polygon)-1]) {
polygon = polygon[:len(polygon)-1]
}
target := geoPointVector(point)
angleSum := 0.0
area := 0.0
origin := geoPointVector(polygon[0])
for index, currentPoint := range polygon {
previousPoint := polygon[(index+len(polygon)-1)%len(polygon)]
if sphericalPointOnArc(point, previousPoint, currentPoint) {
return true
}
previous := geoPointVector(previousPoint)
current := geoPointVector(currentPoint)
area += 2 * math.Atan2(geoVectorDot(origin, geoVectorCross(previous, current)),
1+geoVectorDot(origin, previous)+geoVectorDot(previous, current)+geoVectorDot(current, origin))
previousTangent := geoVectorAdd(previous, geoVectorScale(target, -geoVectorDot(previous, target)))
currentTangent := geoVectorAdd(current, geoVectorScale(target, -geoVectorDot(current, target)))
previousTangent, previousOK := geoVectorNormalize(previousTangent)
currentTangent, currentOK := geoVectorNormalize(currentTangent)
if !previousOK || !currentOK {
// A vertex and its antipode both have no tangent. Only the vertex
// belongs to the ring; acos roundoff can miss it in the arc check.
return (!previousOK && geoVectorDot(previous, target) > 0) ||
(!currentOK && geoVectorDot(current, target) > 0)
}
cross := geoVectorCross(previousTangent, currentTangent)
angleSum += math.Atan2(geoVectorDot(target, cross), geoVectorDot(previousTangent, currentTangent))
}
// Tangent winding has opposite signs inside the polygon and its antipodal
// image. Match the signed minor area instead of accepting both images.
return math.Abs(angleSum) > math.Pi && angleSum*math.Remainder(area, 4*math.Pi) > 0
}
// SphericalPolygonsContainPaths 判断每个路径顶点和加密边中点是否都位于球面多边形内。
// SphericalPolygonsContainPaths reports whether every path vertex and every
// minor-great-circle edge midpoint lies in or on at least one polygon. When
// closePaths is true, the last vertex of each path is also joined to its first.
func SphericalPolygonsContainPaths(polygons, paths [][]GeoPoint, closePaths bool) bool {
return SphericalPolygonsContainPathsWithinKM(polygons, paths, closePaths, 0)
}
// SphericalPolygonIndex 可复用的球面多边形包含索引 / a reusable containment index for one polygon set.
type SphericalPolygonIndex struct {
containment sphericalPolygonContainment
}
// NewSphericalPolygonIndex 为多边形集合构建包含索引 / builds a containment index for the polygon set.
func NewSphericalPolygonIndex(polygons [][]GeoPoint) *SphericalPolygonIndex {
return &SphericalPolygonIndex{containment: newSphericalPolygonContainment(polygons)}
}
// ContainsPoints 返回各点是否位于多边形内,结果与 points 对齐 / reports per-point containment aligned with points.
func (index *SphericalPolygonIndex) ContainsPoints(points []GeoPoint) []bool {
result := make([]bool, len(points))
if index == nil {
return result
}
for pointIndex, point := range points {
result[pointIndex] = index.containment.contains(point)
}
return result
}
// SphericalPolygonsContainPoints 用共享索引批量测试各个独立点 / tests independent points against one shared spherical polygon index.
func SphericalPolygonsContainPoints(polygons [][]GeoPoint, points []GeoPoint) []bool {
return NewSphericalPolygonIndex(polygons).ContainsPoints(points)
}
// SphericalPolygonsContainPathsWithinKM 是 SphericalPolygonsContainPaths 的容差感知形式。
// SphericalPolygonsContainPathsWithinKM is the tolerance-aware form of
// SphericalPolygonsContainPaths. It stops at the first probe farther than the
// requested distance from every polygon.
func SphericalPolygonsContainPathsWithinKM(
polygons, paths [][]GeoPoint,
closePaths bool,
toleranceKM float64,
) bool {
containment := newSphericalPolygonContainment(polygons)
return visitSphericalPathProbes(paths, closePaths, func(point GeoPoint) bool {
return containment.missDistanceKM(point) <= toleranceKM
})
}
// SphericalPolygonsPathMissDistanceKM 返回路径样本到多边形内部或边界的最大偏离距离。
// SphericalPolygonsPathMissDistanceKM returns the greatest distance from a
// path probe outside all polygons to the nearest polygon edge. Vertices and
// minor-great-circle edge midpoints are probed; a fully contained path returns
// zero.
func SphericalPolygonsPathMissDistanceKM(polygons, paths [][]GeoPoint, closePaths bool) float64 {
containment := newSphericalPolygonContainment(polygons)
maximumMiss := 0.0
visitSphericalPathProbes(paths, closePaths, func(point GeoPoint) bool {
maximumMiss = math.Max(maximumMiss, containment.missDistanceKM(point))
return true
})
return maximumMiss
}
func visitSphericalPathProbes(
paths [][]GeoPoint,
closePaths bool,
visit func(GeoPoint) bool,
) bool {
for _, path := range paths {
path = openGeoRing(path)
for _, point := range path {
if !visit(point) {
return false
}
}
edgeCount := len(path) - 1
if closePaths && len(path) > 1 {
edgeCount = len(path)
}
for index := 0; index < edgeCount; index++ {
if !visit(sphericalInterpolate(path[index], path[(index+1)%len(path)], 0.5)) {
return false
}
}
}
return true
}
type sphericalPolygonContainment struct {
polygons [][]GeoPoint
projected []polygonUnionRing
center geoVector3
xAxis geoVector3
yAxis geoVector3
}
func newSphericalPolygonContainment(polygons [][]GeoPoint) sphericalPolygonContainment {
result := sphericalPolygonContainment{polygons: polygons}
center := geoVector3{}
for _, polygon := range polygons {
for _, point := range openGeoRing(polygon) {
center = geoVectorAdd(center, geoPointVector(point))
}
}
var ok bool
result.center, ok = geoVectorNormalize(center)
if !ok {
return result
}
reference := geoVector3{z: 1}
if math.Abs(geoVectorDot(reference, result.center)) > 0.9 {
reference = geoVector3{x: 1}
}
result.xAxis, ok = geoVectorNormalize(geoVectorCross(reference, result.center))
if !ok {
return result
}
result.yAxis, ok = geoVectorNormalize(geoVectorCross(result.center, result.xAxis))
if !ok {
return result
}
result.projected = make([]polygonUnionRing, len(polygons))
for polygonIndex, polygon := range polygons {
polygon = openGeoRing(polygon)
points := make([]polygonUnionPoint, len(polygon))
for pointIndex, point := range polygon {
projected, projectedOK := result.project(point)
if !projectedOK {
result.projected = nil
return result
}
points[pointIndex] = projected
}
result.projected[polygonIndex] = polygonUnionRingForPoints(points)
}
return result
}
func (containment sphericalPolygonContainment) project(point GeoPoint) (polygonUnionPoint, bool) {
vector := geoPointVector(point)
denominator := geoVectorDot(vector, containment.center)
if denominator <= 1e-12 {
return polygonUnionPoint{}, false
}
return polygonUnionPoint{
x: geoVectorDot(vector, containment.xAxis) / denominator,
y: geoVectorDot(vector, containment.yAxis) / denominator,
}, true
}
func (containment sphericalPolygonContainment) contains(point GeoPoint) bool {
if len(containment.projected) > 0 {
if projected, ok := containment.project(point); ok {
for _, polygon := range containment.projected {
if polygonUnionRingContainsPoint(projected, polygon) {
return true
}
}
return false
}
// Every ring is inside this gnomonic hemisphere. A point beyond its
// horizon cannot be inside and must not use antipodal tangent winding.
return false
}
for _, polygon := range containment.polygons {
if sphericalPolygonContainsOrTouches(polygon, point) {
return true
}
}
return false
}
func (containment sphericalPolygonContainment) missDistanceKM(point GeoPoint) float64 {
if containment.contains(point) {
return 0
}
return containment.sphericalMissDistanceKM(point)
}
func (containment sphericalPolygonContainment) sphericalMissDistanceKM(point GeoPoint) float64 {
nearest := math.Inf(1)
for _, polygon := range containment.polygons {
polygon = openGeoRing(polygon)
for index, start := range polygon {
end := polygon[(index+1)%len(polygon)]
nearest = math.Min(nearest, sphericalPointArcDistanceKM(point, start, end))
}
}
return nearest
}
func sphericalPointArcDistanceKM(point, start, end GeoPoint) float64 {
pointVector := geoPointVector(point)
startVector := geoPointVector(start)
endVector := geoPointVector(end)
normal, ok := geoVectorNormalize(geoVectorCross(startVector, endVector))
if ok {
projection := geoVectorAdd(pointVector, geoVectorScale(normal, -geoVectorDot(pointVector, normal)))
if projected, projectedOK := geoVectorNormalize(projection); projectedOK {
for _, candidate := range []geoVector3{projected, geoVectorScale(projected, -1)} {
candidatePoint := geoVectorPoint(candidate)
if sphericalPointOnArc(candidatePoint, start, end) {
angle := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, candidate))))
return angle * 6378.1366
}
}
}
}
return math.Min(geoPointDistanceKM(point, start), geoPointDistanceKM(point, end))
}
// JoinPolylineSegments 按最近端点连接无序边界线段 / JoinPolylineSegments joins unordered boundary segments by their nearest
// 端点连接;输入线段不会被修改 / endpoints. The input segments are not modified.
func JoinPolylineSegments(segments [][]GeoPoint) []GeoPoint {
@@ -77,15 +433,25 @@ func JoinPolylineSegments(segments [][]GeoPoint) []GeoPoint {
reverseGeoPoints(segment)
}
if bestPrepend {
result = append(segment, result...)
result = appendJoinedGeoPoints(segment, result)
} else {
result = append(result, segment...)
result = appendJoinedGeoPoints(result, segment)
}
used[bestIndex] = true
}
return result
}
// appendJoinedGeoPoints 拼接两段并丢掉衔接处重合的顶点。
func appendJoinedGeoPoints(first, second []GeoPoint) []GeoPoint {
result := append([]GeoPoint(nil), first...)
start := 0
for start < len(second) && len(result) > 0 && SameGeoPoint(result[len(result)-1], second[start]) {
start++
}
return append(result, second[start:]...)
}
// ShortestCircleArc 返回两点之间较短的采样圆弧 / ShortestCircleArc returns the shorter sampled arc from one point to another.
func ShortestCircleArc(circle []GeoPoint, from, to GeoPoint) []GeoPoint {
if len(circle) == 0 {
@@ -120,120 +486,6 @@ func SameGeoPoint(a, b GeoPoint) bool {
math.Abs(a.Latitude-b.Latitude) < 1e-9
}
// VisibleHemispherePolygons 返回以指定中心为中心的半球多边形 / VisibleHemispherePolygons returns polygons for the hemisphere centered on
// 中心的半球多边形,并裁剪到请求的地图投影 / center, clipped to the requested map projection.
func VisibleHemispherePolygons(center GeoPoint, projection Projection, samples int) [][]GeoPoint {
if samples < 12 {
samples = 12
}
if projection == ProjectionNorthPolar {
return [][]GeoPoint{polarVisibleHemispherePolygon(center, 1, samples/2)}
}
if projection == ProjectionSouthPolar {
return [][]GeoPoint{polarVisibleHemispherePolygon(center, -1, samples/2)}
}
return equirectangularVisibleHemispherePolygons(center, samples)
}
func equirectangularVisibleHemispherePolygons(center GeoPoint, samples int) [][]GeoPoint {
if math.Abs(center.Latitude) < 1e-9 {
return equirectangularLongitudeBand(center.Longitude)
}
polygon := make([]GeoPoint, 0, samples+3)
for index := 0; index <= samples; index++ {
longitude := -180 + 360*float64(index)/float64(samples)
polygon = append(polygon, GeoPoint{
Longitude: longitude,
Latitude: visibleHorizonLatitude(center, longitude),
})
}
mapEdgeLatitude := math.Copysign(90, center.Latitude)
return [][]GeoPoint{append(polygon,
GeoPoint{Longitude: 180, Latitude: mapEdgeLatitude},
GeoPoint{Longitude: -180, Latitude: mapEdgeLatitude},
)}
}
func equirectangularLongitudeBand(centerLongitude float64) [][]GeoPoint {
centerLongitude = normalizeLongitude(centerLongitude)
start, end := centerLongitude-90, centerLongitude+90
var polygons [][]GeoPoint
for _, shift := range []float64{-360, 0, 360} {
left := math.Max(-180, start+shift)
right := math.Min(180, end+shift)
if right-left <= 1e-9 {
continue
}
polygons = append(polygons, []GeoPoint{
{Longitude: left, Latitude: -90},
{Longitude: right, Latitude: -90},
{Longitude: right, Latitude: 90},
{Longitude: left, Latitude: 90},
})
}
return polygons
}
func polarVisibleHemispherePolygon(center GeoPoint, hemisphere float64, samples int) []GeoPoint {
if samples < 6 {
samples = 6
}
if math.Abs(center.Latitude) < 1e-9 {
polygon := []GeoPoint{
{Longitude: normalizeLongitude(center.Longitude - 90), Latitude: 0},
{Longitude: normalizeLongitude(center.Longitude), Latitude: 90 * hemisphere},
{Longitude: normalizeLongitude(center.Longitude + 90), Latitude: 0},
}
return appendPolarVisibilityRim(polygon, center.Longitude, false, samples)
}
centerLongitude := normalizeLongitude(center.Longitude)
centerInsideProjection := center.Latitude*hemisphere > 0
horizonMidpoint := centerLongitude
if centerInsideProjection {
horizonMidpoint += 180
}
polygon := make([]GeoPoint, 0, 2*samples+1)
for index := 0; index <= samples; index++ {
longitude := horizonMidpoint - 90 + 180*float64(index)/float64(samples)
latitude := visibleHorizonLatitude(center, longitude)
if latitude*hemisphere < 0 && math.Abs(latitude) < 1e-9 {
latitude = 0
}
polygon = append(polygon, GeoPoint{
Longitude: normalizeLongitude(longitude),
Latitude: latitude,
})
}
return appendPolarVisibilityRim(polygon, centerLongitude, centerInsideProjection, samples)
}
func appendPolarVisibilityRim(
polygon []GeoPoint,
centerLongitude float64,
centerInsideProjection bool,
samples int,
) []GeoPoint {
for index := 1; index <= samples; index++ {
fraction := float64(index) / float64(samples)
longitude := centerLongitude + 90 - 180*fraction
if centerInsideProjection {
longitude = centerLongitude - 90 + 180*fraction
}
polygon = append(polygon, GeoPoint{
Longitude: normalizeLongitude(longitude),
Latitude: 0,
})
}
return polygon
}
func visibleHorizonLatitude(center GeoPoint, longitude float64) float64 {
declination := center.Latitude * math.Pi / 180
deltaLongitude := (longitude - center.Longitude) * math.Pi / 180
return math.Atan(-math.Cos(declination)*math.Cos(deltaLongitude)/math.Sin(declination)) * 180 / math.Pi
}
func nearestGeoPointIndex(points []GeoPoint, target GeoPoint) int {
bestIndex := 0
bestDistance := math.Inf(1)
+94 -1
View File
@@ -1,6 +1,9 @@
package geodata
import "testing"
import (
"math"
"testing"
)
func TestJoinPolylineSegmentsUsesBothResultEndpoints(t *testing.T) {
segments := [][]GeoPoint{
@@ -25,9 +28,99 @@ func TestJoinPolylineSegmentsUsesBothResultEndpoints(t *testing.T) {
}
}
func TestSphericalPolygonsContainPointsMatchesIndividualPathChecks(t *testing.T) {
polygons := [][]GeoPoint{{
{Longitude: -20, Latitude: -10},
{Longitude: 20, Latitude: -10},
{Longitude: 20, Latitude: 10},
{Longitude: -20, Latitude: 10},
}}
points := []GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 20, Latitude: 0},
{Longitude: 40, Latitude: 0},
}
got := SphericalPolygonsContainPoints(polygons, points)
if len(got) != len(points) {
t.Fatalf("result count=%d, want %d", len(got), len(points))
}
for index, point := range points {
want := SphericalPolygonsContainPaths(polygons, [][]GeoPoint{{point}}, false)
if got[index] != want {
t.Fatalf("point %d result=%v, want %v", index, got[index], want)
}
}
}
func absoluteLongitude(value float64) float64 {
if value < 0 {
return -value
}
return value
}
func TestSphericalContainmentRejectsAntipodalInterior(t *testing.T) {
for _, center := range []GeoPoint{{}, {Longitude: 179, Latitude: 70}, {Latitude: -90}} {
polygon := SphericalCircle(center, 12, 48)
antipode := GeoPoint{Longitude: normalizeLongitude(center.Longitude + 180), Latitude: -center.Latitude}
for _, reversed := range []bool{false, true} {
if reversed {
for first, last := 0, len(polygon)-1; first < last; first, last = first+1, last-1 {
polygon[first], polygon[last] = polygon[last], polygon[first]
}
}
if !sphericalPolygonContainsOrTouches(polygon, center) || sphericalPolygonContainsOrTouches(polygon, antipode) {
t.Errorf("center=%+v reversed=%v: direct containment must distinguish the antipode", center, reversed)
}
points := []GeoPoint{center, antipode, polygon[0], {
Longitude: normalizeLongitude(polygon[0].Longitude + 180), Latitude: -polygon[0].Latitude,
}}
got := SphericalPolygonsContainPoints([][]GeoPoint{polygon}, points)
for index, want := range []bool{true, false, true, false} {
if got[index] != want {
t.Errorf("center=%+v reversed=%v point=%+v contains=%v want=%v", center, reversed, points[index], got[index], want)
}
}
}
}
// These components cannot share a gnomonic chart. The spherical fallback
// must also distinguish each component from its antipodal image.
polygons := [][]GeoPoint{
SphericalCircle(GeoPoint{}, 12, 48),
SphericalCircle(GeoPoint{Longitude: 160}, 12, 48),
}
got := SphericalPolygonsContainPoints(polygons, []GeoPoint{{}, {Longitude: 160}, {Longitude: 180}, {Longitude: -20}})
for index, want := range []bool{true, true, false, false} {
if got[index] != want {
t.Errorf("disjoint components: point %d contains=%v want=%v", index, got[index], want)
}
}
}
func TestSphericalCircleRejectsDegenerateRadii(t *testing.T) {
center := GeoPoint{Longitude: 10, Latitude: 20}
for _, radius := range []float64{0, -1, 180, 181, math.Inf(1), math.NaN()} {
if circle := SphericalCircle(center, radius, 32); len(circle) != 0 {
t.Fatalf("radius %v produced %d points, want none", radius, len(circle))
}
}
if circle := SphericalCircle(center, 1, 32); len(circle) != 32 {
t.Fatalf("valid radius produced %d points, want 32", len(circle))
}
}
func TestJoinPolylineSegmentsDropsSharedJunctionVertex(t *testing.T) {
first := []GeoPoint{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}}
second := []GeoPoint{{Longitude: 1, Latitude: 1}, {Longitude: 2, Latitude: 0}}
for _, segments := range [][][]GeoPoint{{first, second}, {second, first}} {
joined := JoinPolylineSegments(segments)
if len(joined) != 3 {
t.Fatalf("joined=%v, want three points", joined)
}
for index := 1; index < len(joined); index++ {
if SameGeoPoint(joined[index-1], joined[index]) {
t.Fatalf("joined=%v keeps the shared junction twice", joined)
}
}
}
}
+672
View File
@@ -0,0 +1,672 @@
package geodata
import (
"errors"
"fmt"
"math"
)
// ErrOpenBoundaryEndpointTrackReversal 表示扫掠边界的端点轨迹发生了物理折返。
// ErrOpenBoundaryEndpointTrackReversal reports a physical reversal along a swept boundary endpoint track.
var ErrOpenBoundaryEndpointTrackReversal = errors.New("open boundary endpoint track reversal")
const (
openBoundaryEndpointStepNoiseKM = 1.0
openBoundaryEndpointProgressBackKM = 5.0
)
// OpenBoundarySweepSample 是用于构造连续扫掠区域的一个边界样本。
// OpenBoundarySweepSample is one sampled boundary used to build a continuous
// sweep. Closed samples are ignored because they already describe an
// instantaneous footprint and are not part of an open non-central band.
type OpenBoundarySweepSample struct {
Boundaries [][]GeoPoint
Closed bool
}
type openBoundarySweepSample struct {
boundary []GeoPoint
start GeoPoint
end GeoPoint
}
// OpenBoundarySweep 将开放边界弧连接为连续扫掠多边形。
// OpenBoundarySweep joins open boundary arcs into continuous swept polygons.
// The first and last arcs close each group; the matching endpoint tracks form
// the two sides. A large endpoint jump starts a new group instead of creating
// a false bridge across a branch change.
func OpenBoundarySweep(samples []OpenBoundarySweepSample) ([][]GeoPoint, error) {
groups, err := openBoundarySweepGroups(samples)
if err != nil {
return nil, err
}
polygons := make([][]GeoPoint, 0, len(groups))
for _, group := range groups {
groupPolygons, groupErr := sweepOpenBoundaryGroup(group)
if groupErr != nil {
return nil, groupErr
}
polygons = append(polygons, groupPolygons...)
}
return polygons, nil
}
// MonotoneOpenBoundarySweep 连接端点轨迹和中间弧已知单调的开放弧。
// MonotoneOpenBoundarySweep joins open arcs whose endpoint tracks and
// intermediate arcs are already known to be monotone. It avoids the general
// containment audit used by OpenBoundarySweep and is intended for tightly
// sampled, event-local contact caps.
func MonotoneOpenBoundarySweep(samples []OpenBoundarySweepSample) ([][]GeoPoint, error) {
groups, err := openBoundarySweepGroups(samples)
if err != nil {
return nil, err
}
polygons := make([][]GeoPoint, 0, len(groups))
for index, group := range groups {
if !sweepEndpointTracksMonotone(group) {
return nil, fmt.Errorf("%w in group %d", ErrOpenBoundaryEndpointTrackReversal, index)
}
outline := sweepOpenBoundaryOutline(group)
if len(outline) < 3 {
return nil, fmt.Errorf("monotone open boundary sweep group %d has no usable outline", index)
}
if !sweepOutlineContainsRepresentativeSamples(outline, group) {
return nil, fmt.Errorf("monotone open boundary sweep group %d folds outside its endpoint tracks", index)
}
polygons = append(polygons, outline)
}
return polygons, nil
}
func sweepEndpointTracksMonotone(group []openBoundarySweepSample) bool {
if len(group) < 3 {
return true
}
for _, endpoint := range []func(openBoundarySweepSample) GeoPoint{
func(sample openBoundarySweepSample) GeoPoint { return sample.start },
func(sample openBoundarySweepSample) GeoPoint { return sample.end },
} {
first := geoPointUnitVector(endpoint(group[0]))
last := geoPointUnitVector(endpoint(group[len(group)-1]))
direction := geoPointTangentDirection(first, last)
if vectorNorm(direction) <= 1e-12 {
continue
}
previousProgress := 0.0
previousVector := first
var previousStep [3]float64
for index := 1; index < len(group); index++ {
current := geoPointUnitVector(endpoint(group[index]))
step := [3]float64{
current[0] - previousVector[0],
current[1] - previousVector[1],
current[2] - previousVector[2],
}
if index > 1 {
previousLength := vectorNorm(previousStep)
currentLength := vectorNorm(step)
if math.Min(previousLength, currentLength)*6371.0088 > openBoundaryEndpointStepNoiseKM &&
vectorDot(previousStep, step) < -0.1*previousLength*currentLength {
return false
}
}
progress := vectorDot(current, direction)
// A long spherical track can deviate slightly from its first-to-last
// tangent chord even while every local step remains forward. Reserve a
// few kilometres for that projection effect; larger gradual reversals
// still invalidate the endpoint envelope.
if progress+openBoundaryEndpointProgressBackKM/6371.0088 < previousProgress {
return false
}
previousProgress = progress
previousVector = current
previousStep = step
}
}
return true
}
func geoPointUnitVector(point GeoPoint) [3]float64 {
latitude := point.Latitude * math.Pi / 180
longitude := point.Longitude * math.Pi / 180
cosLatitude := math.Cos(latitude)
return [3]float64{
cosLatitude * math.Cos(longitude),
cosLatitude * math.Sin(longitude),
math.Sin(latitude),
}
}
func geoPointTangentDirection(first, last [3]float64) [3]float64 {
dot := vectorDot(first, last)
return [3]float64{
last[0] - dot*first[0],
last[1] - dot*first[1],
last[2] - dot*first[2],
}
}
func vectorDot(first, second [3]float64) float64 {
return first[0]*second[0] + first[1]*second[1] + first[2]*second[2]
}
func vectorNorm(value [3]float64) float64 {
return math.Sqrt(vectorDot(value, value))
}
// OpenBoundaryEndpointOutlines 返回各开放边界分组的低成本端点轨迹轮廓。
// OpenBoundaryEndpointOutlines returns the inexpensive endpoint-track outline
// for each open-boundary group. The outlines intentionally omit intermediate
// arc bulges, so callers must combine them with the source footprint polygons;
// they are not a standalone geometric union.
func OpenBoundaryEndpointOutlines(samples []OpenBoundarySweepSample) ([][]GeoPoint, error) {
groups, err := openBoundarySweepGroups(samples)
if err != nil {
return nil, err
}
polygons := make([][]GeoPoint, 0, len(groups))
for index, group := range groups {
if len(group) < 2 {
continue
}
outline := sweepOpenBoundaryOutline(group)
if len(outline) < 3 {
return nil, fmt.Errorf("open boundary endpoint group %d has no usable outline", index)
}
polygons = append(polygons, outline)
}
if len(polygons) == 0 {
return nil, fmt.Errorf("open boundary samples contain no endpoint track")
}
return polygons, nil
}
// DecimateOpenBoundarySweepSamples 在保留端点和分组的前提下限制通用带状 union 的采样成本。
// DecimateOpenBoundarySweepSamples bounds the cost of the general ribbon
// union while retaining every group endpoint and closed-sample separator.
func DecimateOpenBoundarySweepSamples(
samples []OpenBoundarySweepSample,
maximumGroupSamples int,
targetSpacingKM float64,
) []OpenBoundarySweepSample {
if maximumGroupSamples < 2 {
maximumGroupSamples = 2
}
result := make([]OpenBoundarySweepSample, 0, len(samples))
flush := func(group []OpenBoundarySweepSample) {
if len(group) == 0 {
return
}
selected := group
if len(group) > maximumGroupSamples {
selected = make([]OpenBoundarySweepSample, 0, maximumGroupSamples)
last := len(group) - 1
lastPosition := -1
for index := 0; index < maximumGroupSamples; index++ {
position := int(math.Round(float64(index*last) / float64(maximumGroupSamples-1)))
if position != lastPosition {
selected = append(selected, group[position])
lastPosition = position
}
}
}
for _, sample := range selected {
copySample := OpenBoundarySweepSample{Closed: sample.Closed}
copySample.Boundaries = make([][]GeoPoint, len(sample.Boundaries))
for index, boundary := range sample.Boundaries {
copySample.Boundaries[index] = decimateOpenBoundaryPoints(boundary, targetSpacingKM)
}
result = append(result, copySample)
}
}
group := make([]OpenBoundarySweepSample, 0, maximumGroupSamples)
for _, sample := range samples {
if sample.Closed {
flush(group)
group = group[:0]
if len(result) == 0 || !result[len(result)-1].Closed {
result = append(result, OpenBoundarySweepSample{Closed: true})
}
continue
}
group = append(group, sample)
}
flush(group)
return result
}
// OpenBoundarySweepInnerCaps 闭合紧邻内部闭合阶段的小地平缺口。
// OpenBoundarySweepInnerCaps closes the small horizon gaps immediately next
// to a closed-sample separator. Those nearly closed arcs overlap the paired
// middle band; leaving their gap open can retain a narrow inward notch after
// polygon union.
func OpenBoundarySweepInnerCaps(
samples []OpenBoundarySweepSample,
maximumGapKM float64,
) [][]GeoPoint {
if maximumGapKM <= 0 {
return nil
}
indices := make([]int, 0, 2)
for index := 0; index < len(samples); index++ {
if !samples[index].Closed {
continue
}
if index > 0 && !samples[index-1].Closed {
indices = append(indices, index-1)
}
for index+1 < len(samples) && samples[index+1].Closed {
index++
}
if index+1 < len(samples) && !samples[index+1].Closed {
indices = append(indices, index+1)
}
}
caps := make([][]GeoPoint, 0, len(indices))
for _, index := range indices {
boundary := openGeoRing(JoinPolylineSegments(samples[index].Boundaries))
if len(boundary) < 3 || geoPointDistanceKM(boundary[0], boundary[len(boundary)-1]) > maximumGapKM {
continue
}
caps = append(caps, boundary)
}
return caps
}
func decimateOpenBoundaryPoints(points []GeoPoint, targetSpacingKM float64) []GeoPoint {
if len(points) < 3 || targetSpacingKM <= 0 {
return append([]GeoPoint(nil), points...)
}
result := make([]GeoPoint, 1, len(points))
result[0] = points[0]
for _, point := range points[1 : len(points)-1] {
if geoPointDistanceKM(result[len(result)-1], point) >= targetSpacingKM {
result = append(result, point)
}
}
if !SameGeoPoint(result[len(result)-1], points[len(points)-1]) {
result = append(result, points[len(points)-1])
}
return result
}
func sweepOutlineContainsRepresentativeSamples(
outline []GeoPoint,
group []openBoundarySweepSample,
) bool {
const checksPerArc = 5
for _, sample := range group {
for check := 0; check < checksPerArc; check++ {
index := check * (len(sample.boundary) - 1) / (checksPerArc - 1)
if !sweepPointInPolygon(outline, sample.boundary[index]) {
return false
}
}
}
return true
}
func openBoundarySweepGroups(samples []OpenBoundarySweepSample) ([][]openBoundarySweepSample, error) {
groups := make([][]openBoundarySweepSample, 0, 2)
current := make([]openBoundarySweepSample, 0, len(samples))
var previous openBoundarySweepSample
for _, source := range samples {
// A closed footprint is an instantaneous cap, not an open arc. It
// terminates the current open branch so later samples cannot bridge
// across a visibility/type transition.
if source.Closed {
if len(current) > 0 {
groups = append(groups, current)
current = nil
}
previous = openBoundarySweepSample{}
continue
}
boundary := JoinPolylineSegments(source.Boundaries)
boundary = openGeoRing(boundary)
if len(boundary) < 2 {
continue
}
value := openBoundarySweepSample{boundary: boundary, start: boundary[0], end: boundary[len(boundary)-1]}
if len(current) > 0 {
keep := geoPointDistanceKM(previous.start, value.start) +
geoPointDistanceKM(previous.end, value.end)
reverse := geoPointDistanceKM(previous.start, value.end) +
geoPointDistanceKM(previous.end, value.start)
if reverse < keep {
reverseSweepGeoPoints(value.boundary)
value.start, value.end = value.end, value.start
}
if math.Max(
geoPointDistanceKM(previous.start, value.start),
geoPointDistanceKM(previous.end, value.end),
) > 2000 {
groups = append(groups, current)
current = nil
}
}
current = append(current, value)
previous = value
}
if len(current) > 0 {
groups = append(groups, current)
}
if len(groups) == 0 {
return nil, fmt.Errorf("open boundary samples contain no usable arcs")
}
return groups, nil
}
// sweepOpenBoundaryGroup constructs the union swept by a sequence of open
// arcs. Each adjacent pair forms a ribbon whose caps are the two sampled arcs
// and whose sides follow their endpoints. Unioning the ribbons retains any
// intermediate arc that becomes part of a non-monotone outer boundary; a
// convex hull would replace that boundary with visibly incorrect chords.
func sweepOpenBoundaryGroup(group []openBoundarySweepSample) ([][]GeoPoint, error) {
if len(group) == 0 {
return nil, fmt.Errorf("open boundary sweep group is empty")
}
if len(group) == 1 {
return [][]GeoPoint{append([]GeoPoint(nil), group[0].boundary...)}, nil
}
if outline := sweepOpenBoundaryOutline(group); len(outline) >= 3 && sweepOutlineContainsSamples(outline, group) {
return [][]GeoPoint{outline}, nil
}
ribbons := make([][]GeoPoint, 0, len(group)-1)
for index := 1; index < len(group); index++ {
first := group[index-1].boundary
second := group[index].boundary
ring := make([]GeoPoint, 0, len(first)+len(second))
ring = append(ring, first...)
for pointIndex := len(second) - 1; pointIndex >= 0; pointIndex-- {
ring = append(ring, second[pointIndex])
}
ring = sweepDeduplicateAdjacent(ring)
if len(ring) < 3 {
return nil, fmt.Errorf("open boundary sweep ribbon %d has no usable boundary", index-1)
}
ribbons = append(ribbons, ring)
}
polygons, err := UnionPolygons(ribbons)
if err != nil {
return nil, fmt.Errorf("open boundary sweep ribbons: %w", err)
}
polygons, err = sweepBridgeTouchingPolygons(polygons)
if err != nil {
return nil, fmt.Errorf("open boundary sweep touching ribbons: %w", err)
}
return polygons, nil
}
// sweepOpenBoundaryOutline is the simple outer envelope for a non-crossing
// sequence of arcs. The two endpoint tracks are the lateral edges of the
// swept band; using them directly avoids retaining every overlapping ribbon
// edge as a false inward spike near a tangent horizon.
func sweepOpenBoundaryOutline(group []openBoundarySweepSample) []GeoPoint {
if len(group) < 2 {
return nil
}
first, last := group[0].boundary, group[len(group)-1].boundary
if len(first) < 2 || len(last) < 2 {
return nil
}
ring := make([]GeoPoint, 0, len(first)+len(last)+2*len(group))
ring = appendUnwrappedSweepPoints(ring, first)
for index := 1; index < len(group); index++ {
ring = appendUnwrappedSweepPoint(ring, group[index].end)
}
for index := len(last) - 1; index >= 0; index-- {
ring = appendUnwrappedSweepPoint(ring, last[index])
}
for index := len(group) - 2; index >= 0; index-- {
ring = appendUnwrappedSweepPoint(ring, group[index].start)
}
return sweepDeduplicateAdjacent(ring)
}
func appendUnwrappedSweepPoints(result []GeoPoint, points []GeoPoint) []GeoPoint {
for _, point := range points {
result = appendUnwrappedSweepPoint(result, point)
}
return result
}
func appendUnwrappedSweepPoint(result []GeoPoint, point GeoPoint) []GeoPoint {
if len(result) == 0 {
return append(result, point)
}
longitude := point.Longitude
for longitude-result[len(result)-1].Longitude > 180 {
longitude -= 360
}
for longitude-result[len(result)-1].Longitude < -180 {
longitude += 360
}
return append(result, GeoPoint{Longitude: longitude, Latitude: point.Latitude})
}
func sweepOutlineContainsSamples(outline []GeoPoint, group []openBoundarySweepSample) bool {
if len(outline) < 3 {
return false
}
for _, sample := range group {
for _, point := range sample.boundary {
if !sweepPointInPolygon(outline, point) {
return false
}
}
}
return true
}
func sweepPointInPolygon(polygon []GeoPoint, point GeoPoint) bool {
if len(polygon) < 3 {
return false
}
longitude := point.Longitude
for longitude-polygon[0].Longitude > 180 {
longitude -= 360
}
for longitude-polygon[0].Longitude < -180 {
longitude += 360
}
probe := GeoPoint{Longitude: longitude, Latitude: point.Latitude}
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
if sweepPointOnSegment(probe, polygon[previous], polygon[current]) {
return true
}
}
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
first, second := polygon[previous], polygon[current]
if (first.Latitude > point.Latitude) == (second.Latitude > point.Latitude) {
continue
}
intersection := first.Longitude +
(point.Latitude-first.Latitude)*(second.Longitude-first.Longitude)/(second.Latitude-first.Latitude)
if intersection >= longitude {
inside = !inside
}
}
return inside
}
func sweepPointOnSegment(point, start, end GeoPoint) bool {
deltaLongitude := end.Longitude - start.Longitude
deltaLatitude := end.Latitude - start.Latitude
length := math.Hypot(deltaLongitude, deltaLatitude)
if length <= 1e-12 {
return math.Hypot(point.Longitude-start.Longitude, point.Latitude-start.Latitude) <= 1e-9
}
cross := deltaLongitude*(point.Latitude-start.Latitude) -
deltaLatitude*(point.Longitude-start.Longitude)
if math.Abs(cross) > 1e-9*length {
return false
}
dot := (point.Longitude-start.Longitude)*deltaLongitude +
(point.Latitude-start.Latitude)*deltaLatitude
return dot >= -1e-9*length && dot <= length*length+1e-9*length
}
func sweepBridgeTouchingPolygons(polygons [][]GeoPoint) ([][]GeoPoint, error) {
const (
touchingDistanceKM = 0.01
bridgeHalfSizeDeg = 1e-4
)
for len(polygons) > 1 {
firstIndex, secondIndex, touchingPoint, ok := sweepTouchingPolygonPair(polygons, touchingDistanceKM)
if !ok {
return polygons, nil
}
bridge := []GeoPoint{
{Longitude: touchingPoint.Longitude - bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude - bridgeHalfSizeDeg},
{Longitude: touchingPoint.Longitude + bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude - bridgeHalfSizeDeg},
{Longitude: touchingPoint.Longitude + bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude + bridgeHalfSizeDeg},
{Longitude: touchingPoint.Longitude - bridgeHalfSizeDeg, Latitude: touchingPoint.Latitude + bridgeHalfSizeDeg},
}
// 输入是并集输出,环之间互不重叠,因此只需合并这一对。
merged, err := UnionPolygons([][]GeoPoint{polygons[firstIndex], polygons[secondIndex], bridge})
if err != nil {
return nil, err
}
if len(merged) >= 2 {
return polygons, nil
}
polygons = sweepReplaceTouchingPolygonPair(polygons, firstIndex, secondIndex, merged)
}
return polygons, nil
}
const sweepTouchingCellSize = 2e-6
type sweepTouchingVertex struct {
polygon int
point GeoPoint
}
// sweepTouchingPolygonPair 返回扫描顺序最前的一对近邻顶点:先按多边形下标,再按顶点下标。
func sweepTouchingPolygonPair(
polygons [][]GeoPoint,
thresholdKM float64,
) (int, int, GeoPoint, bool) {
cells := make(map[int64][]sweepTouchingVertex)
for polygonIndex, polygon := range polygons {
for _, point := range polygon {
key := sweepTouchingCellKey(geoPointVector(point))
cells[key] = append(cells[key], sweepTouchingVertex{polygon: polygonIndex, point: point})
}
}
pairs := make(map[[2]int]struct{})
for polygonIndex, polygon := range polygons {
for _, point := range polygon {
x, y, z := sweepTouchingCellCoordinates(geoPointVector(point))
for deltaX := int64(-1); deltaX <= 1; deltaX++ {
for deltaY := int64(-1); deltaY <= 1; deltaY++ {
for deltaZ := int64(-1); deltaZ <= 1; deltaZ++ {
key := sweepTouchingCellKeyFromCoordinates(x+deltaX, y+deltaY, z+deltaZ)
for _, candidate := range cells[key] {
if candidate.polygon <= polygonIndex ||
geoPointDistanceKM(point, candidate.point) > thresholdKM {
continue
}
pairs[[2]int{polygonIndex, candidate.polygon}] = struct{}{}
}
}
}
}
}
}
firstIndex, secondIndex := -1, -1
for pair := range pairs {
if firstIndex < 0 || pair[0] < firstIndex || (pair[0] == firstIndex && pair[1] < secondIndex) {
firstIndex, secondIndex = pair[0], pair[1]
}
}
if firstIndex < 0 {
return 0, 0, GeoPoint{}, false
}
for _, first := range polygons[firstIndex] {
for _, second := range polygons[secondIndex] {
if !(geoPointDistanceKM(first, second) > thresholdKM) {
return firstIndex, secondIndex, GeoPoint{
Longitude: first.Longitude + math.Remainder(second.Longitude-first.Longitude, 360)/2,
Latitude: (first.Latitude + second.Latitude) / 2,
}, true
}
}
}
return 0, 0, GeoPoint{}, false
}
// 网格边长 2e-6 弧度,大于 0.01 公里的球面弦长,近邻顶点必落在相邻格。
func sweepTouchingCellCoordinates(vector geoVector3) (int64, int64, int64) {
const offset = 1 << 19
return int64(math.Floor(vector.x/sweepTouchingCellSize)) + offset,
int64(math.Floor(vector.y/sweepTouchingCellSize)) + offset,
int64(math.Floor(vector.z/sweepTouchingCellSize)) + offset
}
func sweepTouchingCellKey(vector geoVector3) int64 {
x, y, z := sweepTouchingCellCoordinates(vector)
return sweepTouchingCellKeyFromCoordinates(x, y, z)
}
func sweepTouchingCellKeyFromCoordinates(x, y, z int64) int64 {
return x<<40 | y<<20 | z
}
func sweepReplaceTouchingPolygonPair(
polygons [][]GeoPoint,
first, second int,
merged [][]GeoPoint,
) [][]GeoPoint {
if first > second {
first, second = second, first
}
result := make([][]GeoPoint, 0, len(polygons)-1+len(merged))
result = append(result, polygons[:first]...)
result = append(result, merged...)
result = append(result, polygons[first+1:second]...)
result = append(result, polygons[second+1:]...)
return result
}
func sweepDeduplicateAdjacent(points []GeoPoint) []GeoPoint {
if len(points) < 2 {
return points
}
result := make([]GeoPoint, 0, len(points))
for _, point := range points {
if len(result) == 0 || !SameGeoPoint(result[len(result)-1], point) {
result = append(result, point)
}
}
if len(result) > 1 && SameGeoPoint(result[0], result[len(result)-1]) {
result = result[:len(result)-1]
}
return result
}
func openGeoRing(points []GeoPoint) []GeoPoint {
if len(points) > 1 && SameGeoPoint(points[0], points[len(points)-1]) {
return points[:len(points)-1]
}
return points
}
func reverseSweepGeoPoints(points []GeoPoint) {
for left, right := 0, len(points)-1; left < right; left, right = left+1, right-1 {
points[left], points[right] = points[right], points[left]
}
}
func geoPointDistanceKM(first, second GeoPoint) float64 {
firstLatitude := first.Latitude * math.Pi / 180
secondLatitude := second.Latitude * math.Pi / 180
deltaLatitude := secondLatitude - firstLatitude
deltaLongitude := math.Remainder((second.Longitude-first.Longitude)*math.Pi/180, 2*math.Pi)
haversine := math.Sin(deltaLatitude/2)*math.Sin(deltaLatitude/2) +
math.Cos(firstLatitude)*math.Cos(secondLatitude)*math.Sin(deltaLongitude/2)*math.Sin(deltaLongitude/2)
return 2 * 6378.1366 * math.Asin(math.Sqrt(math.Min(1, haversine)))
}
+351
View File
@@ -0,0 +1,351 @@
package geodata
import (
"errors"
"math"
"testing"
"time"
)
func TestOpenBoundarySweepContainsIntermediateArcs(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: -2, Latitude: 0}, {Longitude: 2, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{
{Longitude: -1.5, Latitude: 0.5},
{Longitude: 0, Latitude: 3},
{Longitude: 1.5, Latitude: 0.5},
}}},
{Boundaries: [][]GeoPoint{{{Longitude: -1, Latitude: 1}, {Longitude: 1, Latitude: 1}}}},
}
polygons, err := OpenBoundarySweep(samples)
if err != nil {
t.Fatalf("OpenBoundarySweep: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polygon count = %d, want 1", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: -2, Latitude: 0},
{Longitude: 0, Latitude: 3},
{Longitude: 2, Latitude: 0},
} {
if !sweepPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("sweep polygon does not contain boundary point %+v", point)
}
}
}
func TestOpenBoundarySweepSeparatesGroupsAcrossClosedSample(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: -20, Latitude: 0}, {Longitude: -10, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{{Longitude: -19, Latitude: 1}, {Longitude: -9, Latitude: 1}}}},
{Closed: true, Boundaries: [][]GeoPoint{{
{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 0}, {Longitude: 0, Latitude: 1},
}}},
{Boundaries: [][]GeoPoint{{{Longitude: 10, Latitude: 0}, {Longitude: 20, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 11, Latitude: 1}, {Longitude: 21, Latitude: 1}}}},
}
polygons, err := OpenBoundarySweep(samples)
if err != nil {
t.Fatalf("OpenBoundarySweep: %v", err)
}
if len(polygons) != 2 {
t.Fatalf("polygon count=%d, want two groups separated by the closed sample", len(polygons))
}
}
func TestMonotoneOpenBoundarySweepUsesEndpointTracks(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: -2, Latitude: 0}, {Longitude: 2, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{{Longitude: -1.5, Latitude: 1}, {Longitude: 1.5, Latitude: 1}}}},
{Boundaries: [][]GeoPoint{{{Longitude: -1, Latitude: 2}, {Longitude: 1, Latitude: 2}}}},
}
polygons, err := MonotoneOpenBoundarySweep(samples)
if err != nil {
t.Fatalf("MonotoneOpenBoundarySweep: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polygon count=%d, want one monotone outline", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: -2, Latitude: 0},
{Longitude: 2, Latitude: 0},
{Longitude: -1.5, Latitude: 1},
{Longitude: 1.5, Latitude: 1},
{Longitude: -1, Latitude: 2},
{Longitude: 1, Latitude: 2},
} {
found := false
for _, candidate := range polygons[0] {
if SameGeoPoint(candidate, point) {
found = true
break
}
}
if !found {
t.Fatalf("monotone outline is missing endpoint-track point %+v", point)
}
}
}
func TestMonotoneOpenBoundarySweepRejectsIntermediateBulge(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: -2, Latitude: 0}, {Longitude: 2, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{
{Longitude: -1.5, Latitude: 0.5},
{Longitude: 0, Latitude: 3},
{Longitude: 1.5, Latitude: 0.5},
}}},
{Boundaries: [][]GeoPoint{{{Longitude: -1, Latitude: 1}, {Longitude: 1, Latitude: 1}}}},
}
if _, err := MonotoneOpenBoundarySweep(samples); err == nil {
t.Fatal("monotone sweep accepted an intermediate arc outside its endpoint tracks")
}
}
func TestMonotoneOpenBoundarySweepRejectsEndpointTrackReversal(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 2, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 1, Latitude: 0.5}, {Longitude: 3, Latitude: 0.5}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 0.5, Latitude: 1}, {Longitude: 2.5, Latitude: 1}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 1.5}, {Longitude: 2, Latitude: 1.5}}}},
}
if _, err := MonotoneOpenBoundarySweep(samples); !errors.Is(err, ErrOpenBoundaryEndpointTrackReversal) {
t.Fatalf("monotone sweep error=%v, want endpoint-track reversal", err)
}
}
func TestMonotoneOpenBoundarySweepAllowsSubKilometreEndpointNoise(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 2, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 0.005, Latitude: 0.5}, {Longitude: 2.005, Latitude: 0.5}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 0.001, Latitude: 1}, {Longitude: 2.001, Latitude: 1}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 0.01, Latitude: 1.5}, {Longitude: 2.01, Latitude: 1.5}}}},
}
if _, err := MonotoneOpenBoundarySweep(samples); err != nil {
t.Fatalf("monotone sweep rejected sub-kilometre endpoint noise: %v", err)
}
}
func TestOpenBoundaryEndpointOutlinesRetainTracksAcrossIntermediateBulge(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 4, Latitude: 0}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 1}, {Longitude: 2, Latitude: 4}, {Longitude: 4, Latitude: 1}}}},
{Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 2}, {Longitude: 4, Latitude: 2}}}},
}
polygons, err := OpenBoundaryEndpointOutlines(samples)
if err != nil {
t.Fatalf("OpenBoundaryEndpointOutlines: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polygon count=%d, want 1", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 0, Latitude: 1},
{Longitude: 0, Latitude: 2},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 1},
{Longitude: 4, Latitude: 2},
} {
if !sweepPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("endpoint outline does not contain track point %+v", point)
}
}
if sweepPolygonContainsOrTouches(polygons[0], GeoPoint{Longitude: 2, Latitude: 4}) {
t.Fatal("endpoint-only outline unexpectedly contains the intermediate bulge")
}
}
func TestOpenBoundaryEndpointOutlinesRejectSingleSampleGroups(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{{Longitude: 0, Latitude: 0}, {Longitude: 1, Latitude: 1}, {Longitude: 2, Latitude: 0}}}},
{Closed: true},
{Boundaries: [][]GeoPoint{{{Longitude: 10, Latitude: 0}, {Longitude: 11, Latitude: 1}, {Longitude: 12, Latitude: 0}}}},
}
if _, err := OpenBoundaryEndpointOutlines(samples); err == nil {
t.Fatal("endpoint outlines accepted groups without an endpoint track")
}
}
func TestDecimateOpenBoundarySweepSamplesPreservesGroupsAndEndpoints(t *testing.T) {
var samples []OpenBoundarySweepSample
for group := 0; group < 2; group++ {
for index := 0; index < 10; index++ {
longitude := float64(group*100 + index)
samples = append(samples, OpenBoundarySweepSample{Boundaries: [][]GeoPoint{{
{Longitude: longitude, Latitude: 0},
{Longitude: longitude + 0.1, Latitude: 0.1},
{Longitude: longitude + 0.2, Latitude: 0.2},
}}})
}
if group == 0 {
samples = append(samples, OpenBoundarySweepSample{Closed: true})
}
}
decimated := DecimateOpenBoundarySweepSamples(samples, 4, 20)
if len(decimated) != 9 || !decimated[4].Closed {
t.Fatalf("decimated samples=%d separator=%v, want two groups of four", len(decimated), decimated[4].Closed)
}
for _, index := range []int{0, 3, 5, 8} {
if len(decimated[index].Boundaries) != 1 || len(decimated[index].Boundaries[0]) != 2 {
t.Fatalf("sample %d boundary was not spatially decimated: %#v", index, decimated[index].Boundaries)
}
}
for index, wantLongitude := range map[int]float64{0: 0, 3: 9, 5: 100, 8: 109} {
if got := decimated[index].Boundaries[0][0].Longitude; got != wantLongitude {
t.Fatalf("sample %d first longitude=%.1f, want %.1f", index, got, wantLongitude)
}
}
}
func TestOpenBoundarySweepInnerCapsCloseOnlyNearSeparator(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{
{Longitude: -2, Latitude: 0}, {Longitude: 0, Latitude: 2}, {Longitude: 2, Latitude: 0},
}}},
{Closed: true},
{Boundaries: [][]GeoPoint{{
{Longitude: 10, Latitude: 0}, {Longitude: 12, Latitude: 2}, {Longitude: 14, Latitude: 0},
}}},
}
if caps := OpenBoundarySweepInnerCaps(samples, 500); len(caps) != 2 {
t.Fatalf("inner caps=%d, want both sides of the closed separator", len(caps))
}
if caps := OpenBoundarySweepInnerCaps(samples, 100); len(caps) != 0 {
t.Fatalf("inner caps=%d, want large horizon gaps left open", len(caps))
}
}
func TestOpenBoundarySweepStaysContinuousAcrossAntimeridian(t *testing.T) {
samples := []OpenBoundarySweepSample{
{Boundaries: [][]GeoPoint{{
{Longitude: 170, Latitude: 82},
{Longitude: -170, Latitude: 82},
}}},
{Boundaries: [][]GeoPoint{{
{Longitude: 172, Latitude: 84},
{Longitude: -168, Latitude: 84},
}}},
{Boundaries: [][]GeoPoint{{
{Longitude: 175, Latitude: 86},
{Longitude: -165, Latitude: 86},
}}},
}
polygons, err := OpenBoundarySweep(samples)
if err != nil {
t.Fatalf("OpenBoundarySweep across antimeridian: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("antimeridian sweep polygon count=%d, want one", len(polygons))
}
for _, sample := range samples {
for _, point := range sample.Boundaries[0] {
if !sphericalPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("sweep excludes sampled endpoint %+v", point)
}
}
}
}
func TestSweepPointInPolygonIncludesBoundary(t *testing.T) {
polygon := []GeoPoint{
{Longitude: 120, Latitude: -72},
{Longitude: 135, Latitude: -72},
{Longitude: 135, Latitude: -68},
{Longitude: 120, Latitude: -68},
}
for _, point := range []GeoPoint{
{Longitude: 120, Latitude: -72},
{Longitude: 127.5, Latitude: -72},
{Longitude: 135, Latitude: -70},
{Longitude: 127.5, Latitude: -70},
} {
if !sweepPointInPolygon(polygon, point) {
t.Fatalf("boundary/interior point %#v was rejected", point)
}
}
if sweepPointInPolygon(polygon, GeoPoint{Longitude: 127.5, Latitude: -73}) {
t.Fatal("outside point was accepted")
}
}
func sweepPolygonContainsOrTouches(polygon []GeoPoint, point GeoPoint) bool {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
first, second := polygon[previous], polygon[current]
cross := (point.Longitude-first.Longitude)*(second.Latitude-first.Latitude) -
(point.Latitude-first.Latitude)*(second.Longitude-first.Longitude)
if math.Abs(cross) <= 1e-9 && point.Longitude >= math.Min(first.Longitude, second.Longitude)-1e-9 &&
point.Longitude <= math.Max(first.Longitude, second.Longitude)+1e-9 &&
point.Latitude >= math.Min(first.Latitude, second.Latitude)-1e-9 &&
point.Latitude <= math.Max(first.Latitude, second.Latitude)+1e-9 {
return true
}
if (first.Latitude > point.Latitude) != (second.Latitude > point.Latitude) &&
point.Longitude < (second.Longitude-first.Longitude)*(point.Latitude-first.Latitude)/
(second.Latitude-first.Latitude)+first.Longitude {
inside = !inside
}
}
return inside
}
func TestSweepBridgeTouchingPolygonsBridgesNearPair(t *testing.T) {
polygons := [][]GeoPoint{
{{0, 0}, {1, 0}, {1, 1}, {0, 1}},
{{0, 0.00005}, {1, 0.00005}, {1, 1.00005}, {0, 1.00005}},
}
result, err := sweepBridgeTouchingPolygons(polygons)
if err != nil {
t.Fatalf("sweepBridgeTouchingPolygons: %v", err)
}
if len(result) != 1 {
t.Fatalf("ring count=%d, want one bridged ring", len(result))
}
for _, point := range []GeoPoint{{Longitude: 0.5, Latitude: 0.5}, {Longitude: 0.5, Latitude: 0.00002}} {
if !sweepPointInPolygon(result[0], point) {
t.Fatalf("bridged ring misses %+v", point)
}
}
}
func TestSweepBridgeTouchingPolygonsSeparatesDistantRings(t *testing.T) {
polygons := [][]GeoPoint{
{{0, 0}, {1, 0}, {1, 1}, {0, 1}},
{{0, 0.001}, {1, 0.001}, {1, 1.001}, {0, 1.001}},
}
result, err := sweepBridgeTouchingPolygons(polygons)
if err != nil {
t.Fatalf("sweepBridgeTouchingPolygons: %v", err)
}
if len(result) != 2 {
t.Fatalf("ring count=%d, want two rings a kilometre apart", len(result))
}
}
func TestSweepBridgeTouchingPolygonsScansManyRingsQuickly(t *testing.T) {
rings := make([][]GeoPoint, 0, 120)
for row := 0; row < 10; row++ {
for col := 0; col < 12; col++ {
rings = append(rings, SphericalCircle(GeoPoint{
Longitude: 15 * float64(col),
Latitude: 10 + 5*float64(row),
}, 0.5, 128))
}
}
rings[119] = SphericalCircle(GeoPoint{Longitude: 150.0005, Latitude: 55}, 0.5, 128)
start := time.Now()
result, err := sweepBridgeTouchingPolygons(rings)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("sweepBridgeTouchingPolygons: %v", err)
}
if len(result) != len(rings) {
t.Fatalf("ring count=%d, want %d", len(result), len(rings))
}
if elapsed > 2*time.Second {
t.Fatalf("scanning 120 rings took %v", elapsed)
}
}
+275 -14
View File
@@ -4,21 +4,42 @@ import "math"
// PolylineSegments 将地理折线裁剪到选定投影并 / PolylineSegments clips a geographic polyline to the selected projection and
// 在等经纬投影中按日界线拆分路径 / splits equirectangular paths at the antimeridian.
func PolylineSegments(points []GeoPoint, projection Projection) [][]GeoPoint {
func PolylineSegments(points []GeoPoint, view ClipView) [][]GeoPoint {
prepared, ok := prepareTopologyPoints(points)
if !ok {
return nil
}
points = prepared
if view.Orthographic() {
return clipPolylineOrthographic(points, view.Center)
}
projection := view.Projection
if projection == ProjectionNorthPolar {
return clipPolylineHemisphere(points, 1)
}
if projection == ProjectionSouthPolar {
return clipPolylineHemisphere(points, -1)
}
if shift := equirectangularSeamShift(view); shift != 0 {
return splitPolylineAtSeam(points, shift)
}
return splitPolylineAntimeridian(points)
}
// PolygonFragments 将地理多边形裁剪到选定地图范围 / PolygonFragments clips a geographic polygon to the selected map extent.
func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint {
func PolygonFragments(points []GeoPoint, view ClipView) [][]GeoPoint {
if len(points) < 3 {
return nil
}
prepared, ok := prepareTopologyPoints(points)
if !ok {
return nil
}
points = prepared
if view.Orthographic() {
return polygonFragmentsOrthographic(points, view.Center)
}
projection := view.Projection
if projection == ProjectionNorthPolar {
if clipped := clipPolygonHemisphere(points, 1); len(clipped) >= 3 {
return [][]GeoPoint{clipped}
@@ -31,9 +52,39 @@ func PolygonFragments(points []GeoPoint, projection Projection) [][]GeoPoint {
}
return nil
}
if shift := equirectangularSeamShift(view); shift != 0 {
return splitPolygonAtSeam(points, shift)
}
return splitPolygonAntimeridian(points)
}
// prepareTopologyPoints 校验裁剪输入:NaN 与 ±Inf 直接拒绝,经度超出 ±180 时按 360 取模归一化
// (同一子午线的等价表示),只有真的越界才复制,正常输入保持零分配。
func prepareTopologyPoints(points []GeoPoint) ([]GeoPoint, bool) {
for _, point := range points {
if math.IsNaN(point.Longitude) || math.IsNaN(point.Latitude) ||
math.IsInf(point.Longitude, 0) || math.IsInf(point.Latitude, 0) {
return nil, false
}
}
shifted := false
for _, point := range points {
if point.Longitude < -180 || point.Longitude > 180 {
shifted = true
break
}
}
if !shifted {
return points, true
}
normalized := make([]GeoPoint, len(points))
copy(normalized, points)
for index := range normalized {
normalized[index].Longitude = normalizeLongitude(normalized[index].Longitude)
}
return normalized, true
}
func splitPolylineAntimeridian(points []GeoPoint) [][]GeoPoint {
if len(points) == 0 {
return nil
@@ -61,8 +112,7 @@ func splitPolylineAntimeridian(points []GeoPoint) [][]GeoPoint {
} else {
adjustedLongitude += 360
}
fraction := (boundary - a.Longitude) / (adjustedLongitude - a.Longitude)
crossing := GeoPoint{Longitude: boundary, Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude)}
crossing := sphericalLongitudeIntersection(a, b, boundary, adjustedLongitude)
current = append(current, crossing)
if len(current) >= 2 {
segments = append(segments, current)
@@ -126,19 +176,22 @@ func splitPolygonAntimeridian(points []GeoPoint) [][]GeoPoint {
for index := 1; index < len(points); index++ {
point := points[index]
previous := unwrapped[index-1].Longitude
for point.Longitude-previous > 180 {
point.Longitude -= 360
}
for point.Longitude-previous < -180 {
point.Longitude += 360
// 一次取整到最近的 360 倍数,等价于反复加减 360 但不随偏移量增长。
if offset := point.Longitude - previous; offset > 180 || offset < -180 {
point.Longitude -= 360 * math.Round(offset/360)
}
unwrapped[index] = point
}
unwrapped = closePoleEnclosingPolygon(unwrapped)
minimum, maximum := unwrapped[0].Longitude, unwrapped[0].Longitude
for _, point := range unwrapped[1:] {
minimum = math.Min(minimum, point.Longitude)
maximum = math.Max(maximum, point.Longitude)
}
if span := maximum - minimum; !(span >= 0 && span <= 3*360) {
// 经度归一化后每个点最多偏离 ±180 再加一次 360 的展开,跨度不可能超过三个世界。
return nil
}
firstWorld := int(math.Floor((minimum + 180) / 360))
lastWorld := int(math.Floor((maximum + 180) / 360))
var fragments [][]GeoPoint
@@ -164,6 +217,113 @@ func splitPolygonAntimeridian(points []GeoPoint) [][]GeoPoint {
return fragments
}
// closePoleEnclosingPolygon adds the equirectangular map-edge closure for a
// simple spherical ring that winds once around a pole. Without this edge, the
// implicit last-to-first segment cuts across the map instead of representing
// the cap at +90 or -90 degrees.
func closePoleEnclosingPolygon(points []GeoPoint) []GeoPoint {
if len(points) < 3 {
return points
}
first := points[0].Longitude
closure := first
last := points[len(points)-1].Longitude
for closure-last > 180 {
closure -= 360
}
for closure-last < -180 {
closure += 360
}
winding := math.Round((closure - first) / 360)
if math.Abs(winding) != 1 {
return points
}
// Choose the smaller map-edge closure from edge geometry. Unlike a vertex
// average, its result is unchanged when a straight boundary edge is resampled.
north := appendPoleClosure(points, closure, first, 90)
south := appendPoleClosure(points, closure, first, -90)
northInside := sphericalPolygonContainsOrTouches(points, GeoPoint{Longitude: first, Latitude: 90})
southInside := sphericalPolygonContainsOrTouches(points, GeoPoint{Longitude: first, Latitude: -90})
northArea := math.Abs(signedPolygonArea(north))
southArea := math.Abs(signedPolygonArea(south))
if northInside == southInside && math.Abs(northArea-southArea) <= 1e-12 {
return points
}
pole := -90.0
if (northInside != southInside && northInside) || (northInside == southInside && northArea < southArea) {
pole = 90
}
// The closure must not cross the physical boundary again. For a concave
// polar ring, only the poleward-most seam crossing has a clear path to
// the pole; the first crossing can turn an excluded pocket into a fill.
points = rotatePoleRingToMapEdge(points, pole)
first = points[0].Longitude
return appendPoleClosure(points, first+360*winding, first, pole)
}
func rotatePoleRingToMapEdge(points []GeoPoint, poleLatitude float64) []GeoPoint {
if len(points) < 3 {
return points
}
bestIndex := -1
bestScore := math.Inf(-1)
var crossing GeoPoint
for index := 0; index < len(points); index++ {
next := (index + 1) % len(points)
a, b := points[index], points[next]
bLongitude := b.Longitude
for bLongitude-a.Longitude > 180 {
bLongitude -= 360
}
for bLongitude-a.Longitude < -180 {
bLongitude += 360
}
if math.Abs(a.Longitude-bLongitude) <= 1e-12 {
continue
}
target := 180 + 360*math.Ceil((math.Min(a.Longitude, bLongitude)-180-1e-12)/360)
for ; target <= math.Max(a.Longitude, bLongitude)+1e-12; target += 360 {
candidate := sphericalLongitudeIntersection(a, b, target, bLongitude)
if score := candidate.Latitude * poleLatitude; score > bestScore {
bestIndex, bestScore, crossing = index, score, candidate
}
}
}
if bestIndex < 0 {
return points
}
rotated := make([]GeoPoint, 1, len(points)+1)
rotated[0] = crossing
for offset := 1; offset <= len(points); offset++ {
point := points[(bestIndex+offset)%len(points)]
previous := rotated[len(rotated)-1].Longitude
for point.Longitude-previous > 180 {
point.Longitude -= 360
}
for point.Longitude-previous < -180 {
point.Longitude += 360
}
rotated = append(rotated, point)
}
return sweepDeduplicateAdjacent(rotated)
}
func appendPoleClosure(points []GeoPoint, closure, first, poleLatitude float64) []GeoPoint {
result := append([]GeoPoint(nil), points...)
// The unwrapped ring ends in the world adjacent to its first vertex. Repeat
// that vertex in the adjacent world before climbing to the map edge;
// otherwise the last boundary point is connected diagonally to the pole and
// a triangular gap is cut out after antimeridian clipping.
result = append(result, GeoPoint{
Longitude: closure,
Latitude: points[0].Latitude,
})
return append(result,
GeoPoint{Longitude: closure, Latitude: poleLatitude},
GeoPoint{Longitude: first, Latitude: poleLatitude},
)
}
func signedPolygonArea(points []GeoPoint) float64 {
if len(points) < 3 {
return 0
@@ -184,8 +344,7 @@ func clipPolygonLongitude(points []GeoPoint, boundary float64, keepGreater bool)
return value.Longitude <= boundary
}
intersection := func(a, b GeoPoint) GeoPoint {
fraction := (boundary - a.Longitude) / (b.Longitude - a.Longitude)
return GeoPoint{Longitude: boundary, Latitude: a.Latitude + fraction*(b.Latitude-a.Latitude)}
return sphericalLongitudeIntersection(a, b, boundary, b.Longitude)
}
return clipPolygon(points, inside, intersection)
}
@@ -216,9 +375,63 @@ func clipPolygon(
}
func hemisphereIntersection(a, b GeoPoint) GeoPoint {
dLongitude := normalizeLongitude(b.Longitude - a.Longitude)
fraction := -a.Latitude / (b.Latitude - a.Latitude)
return GeoPoint{Longitude: normalizeLongitude(a.Longitude + fraction*dLongitude), Latitude: 0}
first := a
second := b
firstVector := geoPointVector(first)
secondVector := geoPointVector(second)
firstSign := firstVector.z
secondSign := secondVector.z
if math.Abs(firstSign) <= 1e-15 {
return GeoPoint{Longitude: normalizeLongitude(first.Longitude), Latitude: 0}
}
if math.Abs(secondSign) <= 1e-15 {
return GeoPoint{Longitude: normalizeLongitude(second.Longitude), Latitude: 0}
}
left, right := 0.0, 1.0
for iteration := 0; iteration < 64; iteration++ {
middle := (left + right) / 2
point := sphericalInterpolate(first, second, middle)
if point.Latitude == 0 || right-left <= 1e-13 {
return GeoPoint{Longitude: normalizeLongitude(point.Longitude), Latitude: 0}
}
if point.Latitude*first.Latitude > 0 {
left = middle
} else {
right = middle
}
}
point := sphericalInterpolate(first, second, (left+right)/2)
return GeoPoint{Longitude: normalizeLongitude(point.Longitude), Latitude: 0}
}
func sphericalLongitudeIntersection(a, b GeoPoint, boundary, adjustedLongitude float64) GeoPoint {
firstLongitude := a.Longitude
secondLongitude := adjustedLongitude
if math.Abs(secondLongitude-firstLongitude) <= 1e-14 {
return GeoPoint{Longitude: boundary, Latitude: a.Latitude}
}
left, right := 0.0, 1.0
for iteration := 0; iteration < 64; iteration++ {
middle := (left + right) / 2
point := sphericalInterpolate(a, b, middle)
middleLongitude := point.Longitude
for middleLongitude-firstLongitude > 180 {
middleLongitude -= 360
}
for middleLongitude-firstLongitude < -180 {
middleLongitude += 360
}
if math.Abs(middleLongitude-boundary) <= 1e-12 || right-left <= 1e-13 {
return GeoPoint{Longitude: boundary, Latitude: point.Latitude}
}
if (firstLongitude-boundary)*(middleLongitude-boundary) <= 0 {
right = middle
} else {
left = middle
}
}
point := sphericalInterpolate(a, b, (left+right)/2)
return GeoPoint{Longitude: boundary, Latitude: point.Latitude}
}
func normalizeLongitude(value float64) float64 {
@@ -228,3 +441,51 @@ func normalizeLongitude(value float64) float64 {
}
return value - 180
}
// equirectangularSeamShift 返回把等经纬接缝从 ±180 搬到视图中心对面所需的经度旋转量。
// 视图中心为 0 时返回 0,调用方即可沿用未旋转的原有路径,保证既有输出逐字节不变。
func equirectangularSeamShift(view ClipView) float64 {
if view.Projection != ProjectionEquirectangular || view.Center.Longitude == 0 {
return 0
}
return view.Center.Longitude
}
// splitPolylineAtSeam 先把经度旋转到接缝落在 ±180 的坐标系,交给原有分割逻辑,再旋转回来。
func splitPolylineAtSeam(points []GeoPoint, shift float64) [][]GeoPoint {
segments := splitPolylineAntimeridian(rotateLongitudes(points, -shift))
for _, segment := range segments {
rotateLongitudesInPlace(segment, shift)
}
return segments
}
// splitPolygonAtSeam 与 splitPolylineAtSeam 同理,供多边形使用。
func splitPolygonAtSeam(points []GeoPoint, shift float64) [][]GeoPoint {
fragments := splitPolygonAntimeridian(rotateLongitudes(points, -shift))
for _, fragment := range fragments {
rotateLongitudesInPlace(fragment, shift)
}
return fragments
}
func rotateLongitudes(points []GeoPoint, shift float64) []GeoPoint {
rotated := make([]GeoPoint, len(points))
copy(rotated, points)
rotateLongitudesInPlace(rotated, shift)
return rotated
}
func rotateLongitudesInPlace(points []GeoPoint, shift float64) {
for index := range points {
points[index].Longitude = normalizeLongitude180(points[index].Longitude + shift)
}
}
func normalizeLongitude180(longitude float64) float64 {
value := math.Mod(longitude+180, 360)
if value < 0 {
value += 360
}
return value - 180
}
+280 -2
View File
@@ -9,7 +9,7 @@ func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) {
segments := PolylineSegments([]GeoPoint{
{Longitude: -180, Latitude: 10},
{Longitude: 180, Latitude: 20},
}, ProjectionEquirectangular)
}, ClipView{Projection: ProjectionEquirectangular})
if len(segments) != 1 || len(segments[0]) != 2 {
t.Fatalf("exact-antimeridian line segments = %#v", segments)
}
@@ -19,6 +19,49 @@ func TestPolylineSegmentsTreatsExactAntimeridianAsOneMeridian(t *testing.T) {
}
}
func TestPolylineSegmentsInterpolatesAntimeridianOnSphere(t *testing.T) {
segments := PolylineSegments([]GeoPoint{
{Longitude: 170, Latitude: 80},
{Longitude: -170, Latitude: 80},
}, ClipView{Projection: ProjectionEquirectangular})
if len(segments) != 2 {
t.Fatalf("high-latitude antimeridian line segments = %d, want 2", len(segments))
}
firstCrossing := segments[0][len(segments[0])-1]
secondCrossing := segments[1][0]
if firstCrossing.Longitude != 180 || secondCrossing.Longitude != -180 {
t.Fatalf("unexpected antimeridian crossings: %+v %+v", firstCrossing, secondCrossing)
}
if math.Abs(firstCrossing.Latitude-secondCrossing.Latitude) > 1e-10 {
t.Fatalf("split crossings disagree in latitude: %.12f != %.12f",
firstCrossing.Latitude, secondCrossing.Latitude)
}
// The shorter great-circle arc bends poleward. Linear longitude/latitude
// interpolation would incorrectly leave the crossing at exactly 80 degrees.
if firstCrossing.Latitude <= 80.1 || firstCrossing.Latitude >= 81 {
t.Fatalf("great-circle crossing latitude = %.9f, want (80.1, 81)", firstCrossing.Latitude)
}
}
func TestPolarPolylineClippingUsesSphericalEquatorIntersection(t *testing.T) {
segments := PolylineSegments([]GeoPoint{
{Longitude: 0, Latitude: -10},
{Longitude: 90, Latitude: 80},
}, ClipView{Projection: ProjectionNorthPolar})
if len(segments) != 1 || len(segments[0]) != 2 {
t.Fatalf("north-polar clipped line = %#v", segments)
}
crossing := segments[0][0]
if math.Abs(crossing.Latitude) > 1e-10 {
t.Fatalf("equator crossing latitude = %.12f", crossing.Latitude)
}
// The great circle crosses near 2 degrees, not at the 45 degree
// arithmetic interpolation used by planar clipping.
if crossing.Longitude < 1 || crossing.Longitude > 3 {
t.Fatalf("spherical equator crossing longitude = %.9f, want (1, 3)", crossing.Longitude)
}
}
func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) {
fragments := PolygonFragments([]GeoPoint{
{Longitude: -180, Latitude: 15},
@@ -27,7 +70,7 @@ func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) {
{Longitude: 150, Latitude: -12},
{Longitude: 180, Latitude: -11},
{Longitude: -180, Latitude: -10},
}, ProjectionEquirectangular)
}, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) != 1 {
t.Fatalf("exact-antimeridian polygon produced %d fragments, want 1", len(fragments))
}
@@ -35,3 +78,238 @@ func TestPolygonFragmentsDropsExactAntimeridianZeroAreaDuplicate(t *testing.T) {
t.Fatal("exact-antimeridian polygon fragment has zero area")
}
}
func TestPolygonFragmentsClosesPoleEnclosingRingsAtEquirectangularMapEdge(t *testing.T) {
for _, test := range []struct {
name string
boundaryLatitude float64
insideLatitude float64
outsideLatitude float64
}{
{name: "north", boundaryLatitude: 70, insideLatitude: 89, outsideLatitude: -89},
{name: "south", boundaryLatitude: -70, insideLatitude: -89, outsideLatitude: 89},
} {
t.Run(test.name, func(t *testing.T) {
polygon := []GeoPoint{
{Longitude: -135, Latitude: test.boundaryLatitude},
{Longitude: -45, Latitude: test.boundaryLatitude},
{Longitude: 45, Latitude: test.boundaryLatitude},
{Longitude: 135, Latitude: test.boundaryLatitude},
{Longitude: -135, Latitude: test.boundaryLatitude},
}
fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) == 0 {
t.Fatal("pole-enclosing polygon produced no fragments")
}
for _, longitude := range []float64{-150, -90, 0, 90, 150} {
if !polygonFragmentsContain(fragments, longitude, test.insideLatitude) {
t.Fatalf("pole cap does not contain %.0f, %.0f", longitude, test.insideLatitude)
}
if polygonFragmentsContain(fragments, longitude, test.outsideLatitude) {
t.Fatalf("pole cap incorrectly contains %.0f, %.0f", longitude, test.outsideLatitude)
}
}
})
}
}
func TestPolygonFragmentsPoleClosurePreservesAntimeridianSeam(t *testing.T) {
for _, test := range []struct {
name string
poleLatitude float64
boundary float64
}{
{name: "north", poleLatitude: 90, boundary: 50},
{name: "south", poleLatitude: -90, boundary: -50},
} {
t.Run(test.name, func(t *testing.T) {
polygon := []GeoPoint{
{Longitude: 150, Latitude: test.boundary},
{Longitude: 180, Latitude: 5},
{Longitude: 120, Latitude: -5},
{Longitude: 30, Latitude: 15},
{Longitude: -60, Latitude: 25},
{Longitude: -150, Latitude: 35},
}
if test.poleLatitude < 0 {
polygon = mirrorGeoPointLatitudes(polygon)
}
fragments := PolygonFragments(polygon, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) == 0 || len(fragments) > 2 {
t.Fatalf("pole polygon fragments=%d, want one or two map-edge fragments", len(fragments))
}
for _, longitude := range []float64{149, 151} {
latitude := 60.0
if test.poleLatitude < 0 {
latitude = -60
}
if !polygonFragmentsContain(fragments, longitude, latitude) {
t.Fatalf("pole closure lost %.0f, %.0f beside its internal seam", longitude, latitude)
}
}
})
}
}
func TestPolygonFragmentsPoleClosureAnchorsInteriorStartAtAntimeridian(t *testing.T) {
// This ring winds around the north pole but starts in the Bering Sea. The
// pole closure must use the map seam, not a meridian through that start
// vertex.
fragments := PolygonFragments([]GeoPoint{
{Longitude: -172, Latitude: 50},
{Longitude: -100, Latitude: 70},
{Longitude: 0, Latitude: 80},
{Longitude: 100, Latitude: 70},
{Longitude: 188, Latitude: 50},
}, ClipView{Projection: ProjectionEquirectangular})
if len(fragments) == 0 {
t.Fatal("pole-winding ring produced no fragments")
}
for _, fragment := range fragments {
for index, first := range fragment {
second := fragment[(index+1)%len(fragment)]
if math.Abs(first.Longitude-second.Longitude) <= 1e-9 &&
math.Abs(first.Latitude-second.Latitude) > 20 &&
math.Abs(first.Longitude) < 179.999 {
t.Fatalf("interior pole closure at %.3f: %+v -> %+v", first.Longitude, first, second)
}
}
}
}
func TestPolygonFragmentsPoleClosureDoesNotDependOnVertexDensity(t *testing.T) {
base := []GeoPoint{
{Longitude: -135, Latitude: 70},
{Longitude: -45, Latitude: 70},
{Longitude: 45, Latitude: -60},
{Longitude: 135, Latitude: -60},
}
dense := []GeoPoint{
{Longitude: -135, Latitude: 70},
{Longitude: -45, Latitude: 70},
{Longitude: 45, Latitude: -60},
{Longitude: 55, Latitude: -60},
{Longitude: 65, Latitude: -60},
{Longitude: 75, Latitude: -60},
{Longitude: 85, Latitude: -60},
{Longitude: 95, Latitude: -60},
{Longitude: 105, Latitude: -60},
{Longitude: 115, Latitude: -60},
{Longitude: 125, Latitude: -60},
{Longitude: 135, Latitude: -60},
}
for _, test := range []struct {
name string
base []GeoPoint
dense []GeoPoint
}{
{name: "north", base: base, dense: dense},
{name: "south", base: mirrorGeoPointLatitudes(base), dense: mirrorGeoPointLatitudes(dense)},
} {
t.Run(test.name, func(t *testing.T) {
baseFragments := PolygonFragments(test.base, ClipView{Projection: ProjectionEquirectangular})
denseFragments := PolygonFragments(test.dense, ClipView{Projection: ProjectionEquirectangular})
for _, point := range []GeoPoint{
{Longitude: 0, Latitude: 89},
{Longitude: 0, Latitude: -89},
} {
baseContains := polygonFragmentsContain(baseFragments, point.Longitude, point.Latitude)
denseContains := polygonFragmentsContain(denseFragments, point.Longitude, point.Latitude)
if denseContains != baseContains {
t.Fatalf("densifying a boundary changed containment at %.0f, %.0f: base=%v dense=%v",
point.Longitude, point.Latitude, baseContains, denseContains)
}
}
})
}
}
func TestPolygonFragmentsPolarIndentationPreservesInterior(t *testing.T) {
// A polar boundary crosses the map seam three times. Closing through the
// lowest crossing fills the excluded indentation on the east side.
polygon := []GeoPoint{
{Longitude: -172, Latitude: 50}, {Longitude: -179, Latitude: 54},
{Longitude: 175, Latitude: 60}, {Longitude: 173, Latitude: 65},
{Longitude: 175, Latitude: 70}, {Longitude: -178, Latitude: 75},
{Longitude: -140, Latitude: 79}, {Longitude: -70, Latitude: 68},
{Longitude: 0, Latitude: 15}, {Longitude: 90, Latitude: 0},
{Longitude: 140, Latitude: -23}, {Longitude: 175, Latitude: 30},
{Longitude: -179, Latitude: 40},
}
for _, sign := range []float64{1, -1} {
points := append([]GeoPoint(nil), polygon...)
for i := range points {
points[i].Latitude *= sign
}
for offset := range points {
rotated := append(append([]GeoPoint(nil), points[offset:]...), points[:offset]...)
for _, reverse := range []bool{false, true} {
if reverse {
for i, j := 0, len(rotated)-1; i < j; i, j = i+1, j-1 {
rotated[i], rotated[j] = rotated[j], rotated[i]
}
}
fragments := PolygonFragments(rotated, ClipView{Projection: ProjectionEquirectangular})
for _, point := range []GeoPoint{
{Longitude: 179, Latitude: sign * 65}, {Longitude: 175, Latitude: sign * 65},
{Longitude: 170, Latitude: sign * 65}, {Longitude: -179, Latitude: sign * 65},
{Longitude: 179, Latitude: sign * 85}, {Longitude: 0, Latitude: sign * 85},
} {
want := SphericalPolygonsContainPoints([][]GeoPoint{rotated}, []GeoPoint{point})[0]
if got := polygonFragmentsContain(fragments, point.Longitude, point.Latitude); got != want {
t.Fatalf("sign=%g offset=%d reversed=%v point=%+v: split=%v sphere=%v", sign, offset, reverse, point, got, want)
}
}
}
}
}
}
func mirrorGeoPointLatitudes(points []GeoPoint) []GeoPoint {
mirrored := make([]GeoPoint, len(points))
for index, point := range points {
mirrored[index] = GeoPoint{Longitude: point.Longitude, Latitude: -point.Latitude}
}
return mirrored
}
func polygonFragmentsContain(fragments [][]GeoPoint, longitude, latitude float64) bool {
for _, polygon := range fragments {
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
a, b := polygon[previous], polygon[current]
if (a.Latitude > latitude) == (b.Latitude > latitude) {
continue
}
intersection := a.Longitude +
(latitude-a.Latitude)*(b.Longitude-a.Longitude)/(b.Latitude-a.Latitude)
if intersection > longitude {
inside = !inside
}
}
if inside {
return true
}
}
return false
}
func TestClosePoleEnclosingPolygonRequiresSingleWinding(t *testing.T) {
open := []GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 1, Latitude: 0},
{Longitude: 1, Latitude: 1},
}
if closed := closePoleEnclosingPolygon(open); len(closed) != len(open) {
t.Fatalf("a ring without pole winding gained %d closure points", len(closed)-len(open))
}
winding := []GeoPoint{
{Longitude: -135, Latitude: 70},
{Longitude: -45, Latitude: 70},
{Longitude: 45, Latitude: 70},
{Longitude: 135, Latitude: 70},
}
if closed := closePoleEnclosingPolygon(winding); len(closed) <= len(winding) {
t.Fatalf("a single-winding ring kept %d points, want a pole closure", len(closed))
}
}