2026-08-06 12:00:56 +08:00
|
|
|
package geodata
|
|
|
|
|
|
|
|
|
|
import "math"
|
|
|
|
|
|
|
|
|
|
// PolylineSegments 将地理折线裁剪到选定投影并 / PolylineSegments clips a geographic polyline to the selected projection and
|
|
|
|
|
// 在等经纬投影中按日界线拆分路径 / splits equirectangular paths at the antimeridian.
|
2026-09-17 12:27:40 +08:00
|
|
|
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
|
2026-08-06 12:00:56 +08:00
|
|
|
if projection == ProjectionNorthPolar {
|
|
|
|
|
return clipPolylineHemisphere(points, 1)
|
|
|
|
|
}
|
|
|
|
|
if projection == ProjectionSouthPolar {
|
|
|
|
|
return clipPolylineHemisphere(points, -1)
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
if shift := equirectangularSeamShift(view); shift != 0 {
|
|
|
|
|
return splitPolylineAtSeam(points, shift)
|
|
|
|
|
}
|
2026-08-06 12:00:56 +08:00
|
|
|
return splitPolylineAntimeridian(points)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PolygonFragments 将地理多边形裁剪到选定地图范围 / PolygonFragments clips a geographic polygon to the selected map extent.
|
2026-09-17 12:27:40 +08:00
|
|
|
func PolygonFragments(points []GeoPoint, view ClipView) [][]GeoPoint {
|
2026-08-06 12:00:56 +08:00
|
|
|
if len(points) < 3 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
prepared, ok := prepareTopologyPoints(points)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
points = prepared
|
|
|
|
|
if view.Orthographic() {
|
|
|
|
|
return polygonFragmentsOrthographic(points, view.Center)
|
|
|
|
|
}
|
|
|
|
|
projection := view.Projection
|
2026-08-06 12:00:56 +08:00
|
|
|
if projection == ProjectionNorthPolar {
|
|
|
|
|
if clipped := clipPolygonHemisphere(points, 1); len(clipped) >= 3 {
|
|
|
|
|
return [][]GeoPoint{clipped}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if projection == ProjectionSouthPolar {
|
|
|
|
|
if clipped := clipPolygonHemisphere(points, -1); len(clipped) >= 3 {
|
|
|
|
|
return [][]GeoPoint{clipped}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
if shift := equirectangularSeamShift(view); shift != 0 {
|
|
|
|
|
return splitPolygonAtSeam(points, shift)
|
|
|
|
|
}
|
2026-08-06 12:00:56 +08:00
|
|
|
return splitPolygonAntimeridian(points)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
func splitPolylineAntimeridian(points []GeoPoint) [][]GeoPoint {
|
|
|
|
|
if len(points) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
segments := make([][]GeoPoint, 0, 2)
|
|
|
|
|
current := []GeoPoint{points[0]}
|
|
|
|
|
for index := 1; index < len(points); index++ {
|
|
|
|
|
a, b := points[index-1], points[index]
|
|
|
|
|
if (a.Longitude == -180 && b.Longitude == 180) || (a.Longitude == 180 && b.Longitude == -180) {
|
|
|
|
|
// -180/+180 的精确端点属于同一子午线,不要 / Exact -180/+180 endpoints are the same meridian; do not
|
|
|
|
|
// 不要让它们进入分母为零的交叉插值 / feed them into the crossing interpolation with a zero denominator.
|
|
|
|
|
b.Longitude = a.Longitude
|
|
|
|
|
current = append(current, b)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(b.Longitude-a.Longitude) <= 180 {
|
|
|
|
|
current = append(current, b)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
boundary := 180.0
|
|
|
|
|
adjustedLongitude := b.Longitude
|
|
|
|
|
if a.Longitude < 0 {
|
|
|
|
|
boundary = -180
|
|
|
|
|
adjustedLongitude -= 360
|
|
|
|
|
} else {
|
|
|
|
|
adjustedLongitude += 360
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
crossing := sphericalLongitudeIntersection(a, b, boundary, adjustedLongitude)
|
2026-08-06 12:00:56 +08:00
|
|
|
current = append(current, crossing)
|
|
|
|
|
if len(current) >= 2 {
|
|
|
|
|
segments = append(segments, current)
|
|
|
|
|
}
|
|
|
|
|
crossing.Longitude = -boundary
|
|
|
|
|
current = []GeoPoint{crossing, b}
|
|
|
|
|
}
|
|
|
|
|
if len(current) >= 2 {
|
|
|
|
|
segments = append(segments, current)
|
|
|
|
|
}
|
|
|
|
|
return segments
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clipPolylineHemisphere(points []GeoPoint, hemisphere float64) [][]GeoPoint {
|
|
|
|
|
if len(points) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
inside := func(value GeoPoint) bool { return value.Latitude*hemisphere >= 0 }
|
|
|
|
|
var segments [][]GeoPoint
|
|
|
|
|
var current []GeoPoint
|
|
|
|
|
for index, point := range points {
|
|
|
|
|
pointInside := inside(point)
|
|
|
|
|
if index == 0 {
|
|
|
|
|
if pointInside {
|
|
|
|
|
current = append(current, point)
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
previous := points[index-1]
|
|
|
|
|
previousInside := inside(previous)
|
|
|
|
|
if previousInside != pointInside {
|
|
|
|
|
crossing := hemisphereIntersection(previous, point)
|
|
|
|
|
if previousInside {
|
|
|
|
|
current = append(current, crossing)
|
|
|
|
|
if len(current) >= 2 {
|
|
|
|
|
segments = append(segments, current)
|
|
|
|
|
}
|
|
|
|
|
current = nil
|
|
|
|
|
} else {
|
|
|
|
|
current = []GeoPoint{crossing}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if pointInside {
|
|
|
|
|
current = append(current, point)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(current) >= 2 {
|
|
|
|
|
segments = append(segments, current)
|
|
|
|
|
}
|
|
|
|
|
return segments
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clipPolygonHemisphere(points []GeoPoint, hemisphere float64) []GeoPoint {
|
|
|
|
|
inside := func(value GeoPoint) bool { return value.Latitude*hemisphere >= 0 }
|
|
|
|
|
return clipPolygon(points, inside, hemisphereIntersection)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func splitPolygonAntimeridian(points []GeoPoint) [][]GeoPoint {
|
|
|
|
|
unwrapped := make([]GeoPoint, len(points))
|
|
|
|
|
unwrapped[0] = points[0]
|
|
|
|
|
for index := 1; index < len(points); index++ {
|
|
|
|
|
point := points[index]
|
|
|
|
|
previous := unwrapped[index-1].Longitude
|
2026-09-17 12:27:40 +08:00
|
|
|
// 一次取整到最近的 360 倍数,等价于反复加减 360 但不随偏移量增长。
|
|
|
|
|
if offset := point.Longitude - previous; offset > 180 || offset < -180 {
|
|
|
|
|
point.Longitude -= 360 * math.Round(offset/360)
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
unwrapped[index] = point
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
unwrapped = closePoleEnclosingPolygon(unwrapped)
|
2026-08-06 12:00:56 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
if span := maximum - minimum; !(span >= 0 && span <= 3*360) {
|
|
|
|
|
// 经度归一化后每个点最多偏离 ±180 再加一次 360 的展开,跨度不可能超过三个世界。
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-08-06 12:00:56 +08:00
|
|
|
firstWorld := int(math.Floor((minimum + 180) / 360))
|
|
|
|
|
lastWorld := int(math.Floor((maximum + 180) / 360))
|
|
|
|
|
var fragments [][]GeoPoint
|
|
|
|
|
for world := firstWorld; world <= lastWorld; world++ {
|
|
|
|
|
left := -180.0 + 360*float64(world)
|
|
|
|
|
right := 180.0 + 360*float64(world)
|
|
|
|
|
clipped := clipPolygonLongitude(unwrapped, left, true)
|
|
|
|
|
clipped = clipPolygonLongitude(clipped, right, false)
|
|
|
|
|
if len(clipped) < 3 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for index := range clipped {
|
|
|
|
|
clipped[index].Longitude -= 360 * float64(world)
|
|
|
|
|
}
|
|
|
|
|
if math.Abs(signedPolygonArea(clipped)) < 1e-12 {
|
|
|
|
|
// 边恰好落在日界线上的多边形可能在相邻世界各输出一次 / A polygon whose edge lies exactly on the antimeridian can be
|
|
|
|
|
// 可能在相邻世界各输出一次;丢弃重复的 / emitted once for each adjacent world. Drop the duplicate
|
|
|
|
|
// 零面积片段后再做 GeoJSON 环验证 / zero-area fragment before GeoJSON ring validation.
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
fragments = append(fragments, clipped)
|
|
|
|
|
}
|
|
|
|
|
return fragments
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-17 12:27:40 +08:00
|
|
|
// 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},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 12:00:56 +08:00
|
|
|
func signedPolygonArea(points []GeoPoint) float64 {
|
|
|
|
|
if len(points) < 3 {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
area := 0.0
|
|
|
|
|
for index, point := range points {
|
|
|
|
|
next := points[(index+1)%len(points)]
|
|
|
|
|
area += point.Longitude*next.Latitude - next.Longitude*point.Latitude
|
|
|
|
|
}
|
|
|
|
|
return area / 2
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clipPolygonLongitude(points []GeoPoint, boundary float64, keepGreater bool) []GeoPoint {
|
|
|
|
|
inside := func(value GeoPoint) bool {
|
|
|
|
|
if keepGreater {
|
|
|
|
|
return value.Longitude >= boundary
|
|
|
|
|
}
|
|
|
|
|
return value.Longitude <= boundary
|
|
|
|
|
}
|
|
|
|
|
intersection := func(a, b GeoPoint) GeoPoint {
|
2026-09-17 12:27:40 +08:00
|
|
|
return sphericalLongitudeIntersection(a, b, boundary, b.Longitude)
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
return clipPolygon(points, inside, intersection)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clipPolygon(
|
|
|
|
|
points []GeoPoint,
|
|
|
|
|
inside func(GeoPoint) bool,
|
|
|
|
|
intersection func(GeoPoint, GeoPoint) GeoPoint,
|
|
|
|
|
) []GeoPoint {
|
|
|
|
|
if len(points) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
result := make([]GeoPoint, 0, len(points)+2)
|
|
|
|
|
previous := points[len(points)-1]
|
|
|
|
|
previousInside := inside(previous)
|
|
|
|
|
for _, current := range points {
|
|
|
|
|
currentInside := inside(current)
|
|
|
|
|
if currentInside != previousInside {
|
|
|
|
|
result = append(result, intersection(previous, current))
|
|
|
|
|
}
|
|
|
|
|
if currentInside {
|
|
|
|
|
result = append(result, current)
|
|
|
|
|
}
|
|
|
|
|
previous = current
|
|
|
|
|
previousInside = currentInside
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func hemisphereIntersection(a, b GeoPoint) GeoPoint {
|
2026-09-17 12:27:40 +08:00
|
|
|
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}
|
2026-08-06 12:00:56 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizeLongitude(value float64) float64 {
|
|
|
|
|
value = math.Mod(value+180, 360)
|
|
|
|
|
if value < 0 {
|
|
|
|
|
value += 360
|
|
|
|
|
}
|
|
|
|
|
return value - 180
|
|
|
|
|
}
|
2026-09-17 12:27:40 +08:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|