feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,940 @@
|
||||
package occultationgeo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"b612.me/astro/basic"
|
||||
"b612.me/astro/internal/geodata"
|
||||
)
|
||||
|
||||
func cleanupOccultationVisibleBandPolygons(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
strongPolarSmoothing bool,
|
||||
) [][]geodata.GeoPoint {
|
||||
polygons = RemoveTinyPolygonComponents(polygons)
|
||||
maximumDisplayEdgeKM := 150.0
|
||||
if strongPolarSmoothing {
|
||||
// Inner-contact Saturn fallbacks retain the sampled sweep rather than a
|
||||
// selected linework face. Keep their rendered chords at the same scale as
|
||||
// the authoritative path so the polar edge cannot appear stepped.
|
||||
maximumDisplayEdgeKM = 40
|
||||
}
|
||||
densified := densifyOccultationPolygons(polygons, maximumDisplayEdgeKM)
|
||||
for index := range densified {
|
||||
// Densification can turn one long, thin numerical return into more than
|
||||
// a dozen short edges. Inspect a wider local window, but require a much
|
||||
// larger detour before removing it so ordinary rounded cusps survive.
|
||||
densified[index] = removeOccultationHairpins(densified[index], 35, 25, 12)
|
||||
densified[index] = removeOccultationHairpins(densified[index], 100, 25, 32)
|
||||
}
|
||||
if strongPolarSmoothing {
|
||||
for index := range densified {
|
||||
densified[index] = smoothOccultationHairpins(densified[index], 180, 35, 16, 50)
|
||||
densified[index] = removeOccultationPolarKinks(densified[index])
|
||||
densified[index] = smoothOccultationPolarCorners(densified[index])
|
||||
}
|
||||
}
|
||||
for index := range densified {
|
||||
densified[index] = removeOccultationSharpCorners(densified[index], 20, 30)
|
||||
}
|
||||
for index := range densified {
|
||||
densified[index] = smoothOccultationPolarWobbles(densified[index])
|
||||
densified[index] = smoothOccultationOrdinaryWobbles(densified[index])
|
||||
}
|
||||
return densified
|
||||
}
|
||||
|
||||
// smoothOccultationOrdinaryWobbles removes the small alternating envelope
|
||||
// error left when adjacent temporal footprints contribute different sampled
|
||||
// limb vertices. A projected five-point filter is used so the correction
|
||||
// follows the Web Mercator chart seen by map clients. The displacement and
|
||||
// local-deviation gates remain deliberately small, so this also handles the
|
||||
// high-latitude portion of a smooth arc without flattening a real horizon fold.
|
||||
func smoothOccultationOrdinaryWobbles(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
if len(points) < 7 {
|
||||
return points
|
||||
}
|
||||
closed := len(points) > 1 && geodata.SameGeoPoint(points[0], points[len(points)-1])
|
||||
limit := len(points)
|
||||
if closed {
|
||||
limit--
|
||||
}
|
||||
if limit < 7 {
|
||||
return points
|
||||
}
|
||||
result := append([]geodata.GeoPoint(nil), points...)
|
||||
original := append([]geodata.GeoPoint(nil), points...)
|
||||
const (
|
||||
minimumLatitude = 0.5
|
||||
maximumLatitude = 85.0
|
||||
maximumMoveKM = 20.0
|
||||
maximumDeviationKM = 40.0
|
||||
)
|
||||
for pass := 0; pass < 12; pass++ {
|
||||
updated := append([]geodata.GeoPoint(nil), result...)
|
||||
for index := 2; index+2 < limit; index++ {
|
||||
point := result[index]
|
||||
if math.Abs(point.Latitude) < minimumLatitude || math.Abs(point.Latitude) > maximumLatitude {
|
||||
continue
|
||||
}
|
||||
weights := [...]float64{-3, 12, 17, 12, -3}
|
||||
var x, y float64
|
||||
for offset := -2; offset <= 2; offset++ {
|
||||
projected := occultationProjectedPoint(result[index+offset])
|
||||
x += weights[offset+2] * projected[0]
|
||||
y += weights[offset+2] * projected[1]
|
||||
}
|
||||
candidate := occultationUnprojectedPoint(x/35, y/35)
|
||||
move := geoDistanceKM(original[index], candidate)
|
||||
if move > maximumMoveKM {
|
||||
continue
|
||||
}
|
||||
first := result[index-2]
|
||||
last := result[index+2]
|
||||
deviation := occultationProjectedPointLineDistanceKM(point, first, last)
|
||||
if deviation > maximumDeviationKM {
|
||||
continue
|
||||
}
|
||||
updated[index] = candidate
|
||||
}
|
||||
result = updated
|
||||
}
|
||||
if closed {
|
||||
result[len(result)-1] = result[0]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func occultationProjectedPoint(point geodata.GeoPoint) [2]float64 {
|
||||
latitude := math.Max(-85.05112878, math.Min(85.05112878, point.Latitude)) * math.Pi / 180
|
||||
return [2]float64{
|
||||
EarthRadiusKM * point.Longitude * math.Pi / 180,
|
||||
EarthRadiusKM * math.Log(math.Tan(math.Pi/4+latitude/2)),
|
||||
}
|
||||
}
|
||||
|
||||
func occultationUnprojectedPoint(pointX, pointY float64) geodata.GeoPoint {
|
||||
longitude := pointX / EarthRadiusKM * 180 / math.Pi
|
||||
longitude = math.Mod(longitude+180, 360)
|
||||
if longitude < 0 {
|
||||
longitude += 360
|
||||
}
|
||||
return geodata.GeoPoint{
|
||||
Longitude: longitude - 180,
|
||||
Latitude: (2*math.Atan(math.Exp(pointY/EarthRadiusKM)) - math.Pi/2) * 180 / math.Pi,
|
||||
}
|
||||
}
|
||||
|
||||
func occultationProjectedPointLineDistanceKM(point, first, last geodata.GeoPoint) float64 {
|
||||
p := occultationProjectedPoint(point)
|
||||
a := occultationProjectedPoint(first)
|
||||
b := occultationProjectedPoint(last)
|
||||
dx, dy := b[0]-a[0], b[1]-a[1]
|
||||
if lengthSquared := dx*dx + dy*dy; lengthSquared > 0 {
|
||||
fraction := ((p[0]-a[0])*dx + (p[1]-a[1])*dy) / lengthSquared
|
||||
fraction = math.Max(0, math.Min(1, fraction))
|
||||
return math.Hypot(p[0]-(a[0]+fraction*dx), p[1]-(a[1]+fraction*dy))
|
||||
}
|
||||
return math.Hypot(p[0]-a[0], p[1]-a[1])
|
||||
}
|
||||
|
||||
func occultationProjectedTurnAngleDegrees(
|
||||
first, middle, last geodata.GeoPoint,
|
||||
) float64 {
|
||||
firstProjected := occultationProjectedPoint(first)
|
||||
middleProjected := occultationProjectedPoint(middle)
|
||||
lastProjected := occultationProjectedPoint(last)
|
||||
firstX, firstY := firstProjected[0]-middleProjected[0], firstProjected[1]-middleProjected[1]
|
||||
lastX, lastY := lastProjected[0]-middleProjected[0], lastProjected[1]-middleProjected[1]
|
||||
firstLength := math.Hypot(firstX, firstY)
|
||||
lastLength := math.Hypot(lastX, lastY)
|
||||
if firstLength <= 1e-12 || lastLength <= 1e-12 {
|
||||
return 180
|
||||
}
|
||||
cosine := (firstX*lastX + firstY*lastY) / (firstLength * lastLength)
|
||||
cosine = math.Max(-1, math.Min(1, cosine))
|
||||
return math.Acos(cosine) * 180 / math.Pi
|
||||
}
|
||||
|
||||
// cleanupOccultationFootprintUnionPolygons keeps every component of the
|
||||
// horizon-closed instantaneous union. Unlike the compact-band cleaner it does
|
||||
// not discard small components: at a horizon transition a narrow component is
|
||||
// still a real visible portion of the time union, not a polygonizer sliver.
|
||||
func cleanupOccultationFootprintUnionPolygons(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
strongPolarSmoothing bool,
|
||||
) [][]geodata.GeoPoint {
|
||||
result := make([][]geodata.GeoPoint, 0, len(polygons))
|
||||
// A disconnected horizon union is already the authoritative topology. In
|
||||
// particular, Saturn's polar samples contain many narrow real components;
|
||||
// moving their vertices can bridge a below-horizon cap. Keep those rings
|
||||
// fixed and only densify their projected edges for rendering.
|
||||
for _, polygon := range polygons {
|
||||
if len(openFootprintRing(polygon)) < 3 || math.Abs(geoRingArea(polygon)) <= 1e-12 {
|
||||
continue
|
||||
}
|
||||
ring := densifyOccultationPolygons([][]geodata.GeoPoint{polygon}, 40)[0]
|
||||
if len(polygons) == 1 {
|
||||
if strongPolarSmoothing {
|
||||
ring = removeOccultationHairpins(ring, 190, 25, 32)
|
||||
} else {
|
||||
ring = removeOccultationHairpins(ring, 100, 25, 32)
|
||||
}
|
||||
}
|
||||
ring = removeOccultationSharpCorners(ring, 20, 30)
|
||||
ring = densifyOccultationPolygons([][]geodata.GeoPoint{ring}, 40)[0]
|
||||
if len(openFootprintRing(ring)) >= 3 && math.Abs(geoRingArea(ring)) > 1e-12 {
|
||||
result = append(result, ring)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Contact-contour output is already a physical continuous envelope. Clean
|
||||
// only the short, high-latitude reversals that are numerical polygonizer
|
||||
// vertices, then densify for display; long physical phase branches remain
|
||||
// untouched.
|
||||
func cleanupOccultationAuthoritativeBandPolygons(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
) [][]geodata.GeoPoint {
|
||||
polygons = RemoveTinyPolygonComponents(polygons)
|
||||
densified := densifyOccultationPolygons(polygons, 25)
|
||||
usable := make([][]geodata.GeoPoint, 0, len(densified))
|
||||
for _, polygon := range densified {
|
||||
open := openFootprintRing(polygon)
|
||||
if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 {
|
||||
continue
|
||||
}
|
||||
usable = append(usable, polygon)
|
||||
}
|
||||
densified = usable
|
||||
for index := range densified {
|
||||
densified[index] = removeOccultationPolarKinks(densified[index])
|
||||
densified[index] = removeOccultationSharpCorners(densified[index], 60, 45)
|
||||
densified[index] = removeOccultationPolarSharpCorners(densified[index], 20, 70, 70)
|
||||
densified[index] = smoothOccultationPolarCorners(densified[index])
|
||||
densified[index] = smoothOccultationPolarWobbles(densified[index])
|
||||
densified[index] = smoothOccultationOrdinaryWobbles(densified[index])
|
||||
}
|
||||
// The first cleanup can expose a short reversal at the interpolation seam
|
||||
// of an otherwise longer edge. Run the local cleanup once more before the
|
||||
// final spacing pass so the GeoJSON consumer receives both smooth turns and
|
||||
// bounded projected edges.
|
||||
densified = densifyOccultationPolygons(densified, 25)
|
||||
for index := range densified {
|
||||
densified[index] = removeOccultationPolarKinks(densified[index])
|
||||
densified[index] = removeOccultationSharpCorners(densified[index], 60, 45)
|
||||
densified[index] = removeOccultationPolarSharpCorners(densified[index], 20, 70, 70)
|
||||
densified[index] = smoothOccultationPolarCorners(densified[index])
|
||||
densified[index] = smoothOccultationPolarWobbles(densified[index])
|
||||
densified[index] = smoothOccultationOrdinaryWobbles(densified[index])
|
||||
}
|
||||
result := make([][]geodata.GeoPoint, 0, len(densified))
|
||||
for _, polygon := range densified {
|
||||
open := openFootprintRing(polygon)
|
||||
if len(open) >= 3 && math.Abs(geoRingArea(open)) > 1e-12 {
|
||||
result = append(result, polygon)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// preserveOccultationPhaseBoundaryEnvelope restores only the part of an
|
||||
// accepted physical phase cycle that bounded smoothing moved outside the
|
||||
// cleaned polygon. The source cycle is already topology- and witness-checked;
|
||||
// unioning it back cannot invent visibility, while leaving unrelated portions
|
||||
// of the cleaned ring unchanged.
|
||||
func preserveOccultationPhaseBoundaryEnvelope(
|
||||
cleaned, physicalBoundary [][]geodata.GeoPoint,
|
||||
) [][]geodata.GeoPoint {
|
||||
if len(cleaned) == 0 || len(physicalBoundary) == 0 {
|
||||
return cleaned
|
||||
}
|
||||
// The phase cycle can close two branches at a shared polar extremum. Unioning
|
||||
// that raw closure back into the cleaned footprint restores the very cusp the
|
||||
// cleanup removed, so round only that junction before the merge. The rounded
|
||||
// candidate is accepted only when it still contains the complete source cycle.
|
||||
roundedBoundary := roundOccultationPhaseBoundaryJunctions(physicalBoundary)
|
||||
input := append([][]geodata.GeoPoint(nil), cleaned...)
|
||||
input = append(input, roundedBoundary...)
|
||||
merged, err := geodata.UnionPolygons(input)
|
||||
if err != nil || len(merged) == 0 {
|
||||
return cleaned
|
||||
}
|
||||
merged = RemoveTinyPolygonComponents(merged)
|
||||
merged = densifyOccultationPolygons(merged, 40)
|
||||
return roundOccultationPhaseBoundaryJunctions(merged)
|
||||
}
|
||||
|
||||
// roundOccultationPhaseBoundaryJunctions replaces only a short polar phase
|
||||
// junction with a projected C1 curve. The replaced source window remains the
|
||||
// acceptance witness: if the outward fillet cannot contain it within the
|
||||
// source-specific numerical projection tolerance, the original ring is
|
||||
// returned unchanged rather than silently shrinking visibility.
|
||||
func roundOccultationPhaseBoundaryJunctions(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
) [][]geodata.GeoPoint {
|
||||
result := make([][]geodata.GeoPoint, len(polygons))
|
||||
for index, polygon := range polygons {
|
||||
result[index] = roundOccultationPhaseBoundaryRing(polygon)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roundOccultationAuthoritativeBandJunctions(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
) [][]geodata.GeoPoint {
|
||||
result := make([][]geodata.GeoPoint, len(polygons))
|
||||
for index, polygon := range polygons {
|
||||
rounded := roundOccultationAuthoritativeBandJunctionRing(polygon)
|
||||
result[index] = rounded
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roundOccultationPhaseBoundaryRing(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
return roundOccultationBandJunctionRing(points, 74, 75, 155, false)
|
||||
}
|
||||
|
||||
// roundOccultationAuthoritativeBandJunctionRing rounds a compact high-latitude
|
||||
// latitude return left by a temporal sweep. Unlike a normal sampled arc, these
|
||||
// seam vertices reverse latitude over a short chord and are shared by the
|
||||
// partial and total bands. The candidate remains subject to the source-window
|
||||
// containment gate in roundOccultationBandJunctionRing, so smoothing preserves
|
||||
// the local visible envelope within the numerical projection tolerance.
|
||||
func roundOccultationAuthoritativeBandJunctionRing(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
return roundOccultationBandJunctionRing(points, 60, 80, 125, true)
|
||||
}
|
||||
|
||||
func roundOccultationTotalBandJunctions(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
) [][]geodata.GeoPoint {
|
||||
result := roundOccultationAuthoritativeBandJunctions(polygons)
|
||||
for index := range result {
|
||||
result[index] = roundOccultationWidePolarShoulder(result[index])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// roundOccultationWidePolarShoulder repairs a broad concave shoulder where a
|
||||
// horizon phase cap joins the continuously swept inner-contact envelope. The
|
||||
// ordinary junction rounder above deliberately handles only short chords; a
|
||||
// several-hundred-kilometre shoulder therefore survives as a visible notch in
|
||||
// Web Mercator even though every individual vertex has a benign local turn.
|
||||
//
|
||||
// This pass is used only by total bands. It requires a high-latitude global
|
||||
// extremum, a sharp drop in secant slope on one adjacent arc, and a replacement
|
||||
// which contains the complete source ring. Ordinary convex polar arcs never
|
||||
// meet that slope signature and are returned unchanged.
|
||||
func roundOccultationWidePolarShoulder(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
if len(points) < 16 {
|
||||
return points
|
||||
}
|
||||
closed := geodata.SameGeoPoint(points[0], points[len(points)-1])
|
||||
open := append([]geodata.GeoPoint(nil), points...)
|
||||
if closed {
|
||||
open = open[:len(open)-1]
|
||||
}
|
||||
if len(open) < 15 {
|
||||
return points
|
||||
}
|
||||
extreme := 0
|
||||
for index := 1; index < len(open); index++ {
|
||||
if math.Abs(open[index].Latitude) > math.Abs(open[extreme].Latitude) {
|
||||
extreme = index
|
||||
}
|
||||
}
|
||||
if math.Abs(open[extreme].Latitude) < 75 {
|
||||
return points
|
||||
}
|
||||
type shoulderCandidate struct {
|
||||
ring []geodata.GeoPoint
|
||||
gap float64
|
||||
}
|
||||
var best shoulderCandidate
|
||||
for _, direction := range []int{1, -1} {
|
||||
candidate, gap, ok := occultationWidePolarShoulderCandidate(open, extreme, direction)
|
||||
if ok && gap > best.gap {
|
||||
best = shoulderCandidate{ring: candidate, gap: gap}
|
||||
}
|
||||
}
|
||||
if len(best.ring) == 0 {
|
||||
return points
|
||||
}
|
||||
if closed {
|
||||
best.ring = append(best.ring, best.ring[0])
|
||||
}
|
||||
return best.ring
|
||||
}
|
||||
|
||||
func occultationWidePolarShoulderCandidate(
|
||||
open []geodata.GeoPoint,
|
||||
extreme, direction int,
|
||||
) ([]geodata.GeoPoint, float64, bool) {
|
||||
const (
|
||||
minimumSpanKM = 150.0
|
||||
maximumSpanKM = 1200.0
|
||||
minimumSlopeDrop = 0.20
|
||||
minimumInwardGapKM = 20.0
|
||||
maximumInwardGapKM = 100.0
|
||||
containmentTolerance = 1.0
|
||||
)
|
||||
n := len(open)
|
||||
oriented := make([]geodata.GeoPoint, n)
|
||||
for index := range oriented {
|
||||
source := (extreme - direction + index*direction) % n
|
||||
if source < 0 {
|
||||
source += n
|
||||
}
|
||||
oriented[index] = open[source]
|
||||
}
|
||||
start := occultationProjectedPoint(oriented[1])
|
||||
polarSign := 1.0
|
||||
if oriented[1].Latitude > 0 {
|
||||
polarSign = -1
|
||||
}
|
||||
xSign := 0.0
|
||||
minimumSlope := math.Inf(1)
|
||||
maximumEarlySlope := 0.0
|
||||
anchor := -1
|
||||
for index := 2; index+1 < len(oriented); index++ {
|
||||
point := occultationProjectedPoint(oriented[index])
|
||||
dx := point[0] - start[0]
|
||||
if xSign == 0 && math.Abs(dx) > 1 {
|
||||
xSign = math.Copysign(1, dx)
|
||||
}
|
||||
if xSign == 0 || dx*xSign <= 0 {
|
||||
break
|
||||
}
|
||||
span := math.Abs(dx)
|
||||
if span > maximumSpanKM {
|
||||
break
|
||||
}
|
||||
inward := (point[1] - start[1]) * polarSign
|
||||
if inward < -containmentTolerance {
|
||||
continue
|
||||
}
|
||||
slope := inward / span
|
||||
if span < minimumSpanKM {
|
||||
maximumEarlySlope = math.Max(maximumEarlySlope, slope)
|
||||
continue
|
||||
}
|
||||
if slope < minimumSlope {
|
||||
minimumSlope = slope
|
||||
anchor = index
|
||||
}
|
||||
}
|
||||
if anchor < 4 || maximumEarlySlope-minimumSlope < minimumSlopeDrop {
|
||||
return nil, 0, false
|
||||
}
|
||||
anchorPoint := occultationProjectedPoint(oriented[anchor])
|
||||
anchorSpan := math.Abs(anchorPoint[0] - start[0])
|
||||
maximumGap := 0.0
|
||||
for index := 2; index < anchor; index++ {
|
||||
point := occultationProjectedPoint(oriented[index])
|
||||
span := math.Abs(point[0] - start[0])
|
||||
if span > anchorSpan {
|
||||
return nil, 0, false
|
||||
}
|
||||
inward := (point[1] - start[1]) * polarSign
|
||||
maximumGap = math.Max(maximumGap, inward-minimumSlope*span)
|
||||
}
|
||||
if maximumGap < minimumInwardGapKM || maximumGap > maximumInwardGapKM {
|
||||
return nil, 0, false
|
||||
}
|
||||
area := geoRingArea(oriented)
|
||||
if !finiteGeo(area) || math.Abs(area) <= 1e-12 {
|
||||
return nil, 0, false
|
||||
}
|
||||
outwardSign := 1.0
|
||||
if area < 0 {
|
||||
outwardSign = -1
|
||||
}
|
||||
for offset := 0.0; offset <= maximumInwardGapKM; offset += 2.0 {
|
||||
candidate := roundOccultationPhaseBoundaryWindow(
|
||||
oriented, 1, anchor, outwardSign, offset,
|
||||
)
|
||||
if len(candidate) < 4 || !geodata.SphericalPolygonsContainPathsWithinKM(
|
||||
[][]geodata.GeoPoint{candidate}, [][]geodata.GeoPoint{oriented}, true, containmentTolerance,
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if direction < 0 {
|
||||
reverseGeoPointRing(candidate)
|
||||
}
|
||||
return candidate, maximumGap, true
|
||||
}
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
func roundOccultationBandJunctionRing(
|
||||
points []geodata.GeoPoint,
|
||||
minimumLatitudeDegrees, maximumChordKM, minimumTurnDegrees float64,
|
||||
requireLatitudeReturn bool,
|
||||
) []geodata.GeoPoint {
|
||||
if len(points) < 9 {
|
||||
return points
|
||||
}
|
||||
closed := geodata.SameGeoPoint(points[0], points[len(points)-1])
|
||||
open := append([]geodata.GeoPoint(nil), points...)
|
||||
if closed {
|
||||
open = open[:len(open)-1]
|
||||
// The polygonizer can place a short polar return across the ring's
|
||||
// closing edge. Rotate the closed ring so that the original last
|
||||
// vertex becomes an ordinary interior node and is examined by the
|
||||
// same junction scan as every other vertex.
|
||||
if requireLatitudeReturn && len(open) > 3 {
|
||||
// Keep two successors after the original closing pair. The rounder
|
||||
// replaces a window and needs one neighbour beyond that window; a
|
||||
// one-vertex rotation would still leave a seam candidate at the
|
||||
// final index and make it ineligible for smoothing.
|
||||
rotated := make([]geodata.GeoPoint, len(open))
|
||||
copy(rotated, open[3:])
|
||||
copy(rotated[len(open)-3:], open[:3])
|
||||
open = rotated
|
||||
}
|
||||
}
|
||||
if len(open) < 9 {
|
||||
return points
|
||||
}
|
||||
area := geoRingArea(open)
|
||||
if !finiteGeo(area) || math.Abs(area) <= 1e-12 {
|
||||
return points
|
||||
}
|
||||
// A positive ring area has its interior on the left of traversal; the right
|
||||
// normal is therefore outward. Reverse it for a clockwise ring.
|
||||
outwardSign := 1.0
|
||||
if area < 0 {
|
||||
outwardSign = -1
|
||||
}
|
||||
result := open
|
||||
changed := false
|
||||
sourceWindowContainmentToleranceKM := 0.05
|
||||
if requireLatitudeReturn {
|
||||
sourceWindowContainmentToleranceKM = 1.0
|
||||
}
|
||||
for pass := 0; pass < 8; pass++ {
|
||||
// A locally sharp candidate can fail the source-window containment gate
|
||||
// even when a neighbouring return is safely roundable. Keep trying the
|
||||
// remaining candidates in this pass instead of abandoning the whole ring.
|
||||
rejected := make(map[int]bool)
|
||||
passAccepted := false
|
||||
for {
|
||||
candidate := -1
|
||||
bestLatitude := 0.0
|
||||
bestTurn := 180.0
|
||||
for index := 1; index+1 < len(result); index++ {
|
||||
if rejected[index] {
|
||||
continue
|
||||
}
|
||||
point := result[index]
|
||||
if math.Abs(point.Latitude) < minimumLatitudeDegrees {
|
||||
continue
|
||||
}
|
||||
previous, next := result[index-1], result[index+1]
|
||||
if occultationProjectedEdgeDistanceKM(previous, next) > maximumChordKM {
|
||||
continue
|
||||
}
|
||||
turn := occultationTurnAngleDegrees(previous, point, next)
|
||||
if turn >= minimumTurnDegrees {
|
||||
continue
|
||||
}
|
||||
if requireLatitudeReturn &&
|
||||
((point.Latitude-previous.Latitude)*(next.Latitude-point.Latitude) >= 0 ||
|
||||
occultationProjectedTurnAngleDegrees(previous, point, next) >= minimumTurnDegrees) {
|
||||
continue
|
||||
}
|
||||
extreme := math.Abs(point.Latitude)
|
||||
if candidate < 0 || turn < bestTurn ||
|
||||
(turn == bestTurn && extreme > bestLatitude) {
|
||||
candidate = index
|
||||
bestLatitude = extreme
|
||||
bestTurn = turn
|
||||
}
|
||||
}
|
||||
if candidate < 0 {
|
||||
break
|
||||
}
|
||||
plateauStart, plateauEnd := candidate, candidate
|
||||
for plateauStart > 1 && math.Abs(result[plateauStart-1].Latitude-result[candidate].Latitude) <= 1e-5 {
|
||||
plateauStart--
|
||||
}
|
||||
for plateauEnd+1 < len(result)-1 && math.Abs(result[plateauEnd+1].Latitude-result[candidate].Latitude) <= 1e-5 {
|
||||
plateauEnd++
|
||||
}
|
||||
left, right := plateauStart-1, plateauEnd+1
|
||||
if left < 1 || right >= len(result)-1 || right-left < 2 {
|
||||
rejected[candidate] = true
|
||||
continue
|
||||
}
|
||||
accepted := false
|
||||
sourceWindow := append([]geodata.GeoPoint(nil), result[left:right+1]...)
|
||||
for offset := 0.0; offset <= 80.0; offset += 2.0 {
|
||||
candidateRing := roundOccultationPhaseBoundaryWindow(
|
||||
result, left, right, outwardSign, offset,
|
||||
)
|
||||
if len(candidateRing) < 4 || !geodata.SphericalPolygonsContainPathsWithinKM(
|
||||
[][]geodata.GeoPoint{candidateRing}, [][]geodata.GeoPoint{sourceWindow}, false, sourceWindowContainmentToleranceKM,
|
||||
) {
|
||||
continue
|
||||
}
|
||||
result = candidateRing
|
||||
changed = true
|
||||
accepted = true
|
||||
passAccepted = true
|
||||
break
|
||||
}
|
||||
if accepted {
|
||||
break
|
||||
}
|
||||
rejected[candidate] = true
|
||||
}
|
||||
if !passAccepted {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return points
|
||||
}
|
||||
if closed {
|
||||
result = append(result, result[0])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func roundOccultationPhaseBoundaryWindow(
|
||||
points []geodata.GeoPoint,
|
||||
left, right int,
|
||||
outwardSign, offsetKM float64,
|
||||
) []geodata.GeoPoint {
|
||||
first := occultationProjectedPoint(points[left])
|
||||
last := occultationProjectedPoint(points[right])
|
||||
firstNeighbour := occultationProjectedPoint(points[left-1])
|
||||
lastNeighbour := occultationProjectedPoint(points[right+1])
|
||||
startDX, startDY := first[0]-firstNeighbour[0], first[1]-firstNeighbour[1]
|
||||
endDX, endDY := lastNeighbour[0]-last[0], lastNeighbour[1]-last[1]
|
||||
startLength, endLength := math.Hypot(startDX, startDY), math.Hypot(endDX, endDY)
|
||||
chordLength := math.Hypot(last[0]-first[0], last[1]-first[1])
|
||||
if startLength <= 1e-9 || endLength <= 1e-9 || chordLength <= 1e-9 {
|
||||
return nil
|
||||
}
|
||||
startDX, startDY = startDX/startLength, startDY/startLength
|
||||
endDX, endDY = endDX/endLength, endDY/endLength
|
||||
tangentLength := chordLength / 3
|
||||
controlFirst := [2]float64{first[0] + startDX*tangentLength, first[1] + startDY*tangentLength}
|
||||
controlLast := [2]float64{last[0] - endDX*tangentLength, last[1] - endDY*tangentLength}
|
||||
chordDX, chordDY := last[0]-first[0], last[1]-first[1]
|
||||
chordScale := math.Hypot(chordDX, chordDY)
|
||||
if chordScale <= 1e-9 {
|
||||
return nil
|
||||
}
|
||||
outwardX, outwardY := chordDY/chordScale, -chordDX/chordScale
|
||||
outwardX *= outwardSign
|
||||
outwardY *= outwardSign
|
||||
steps := int(math.Ceil(chordLength / 5))
|
||||
if steps < 8 {
|
||||
steps = 8
|
||||
}
|
||||
if steps > 96 {
|
||||
steps = 96
|
||||
}
|
||||
replacement := make([]geodata.GeoPoint, 0, steps-1)
|
||||
for step := 1; step < steps; step++ {
|
||||
t := float64(step) / float64(steps)
|
||||
u := 1 - t
|
||||
weightFirst := u * u * u
|
||||
weightControlFirst := 3 * u * u * t
|
||||
weightControlLast := 3 * u * t * t
|
||||
weightLast := t * t * t
|
||||
x := weightFirst*first[0] + weightControlFirst*controlFirst[0] +
|
||||
weightControlLast*controlLast[0] + weightLast*last[0]
|
||||
y := weightFirst*first[1] + weightControlFirst*controlFirst[1] +
|
||||
weightControlLast*controlLast[1] + weightLast*last[1]
|
||||
bulge := offsetKM * math.Sin(math.Pi*t) * math.Sin(math.Pi*t)
|
||||
x += outwardX * bulge
|
||||
y += outwardY * bulge
|
||||
replacement = append(replacement, occultationUnprojectedPoint(x, y))
|
||||
}
|
||||
result := make([]geodata.GeoPoint, 0, len(points)+len(replacement)-right+left)
|
||||
result = append(result, points[:left+1]...)
|
||||
result = append(result, replacement...)
|
||||
result = append(result, points[right:]...)
|
||||
return result
|
||||
}
|
||||
|
||||
// CleanAuthoritativeBandPolygons 在调用方执行父子包含 union 等拓扑操作后,重新应用权威掩带的显示清理。
|
||||
// CleanAuthoritativeBandPolygons reapplies the authoritative display cleanup
|
||||
// after a caller performs a topology operation such as a parent/child union.
|
||||
// Those operations can reintroduce the short polar seams removed from the
|
||||
// original linework.
|
||||
func CleanAuthoritativeBandPolygons(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
||||
return cleanupOccultationAuthoritativeBandPolygons(polygons)
|
||||
}
|
||||
|
||||
// RoundAuthoritativeBandJunctions 对已组装的权威掩带应用最终的仅显示接缝平滑。
|
||||
// RoundAuthoritativeBandJunctions applies the final display-only smoothing to
|
||||
// an already assembled authoritative band. Callers should invoke it after
|
||||
// topology operations such as parent/child containment unions, since those
|
||||
// operations can reintroduce the short polar sweep junction.
|
||||
func RoundAuthoritativeBandJunctions(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
||||
return roundOccultationAuthoritativeBandJunctions(polygons)
|
||||
}
|
||||
|
||||
// RoundAuthoritativeTotalBandJunctions 应用常规局部接缝清理和仅限全掩带的宽极区肩部修复。
|
||||
// RoundAuthoritativeTotalBandJunctions applies the ordinary local seam cleanup
|
||||
// plus the total-band-only broad polar shoulder repair.
|
||||
func RoundAuthoritativeTotalBandJunctions(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
||||
return roundOccultationTotalBandJunctions(polygons)
|
||||
}
|
||||
|
||||
// removeOccultationPolarKinks removes short high-latitude direction reversals
|
||||
// that are numerical polygonizer vertices rather than physical boundary folds.
|
||||
// The endpoint/chord limits keep this local: real phase branches and horizon
|
||||
// connectors span much farther than this pattern, even when they turn sharply.
|
||||
func removeOccultationPolarKinks(points []geodata.GeoPoint) []geodata.GeoPoint {
|
||||
if len(points) < 4 {
|
||||
return points
|
||||
}
|
||||
const (
|
||||
// Web Mercator magnifies a 5-10 km spherical return to tens of
|
||||
// kilometres at the south-polar latitudes used by occultation maps.
|
||||
// Use a wider local window here than the generic corner cleaner, but
|
||||
// keep it restricted to the genuinely polar branch where these returns
|
||||
// are numerical polygonizer junctions rather than physical curves.
|
||||
minimumPolarLatitudeDegrees = 70.0
|
||||
maximumAdjacentEdgeKM = 120.0
|
||||
// Never delete a vertex when the resulting rendered chord would itself
|
||||
// exceed this local 75 km cleanup budget; the final densification pass
|
||||
// restores the output spacing target after the seam is removed.
|
||||
maximumEndpointChordKM = 75.0
|
||||
maximumTurnAngleDegrees = 165.0
|
||||
)
|
||||
result := append([]geodata.GeoPoint(nil), points...)
|
||||
for pass := 0; pass < 128; pass++ {
|
||||
changed := false
|
||||
for index := 1; index+1 < len(result); index++ {
|
||||
first, middle, last := result[index-1], result[index], result[index+1]
|
||||
if math.Abs(middle.Latitude) < minimumPolarLatitudeDegrees ||
|
||||
occultationProjectedEdgeDistanceKM(first, middle) > maximumAdjacentEdgeKM ||
|
||||
occultationProjectedEdgeDistanceKM(middle, last) > maximumAdjacentEdgeKM ||
|
||||
occultationProjectedEdgeDistanceKM(first, last) > maximumEndpointChordKM {
|
||||
continue
|
||||
}
|
||||
// A reversal in either projected axis is required. A merely curved
|
||||
// sample with a small turn angle is still a valid physical boundary.
|
||||
longitudeReversal := math.Remainder(middle.Longitude-first.Longitude, 360)*
|
||||
math.Remainder(last.Longitude-middle.Longitude, 360) < 0
|
||||
latitudeReversal := (middle.Latitude-first.Latitude)*(last.Latitude-middle.Latitude) < 0
|
||||
if !longitudeReversal && !latitudeReversal {
|
||||
continue
|
||||
}
|
||||
if occultationTurnAngleDegrees(first, middle, last) >= maximumTurnAngleDegrees {
|
||||
continue
|
||||
}
|
||||
result = append(result[:index], result[index+1:]...)
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func validateOccultationVisibleBandWitnesses(
|
||||
visible [][]geodata.GeoPoint,
|
||||
useContactContours bool,
|
||||
visibleFill, contourFill, visibleFillCoveragePaths [][]geodata.GeoPoint,
|
||||
) error {
|
||||
if !useContactContours || len(visibleFill) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(visibleFillCoveragePaths) > 0 {
|
||||
// Footprint probes are sparse, but they are the cheapest witness set that
|
||||
// still tracks the actual visible region rather than only the continuous
|
||||
// contact envelope. Catching a wrong-but-valid face here is much cheaper
|
||||
// than re-running the full linework search.
|
||||
if !geodata.SphericalPolygonsContainPathsWithinKM(visible, visibleFillCoveragePaths, true, 25) {
|
||||
return fmt.Errorf("visible contact-contour face misses footprint witnesses by more than 25 km")
|
||||
}
|
||||
}
|
||||
if !geodata.SphericalPolygonsContainPathsWithinKM(visible, visibleFill, true, 50) {
|
||||
return fmt.Errorf("visible contact-contour face misses footprint fill by more than 50 km")
|
||||
}
|
||||
auditFill := contourFill
|
||||
if len(auditFill) == 0 {
|
||||
auditFill = visibleFill
|
||||
}
|
||||
if !geodata.SphericalPolygonsContainPathsWithinKM(visible, auditFill, true, 150) {
|
||||
return fmt.Errorf("visible contact-contour face misses authoritative fill by more than 150 km")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func occultationInteriorPolygon(
|
||||
source []basic.OccultationPathPoint,
|
||||
interior [][]basic.OccultationPathPoint,
|
||||
) bool {
|
||||
for _, candidate := range interior {
|
||||
if len(source) != len(candidate) {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for index := range source {
|
||||
if !sameOccultationPoint(source[index], candidate[index]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func occultationStaticInteriorPolygon(
|
||||
source []basic.OccultationPathPoint,
|
||||
interior [][]basic.OccultationPathPoint,
|
||||
) bool {
|
||||
if !occultationInteriorPolygon(source, interior) {
|
||||
return false
|
||||
}
|
||||
// Center repairs contain a triangular bridge from the physical center to
|
||||
// the nearest sampled contact edge plus a small circular cap around the
|
||||
// center. Static bands may keep the bridge because it only closes a sampled
|
||||
// seam; the radius-based cap is for one-instant rendering and would
|
||||
// artificially enlarge the long-lived band.
|
||||
open := source
|
||||
if len(open) > 1 && sameOccultationPoint(open[0], open[len(open)-1]) {
|
||||
open = open[:len(open)-1]
|
||||
}
|
||||
return len(open) <= 3
|
||||
}
|
||||
|
||||
func mergeStaticFootprintRepairs(
|
||||
polygons [][]geodata.GeoPoint,
|
||||
footprints []basic.OccultationFootprint,
|
||||
) [][]geodata.GeoPoint {
|
||||
repairs := footprintStaticInteriorPolygons(footprints)
|
||||
if len(repairs) == 0 {
|
||||
return polygons
|
||||
}
|
||||
input := append([][]geodata.GeoPoint(nil), polygons...)
|
||||
input = append(input, repairs...)
|
||||
merged, err := geodata.UnionPolygons(input)
|
||||
if err != nil {
|
||||
return polygons
|
||||
}
|
||||
return densifyOccultationPolygons(merged, 50)
|
||||
}
|
||||
|
||||
func occultationLimitVisibleFillPolygons(
|
||||
northern, southern []basic.OccultationPathPoint,
|
||||
) [][]geodata.GeoPoint {
|
||||
polygons := make([][]geodata.GeoPoint, 0)
|
||||
for _, sampleRange := range ContinuousPairedBoundaryRanges(northern, southern) {
|
||||
for index := sampleRange.Start + 1; index < sampleRange.End; index++ {
|
||||
source := []basic.OccultationPathPoint{
|
||||
northern[index-1],
|
||||
northern[index],
|
||||
southern[index],
|
||||
southern[index-1],
|
||||
}
|
||||
visibleSource := clipOccultationPolygonToHorizon(source)
|
||||
if len(visibleSource) < 3 {
|
||||
continue
|
||||
}
|
||||
polygon := make([]geodata.GeoPoint, len(visibleSource))
|
||||
for pointIndex, point := range visibleSource {
|
||||
polygon[pointIndex] = geodata.GeoPoint{
|
||||
Longitude: point.Longitude,
|
||||
Latitude: point.Latitude,
|
||||
}
|
||||
}
|
||||
polygons = append(polygons, polygon)
|
||||
}
|
||||
}
|
||||
if len(polygons) == 0 {
|
||||
return nil
|
||||
}
|
||||
merged, err := geodata.UnionPolygons(polygons)
|
||||
if err != nil {
|
||||
return polygons
|
||||
}
|
||||
return RemoveTinyPolygonComponents(merged)
|
||||
}
|
||||
|
||||
// occultationCurveCoverageProbes adds small interior probes on both sides of
|
||||
// every physical phase curve. A narrow face between two nearly coincident
|
||||
// curves can contain real visible footprints while missing all footprint
|
||||
// centroids; sampling the curve sides lets the linework selector retain that
|
||||
// face without admitting below-horizon regions.
|
||||
func occultationCurveCoverageProbes(
|
||||
curves []basic.OccultationRiseSetCurve,
|
||||
fillPolygons [][]geodata.GeoPoint,
|
||||
) [][]geodata.GeoPoint {
|
||||
if len(curves) == 0 || len(fillPolygons) == 0 {
|
||||
return nil
|
||||
}
|
||||
const maximumSamplesPerSegment = 64
|
||||
candidates := make([]geodata.GeoPoint, 0, len(curves)*maximumSamplesPerSegment)
|
||||
for _, curve := range curves {
|
||||
for _, segment := range curve.Segments {
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
step := (len(segment) + maximumSamplesPerSegment - 1) / maximumSamplesPerSegment
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
for index := 0; index+1 < len(segment); index += step {
|
||||
next := index + step
|
||||
if next >= len(segment) {
|
||||
next = len(segment) - 1
|
||||
}
|
||||
first, second := segment[index], segment[next]
|
||||
deltaLongitude := normalizeGeoLongitude(second.Longitude - first.Longitude)
|
||||
deltaLatitude := second.Latitude - first.Latitude
|
||||
length := math.Hypot(deltaLongitude, deltaLatitude)
|
||||
if length <= 1e-12 {
|
||||
continue
|
||||
}
|
||||
midpoint := geodata.GeoPoint{
|
||||
Longitude: normalizeGeoLongitude(first.Longitude + deltaLongitude/2),
|
||||
Latitude: first.Latitude + deltaLatitude/2,
|
||||
}
|
||||
for _, offset := range []float64{0.00005, 0.0005, 0.002} {
|
||||
for _, side := range []float64{-1, 1} {
|
||||
candidates = append(candidates, geodata.GeoPoint{
|
||||
Longitude: normalizeGeoLongitude(midpoint.Longitude - side*deltaLatitude*offset/length),
|
||||
Latitude: midpoint.Latitude + side*deltaLongitude*offset/length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inside := geodata.SphericalPolygonsContainPoints(fillPolygons, candidates)
|
||||
probes := make([][]geodata.GeoPoint, 0, len(candidates))
|
||||
for index, probe := range candidates {
|
||||
if !inside[index] {
|
||||
continue
|
||||
}
|
||||
duplicate := false
|
||||
for _, existing := range probes {
|
||||
if len(existing) > 0 && geoDistanceKM(existing[0], probe) < 1 {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !duplicate {
|
||||
probes = append(probes, []geodata.GeoPoint{probe})
|
||||
}
|
||||
}
|
||||
return probes
|
||||
}
|
||||
|
||||
const maximumOccultationBoundaryAlternatives = 8
|
||||
Reference in New Issue
Block a user