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

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