feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user