2bf8478639
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
879 lines
34 KiB
Go
879 lines
34 KiB
Go
// Package occultationgeo provides shared geographic helpers for occultation encoders and renderers.
|
|
package occultationgeo
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
|
|
"b612.me/astro/basic"
|
|
"b612.me/astro/internal/geodata"
|
|
)
|
|
|
|
// 掩星边界连续性判定的几何门限 / geometric thresholds for occultation-boundary continuity checks.
|
|
const (
|
|
EarthRadiusKM = 6378.1366
|
|
BoundaryBranchJumpKM = 750.0
|
|
// BoundaryBranchSpeedKMPerSecond 是判定"物理上不可能的支路跳变"的地面速度门限:
|
|
// 掩星边界随月球影子移动,地面速度上限约 1.1 km/s,取 2 km/s 留约两倍余量。
|
|
// BoundaryBranchSpeedKMPerSecond gates "physically impossible" branch jumps: the boundary
|
|
// follows the lunar shadow at up to about 1.1 km/s on the ground, so 2 km/s keeps a
|
|
// factor-of-two margin.
|
|
BoundaryBranchSpeedKMPerSecond = 2.0
|
|
staticCenterCapRadiusKM = 31.0
|
|
staticCenterCapPoints = 16
|
|
closedFootprintSweepPoints = 96
|
|
closedFootprintSweepMaxStepKM = 2500.0
|
|
)
|
|
|
|
// SampleRange 是一个可在不跨越支路变化时连接的半开样本区间。
|
|
// SampleRange is a half-open range of samples that can be joined without crossing a branch change.
|
|
type SampleRange struct {
|
|
Start int
|
|
End int
|
|
}
|
|
|
|
// ContinuousBoundaryRanges 在物理上不可能的支路跳变处拆分边界。
|
|
// ContinuousBoundaryRanges splits a boundary at physically impossible branch changes.
|
|
// One-sample ranges are retained so callers can represent event endpoints without reconnecting a jump.
|
|
func ContinuousBoundaryRanges(points []basic.OccultationPathPoint) []SampleRange {
|
|
return continuousRanges(len(points), func(index int) bool {
|
|
return BoundaryBranchChanged(points[index-1], points[index])
|
|
})
|
|
}
|
|
|
|
// ContinuousPairedBoundaryRanges 在成对边界任一侧换支时拆分区间。
|
|
// ContinuousPairedBoundaryRanges splits paired limits when either side changes branch.
|
|
func ContinuousPairedBoundaryRanges(
|
|
first, second []basic.OccultationPathPoint,
|
|
) []SampleRange {
|
|
count := len(first)
|
|
if len(second) < count {
|
|
count = len(second)
|
|
}
|
|
return continuousRanges(count, func(index int) bool {
|
|
return BoundaryBranchChanged(first[index-1], first[index]) ||
|
|
BoundaryBranchChanged(second[index-1], second[index])
|
|
})
|
|
}
|
|
|
|
// PairedBoundaryPolygons 仅在连续成对边界之间返回扫掠单元。
|
|
// PairedBoundaryPolygons returns sweep cells only across continuous paired
|
|
// limit ranges. Instantaneous footprints remain responsible for end caps.
|
|
func PairedBoundaryPolygons(
|
|
first, second []basic.OccultationPathPoint,
|
|
) [][]geodata.GeoPoint {
|
|
count := len(first)
|
|
if len(second) < count {
|
|
count = len(second)
|
|
}
|
|
polygons := make([][]geodata.GeoPoint, 0, count)
|
|
// A branch change on only one side creates a long-lived invalid cross
|
|
// section: the changed side has already moved to its new tangent branch
|
|
// while the other side remains on the old branch. Suppress cells until the
|
|
// next branch transition establishes a new paired branch.
|
|
pendingBranch := false
|
|
for index := 1; index < count; index++ {
|
|
firstChanged := BoundaryBranchChanged(first[index-1], first[index])
|
|
secondChanged := BoundaryBranchChanged(second[index-1], second[index])
|
|
if pendingBranch {
|
|
// 抑制以"下一次任一侧换支"为界;此后若不再换支,剩余单元仍是不匹配的支路对。
|
|
if firstChanged || secondChanged {
|
|
pendingBranch = false
|
|
}
|
|
continue
|
|
}
|
|
if firstChanged != secondChanged {
|
|
pendingBranch = true
|
|
continue
|
|
}
|
|
if firstChanged { // both sides changed at the same transition
|
|
continue
|
|
}
|
|
previousFirst, currentFirst := first[index-1], first[index]
|
|
previousSecond, currentSecond := second[index-1], second[index]
|
|
polygons = append(polygons, []geodata.GeoPoint{
|
|
{Longitude: previousFirst.Longitude, Latitude: previousFirst.Latitude},
|
|
{Longitude: currentFirst.Longitude, Latitude: currentFirst.Latitude},
|
|
{Longitude: currentSecond.Longitude, Latitude: currentSecond.Latitude},
|
|
{Longitude: previousSecond.Longitude, Latitude: previousSecond.Latitude},
|
|
})
|
|
}
|
|
return polygons
|
|
}
|
|
|
|
// RemoveTinyPolygonComponents 删除紧凑极区掩带组面时留下的微小数值薄片。
|
|
// RemoveTinyPolygonComponents drops numerical slivers left when a compact
|
|
// sweep closes at a shared endpoint. A component survives only when it reaches
|
|
// 0.01% of the largest component area, so components comparable to the main
|
|
// band and genuine disjoint projected branches are always preserved; the
|
|
// largest component itself is retained unconditionally.
|
|
func RemoveTinyPolygonComponents(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
|
if len(polygons) < 2 {
|
|
return polygons
|
|
}
|
|
areas := make([]float64, len(polygons))
|
|
maximum, maximumIndex := 0.0, 0
|
|
for index, polygon := range polygons {
|
|
areas[index] = math.Abs(geoRingArea(polygon))
|
|
if areas[index] > maximum {
|
|
maximum, maximumIndex = areas[index], index
|
|
}
|
|
}
|
|
if maximum <= 0 || !finiteGeo(maximum) {
|
|
return polygons
|
|
}
|
|
threshold := maximum * 1e-4
|
|
filtered := make([][]geodata.GeoPoint, 0, len(polygons))
|
|
for index, polygon := range polygons {
|
|
// 最大分量必然达到门槛,显式保留以固定"主带不会被过滤"的契约。
|
|
if index == maximumIndex || areas[index] >= threshold {
|
|
filtered = append(filtered, polygon)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// removeOccultationPolarSliverComponents drops detached high-latitude faces
|
|
// with only a handful of vertices. These are polygonizer junction slivers,
|
|
// not independent occultation regions; merging one into the main face turns
|
|
// its closure into the staircase visible at the south polar tip.
|
|
func removeOccultationPolarSliverComponents(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
|
if len(polygons) < 2 {
|
|
return polygons
|
|
}
|
|
filtered := make([][]geodata.GeoPoint, 0, len(polygons))
|
|
for _, polygon := range polygons {
|
|
polar := len(polygon) < 8
|
|
for _, point := range polygon {
|
|
if math.Abs(point.Latitude) < 70 {
|
|
polar = false
|
|
break
|
|
}
|
|
}
|
|
if !polar {
|
|
filtered = append(filtered, polygon)
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
return polygons
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// ConstrainPolygonsWithin 修复子掩带对父掩带的小数值突破,大幅差异保持不变以便诊断。
|
|
// ConstrainPolygonsWithin repairs a small numerical breach of a child band
|
|
// against its parent band. Finite-disk inner-contact sweeps can differ from
|
|
// the outer sweep by a few samples at a branch junction; when the breach is
|
|
// small, replacing those samples with the nearest parent boundary vertex
|
|
// preserves the child curve while restoring the physical containment
|
|
// invariant. Large breaches are left untouched so this helper cannot hide a
|
|
// wrong face selection.
|
|
func ConstrainPolygonsWithin(
|
|
parent, child [][]geodata.GeoPoint,
|
|
) [][]geodata.GeoPoint {
|
|
if len(parent) == 0 || len(child) == 0 {
|
|
return child
|
|
}
|
|
initialMissDistance := geodata.SphericalPolygonsPathMissDistanceKM(parent, child, true)
|
|
if initialMissDistance <= 0 {
|
|
return child
|
|
}
|
|
const maximumRepairDistanceKM = 100.0
|
|
const maximumResidualMissDistanceKM = 10.0
|
|
if initialMissDistance > maximumRepairDistanceKM {
|
|
return child
|
|
}
|
|
result := make([][]geodata.GeoPoint, len(child))
|
|
// 父带不变,逐点包含索引只建一次;每遍重建会让 6 遍细化退化成 O(passes×父带边数)。
|
|
parentIndex := geodata.NewSphericalPolygonIndex(parent)
|
|
for index, source := range child {
|
|
if len(source) < 4 {
|
|
if len(openFootprintRing(source)) >= 3 && math.Abs(geoRingArea(source)) > 1e-12 {
|
|
result[index] = append([]geodata.GeoPoint(nil), source...)
|
|
}
|
|
continue
|
|
}
|
|
ring := append([]geodata.GeoPoint(nil), source...)
|
|
closed := geodata.SameGeoPoint(ring[0], ring[len(ring)-1])
|
|
limit := len(ring)
|
|
if closed {
|
|
limit--
|
|
}
|
|
containment := parentIndex.ContainsPoints(ring[:limit])
|
|
for pointIndex := 0; pointIndex < limit; pointIndex++ {
|
|
point := ring[pointIndex]
|
|
if containment[pointIndex] {
|
|
continue
|
|
}
|
|
nearest, distance := nearestPolygonBoundaryPoint(parent, point)
|
|
if distance <= maximumRepairDistanceKM {
|
|
ring[pointIndex] = nearest
|
|
}
|
|
}
|
|
if closed {
|
|
ring[len(ring)-1] = ring[0]
|
|
}
|
|
result[index] = ring
|
|
}
|
|
result = usableOccultationPolygons(result)
|
|
if geodata.SphericalPolygonsPathMissDistanceKM(parent, result, true) <= 0 {
|
|
return result
|
|
}
|
|
// Vertex-only repair cannot see a child edge whose endpoints are both
|
|
// inside the parent while its great-circle midpoint crosses outside. Split
|
|
// the repaired ring at the same projected spacing used by output geometry,
|
|
// then apply the local vertex snap to those newly exposed edge probes.
|
|
densified := densifyOccultationPolygons(result, 10)
|
|
densifiedMiss := initialMissDistance
|
|
for pass := 0; pass < 6; pass++ {
|
|
changed := false
|
|
for index, source := range densified {
|
|
ring := append([]geodata.GeoPoint(nil), source...)
|
|
closed := len(ring) > 1 && geodata.SameGeoPoint(ring[0], ring[len(ring)-1])
|
|
limit := len(ring)
|
|
if closed {
|
|
limit--
|
|
}
|
|
containment := parentIndex.ContainsPoints(ring[:limit])
|
|
for pointIndex := 0; pointIndex < limit; pointIndex++ {
|
|
point := ring[pointIndex]
|
|
if containment[pointIndex] {
|
|
continue
|
|
}
|
|
nearest, distance := nearestPolygonBoundaryPoint(parent, point)
|
|
if distance <= maximumRepairDistanceKM {
|
|
ring[pointIndex] = nearest
|
|
changed = true
|
|
}
|
|
}
|
|
if closed {
|
|
ring[len(ring)-1] = ring[0]
|
|
}
|
|
for {
|
|
cleaned := removeDirectProjectedSharpCorners(ring, 20, 30)
|
|
cleaned = removeOccultationSharpCorners(cleaned, 20, 30)
|
|
if len(cleaned) == len(ring) {
|
|
break
|
|
}
|
|
ring = cleaned
|
|
}
|
|
if closed && len(ring) > 1 && !geodata.SameGeoPoint(ring[0], ring[len(ring)-1]) {
|
|
ring = append(ring, ring[0])
|
|
}
|
|
densified[index] = ring
|
|
}
|
|
densified = usableOccultationPolygons(densified)
|
|
densifiedMiss = geodata.SphericalPolygonsPathMissDistanceKM(parent, densified, true)
|
|
if densifiedMiss <= maximumResidualMissDistanceKM {
|
|
return densified
|
|
}
|
|
if !changed {
|
|
break
|
|
}
|
|
}
|
|
// 只有残差实质变小(>10%)才采用细化结果;亚公里级改善不值得改变输出点数,
|
|
// 其余情况按"大突破保持不变"的契约返回 child。
|
|
if len(densified) > 0 && densifiedMiss < initialMissDistance*0.9 {
|
|
return densified
|
|
}
|
|
return child
|
|
}
|
|
|
|
func nearestPolygonVertex(
|
|
polygons [][]geodata.GeoPoint,
|
|
point geodata.GeoPoint,
|
|
) (geodata.GeoPoint, float64) {
|
|
nearest := geodata.GeoPoint{}
|
|
distance := math.Inf(1)
|
|
for _, polygon := range polygons {
|
|
for _, candidate := range polygon {
|
|
value := geoDistanceKM(point, candidate)
|
|
if value < distance {
|
|
nearest, distance = candidate, value
|
|
}
|
|
}
|
|
}
|
|
return nearest, distance
|
|
}
|
|
|
|
func nearestPolygonBoundaryPoint(
|
|
polygons [][]geodata.GeoPoint,
|
|
point geodata.GeoPoint,
|
|
) (geodata.GeoPoint, float64) {
|
|
nearest := geodata.GeoPoint{}
|
|
distance := math.Inf(1)
|
|
for _, polygon := range polygons {
|
|
if len(polygon) < 2 {
|
|
continue
|
|
}
|
|
limit := len(polygon)
|
|
if limit > 1 && geodata.SameGeoPoint(polygon[0], polygon[limit-1]) {
|
|
limit--
|
|
}
|
|
for index := 0; index < limit; index++ {
|
|
start := polygon[index]
|
|
end := polygon[(index+1)%limit]
|
|
latitude := point.Latitude * math.Pi / 180
|
|
scaleX := math.Cos(latitude)
|
|
startX := math.Remainder(start.Longitude-point.Longitude, 360) * scaleX
|
|
startY := start.Latitude - point.Latitude
|
|
endX := math.Remainder(end.Longitude-point.Longitude, 360) * scaleX
|
|
endY := end.Latitude - point.Latitude
|
|
deltaX, deltaY := endX-startX, endY-startY
|
|
fraction := 0.0
|
|
if lengthSquared := deltaX*deltaX + deltaY*deltaY; lengthSquared > 0 {
|
|
fraction = math.Max(0, math.Min(1,
|
|
-(startX*deltaX+startY*deltaY)/lengthSquared,
|
|
))
|
|
}
|
|
candidate := interpolateOccultationGeoPoint(start, end, fraction)
|
|
value := geoDistanceKM(point, candidate)
|
|
if value < distance {
|
|
nearest, distance = candidate, value
|
|
}
|
|
}
|
|
}
|
|
if math.IsInf(distance, 1) {
|
|
return nearestPolygonVertex(polygons, point)
|
|
}
|
|
return nearest, distance
|
|
}
|
|
|
|
// FootprintSweepPolygons 构造瞬时月掩足迹的静态扫掠并集。
|
|
// FootprintSweepPolygons builds the static union of instantaneous occultation
|
|
// footprints. Open contact-cone arcs are swept between adjacent samples so
|
|
// their per-instant horizon closures do not survive as staircase edges.
|
|
// Legacy footprints without Boundaries retain the polygon-union behavior.
|
|
func FootprintSweepPolygons(
|
|
footprints []basic.OccultationFootprint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
) ([][]geodata.GeoPoint, error) {
|
|
return footprintSweepPolygons(footprints, northern, southern)
|
|
}
|
|
|
|
// ContactSweepBoundaryLines 返回由瞬时接触弧的时间扫掠导出的连续边界线网。
|
|
// ContactSweepBoundaryLines returns continuous boundary linework derived from
|
|
// open contact-cone arcs. Closed instantaneous footprint rings are deliberately
|
|
// excluded so callers can use these lines as static-band boundary candidates.
|
|
func ContactSweepBoundaryLines(
|
|
footprints []basic.OccultationFootprint,
|
|
) [][]geodata.GeoPoint {
|
|
if !footprintBoundariesAvailable(footprints) {
|
|
return nil
|
|
}
|
|
polygons, err := footprintOpenSweepPolygons(footprints)
|
|
if err != nil {
|
|
polygons, err = footprintOpenSweepPolygonsWithoutTransitions(footprints)
|
|
}
|
|
if err != nil || len(polygons) == 0 {
|
|
// A branch change can make the ribbon union fail at a single numerical
|
|
// intersection even though its endpoint tracks remain valid. Expose those
|
|
// tracks as diagnostic boundary lines; they are also the caps used by the
|
|
// bounded endpoint fallback and therefore keep line/fill audits consistent.
|
|
samples := make([]geodata.OpenBoundarySweepSample, 0, len(footprints))
|
|
for _, footprint := range footprints {
|
|
if footprint.Closed {
|
|
samples = append(samples, geodata.OpenBoundarySweepSample{Closed: true})
|
|
continue
|
|
}
|
|
boundaries := footprintGeoBoundaries(footprint)
|
|
if len(boundaries) == 0 {
|
|
continue
|
|
}
|
|
samples = append(samples, geodata.OpenBoundarySweepSample{Boundaries: boundaries})
|
|
}
|
|
outlines, outlineErr := geodata.OpenBoundaryEndpointOutlines(samples)
|
|
if outlineErr != nil {
|
|
return nil
|
|
}
|
|
polygons = outlines
|
|
// Keep the actual open contact arcs as diagnostic linework as well. The
|
|
// endpoint outline alone omits the intermediate limb bulges and can make a
|
|
// valid time-union edge appear a few kilometres detached from its source
|
|
// boundary after spherical union interpolation.
|
|
for _, footprint := range footprints {
|
|
for _, boundary := range footprintGeoBoundaries(footprint) {
|
|
if len(boundary) >= 2 {
|
|
polygons = append(polygons, boundary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
lines := make([][]geodata.GeoPoint, 0, len(polygons))
|
|
for _, polygon := range polygons {
|
|
if len(polygon) < 3 {
|
|
continue
|
|
}
|
|
line := append([]geodata.GeoPoint(nil), polygon...)
|
|
if !geodata.SameGeoPoint(line[0], line[len(line)-1]) {
|
|
line = append(line, line[0])
|
|
}
|
|
// Sparse polar footprint sweeps can carry a one-sample numerical return
|
|
// at a branch junction. Remove that local kink before this line is used
|
|
// as a fallback boundary; authoritative contact contours remain untouched.
|
|
line = removeOccultationPolarKinks(line)
|
|
line = removeOccultationSharpCorners(line, 20, 30)
|
|
if len(line) > 1 && !geodata.SameGeoPoint(line[0], line[len(line)-1]) {
|
|
line = append(line, line[0])
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func footprintSweepPolygons(
|
|
footprints []basic.OccultationFootprint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
) ([][]geodata.GeoPoint, error) {
|
|
var (
|
|
staticRepairs [][]geodata.GeoPoint
|
|
closedSweep [][]geodata.GeoPoint
|
|
legacy [][]geodata.GeoPoint
|
|
polygons [][]geodata.GeoPoint
|
|
)
|
|
usedOpenSweep := false
|
|
if footprintBoundariesAvailable(footprints) {
|
|
sweep, err := footprintOpenSweepPolygons(footprints)
|
|
if err != nil {
|
|
sweep = nil
|
|
}
|
|
if len(sweep) > 0 && footprintSweepCoversSamples(sweep, footprints) {
|
|
// A covering open sweep already contains the closed instantaneous
|
|
// footprints between its two horizon-limited runs. Adding those faces and
|
|
// their secondary sweep again introduces coincident edges and can make the
|
|
// spherical union select a sampled scallop or reject an otherwise closed
|
|
// continuous ring. Keep source footprints as witnesses; only independent
|
|
// interior repair faces still need to participate in the output union.
|
|
hasInterior := false
|
|
for _, footprint := range footprints {
|
|
hasInterior = hasInterior || len(footprint.InteriorPolygons) > 0
|
|
}
|
|
if hasInterior {
|
|
staticRepairs = footprintStaticInteriorPolygons(footprints)
|
|
polygons = append(polygons, staticRepairs...)
|
|
}
|
|
polygons = append(polygons, sweep...)
|
|
usedOpenSweep = true
|
|
} else {
|
|
staticRepairs = footprintStaticInteriorPolygons(footprints)
|
|
closedSweep = footprintClosedSweepPolygons(footprints)
|
|
legacy = append(footprintPolygons(footprints), staticRepairs...)
|
|
legacy = append(legacy, closedSweep...)
|
|
polygons = legacy
|
|
if len(closedSweep) == 0 {
|
|
polygons = append(polygons, PairedBoundaryPolygons(northern, southern)...)
|
|
}
|
|
}
|
|
} else {
|
|
staticRepairs = footprintStaticInteriorPolygons(footprints)
|
|
closedSweep = footprintClosedSweepPolygons(footprints)
|
|
legacy = append(footprintPolygons(footprints), staticRepairs...)
|
|
legacy = append(legacy, closedSweep...)
|
|
polygons = legacy
|
|
if len(closedSweep) == 0 {
|
|
polygons = append(polygons, PairedBoundaryPolygons(northern, southern)...)
|
|
}
|
|
}
|
|
if len(polygons) == 0 {
|
|
return nil, fmt.Errorf("occultation footprint sweep has no usable polygons")
|
|
}
|
|
polygons = usableOccultationPolygons(polygons)
|
|
if len(polygons) == 0 {
|
|
return nil, fmt.Errorf("occultation footprint sweep has no non-degenerate polygons")
|
|
}
|
|
if usedOpenSweep && len(polygons) == 1 {
|
|
return cleanupFootprintSweepPolygons(closedOccultationSweepFaces(polygons), northern, southern), nil
|
|
}
|
|
merged, err := geodata.UnionPolygons(polygons)
|
|
if err != nil && usedOpenSweep {
|
|
merged, err = occultationRetryOpenSweepUnion(polygons, footprints, northern, southern, err)
|
|
}
|
|
if err != nil {
|
|
if fallback := closedOccultationSweepFaces(polygons); len(fallback) > 0 {
|
|
return cleanupFootprintSweepPolygons(fallback, northern, southern), nil
|
|
}
|
|
return nil, fmt.Errorf("merge instantaneous footprints: %w", err)
|
|
}
|
|
return cleanupFootprintSweepPolygons(merged, northern, southern), nil
|
|
}
|
|
|
|
// occultationRetryOpenSweepUnion 在时间扫掠的球面并集失败后依次重试去掉过渡端帽、
|
|
// 只用配对限带、退回传统瞬时面;全部失败才把原始错误交回调用方。
|
|
func occultationRetryOpenSweepUnion(
|
|
polygons [][]geodata.GeoPoint,
|
|
footprints []basic.OccultationFootprint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
original error,
|
|
) ([][]geodata.GeoPoint, error) {
|
|
bareSweep, bareErr := footprintOpenSweepPolygonsWithoutTransitions(footprints)
|
|
if bareErr == nil && len(bareSweep) > 0 && footprintSweepCoversSamples(bareSweep, footprints) {
|
|
input := footprintClosedPolygons(footprints)
|
|
input = append(input, footprintStaticInteriorPolygons(footprints)...)
|
|
input = append(input, footprintClosedSweepPolygons(footprints)...)
|
|
input = append(input, bareSweep...)
|
|
if merged, err := geodata.UnionPolygons(input); err == nil {
|
|
return merged, nil
|
|
}
|
|
}
|
|
paired := PairedBoundaryPolygons(northern, southern)
|
|
if len(paired) > 0 {
|
|
if merged, err := geodata.UnionPolygons(paired); err == nil {
|
|
return merged, nil
|
|
}
|
|
}
|
|
legacy := footprintPolygons(footprints)
|
|
legacy = append(legacy, footprintStaticInteriorPolygons(footprints)...)
|
|
legacy = append(legacy, footprintClosedSweepPolygons(footprints)...)
|
|
legacy = append(legacy, paired...)
|
|
if len(legacy) > 0 {
|
|
if merged, err := geodata.UnionPolygons(legacy); err == nil {
|
|
return merged, nil
|
|
}
|
|
}
|
|
return nil, original
|
|
}
|
|
|
|
func closedOccultationSweepFaces(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
|
result := make([][]geodata.GeoPoint, 0, len(polygons))
|
|
for _, polygon := range polygons {
|
|
open := openFootprintRing(polygon)
|
|
if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 {
|
|
continue
|
|
}
|
|
closed := append([]geodata.GeoPoint(nil), open...)
|
|
closed = append(closed, open[0])
|
|
result = append(result, closed)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func cleanupFootprintSweepPolygons(
|
|
polygons [][]geodata.GeoPoint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
) [][]geodata.GeoPoint {
|
|
if len(northern) > 0 || len(southern) > 0 {
|
|
polygons = RemoveTinyPolygonComponents(polygons)
|
|
}
|
|
for index := range polygons {
|
|
polygons[index] = removeOccultationHairpins(polygons[index], 35, 25, 12)
|
|
polygons[index] = removeOccultationHairpins(polygons[index], 100, 25, 32)
|
|
polygons[index] = removeOccultationSharpCorners(polygons[index], 20, 30)
|
|
}
|
|
return polygons
|
|
}
|
|
|
|
// VisibleBandPolygons 返回瞬时可见接触区域的连续时间并集。
|
|
// VisibleBandPolygons returns the union of the instantaneous visible
|
|
// footprints when samples are available. That union is the geographic area
|
|
// where the occultation occurs at any time while the Moon is above the local
|
|
// horizon; rise/set phase curves are diagnostic/display boundaries, not the
|
|
// outer edge of this time-union. The contour/linework construction remains a
|
|
// fallback for callers that do not provide footprints.
|
|
func VisibleBandPolygons(
|
|
footprints []basic.OccultationFootprint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, nil, curves, false)
|
|
return normalizeOccultationBandOutput(polygons), authoritative, err
|
|
}
|
|
|
|
// VisibleTotalBandPolygons 是 VisibleBandPolygons 的全掩带变体。
|
|
// VisibleTotalBandPolygons is the total-occultation variant of
|
|
// VisibleBandPolygons. Inner-contact total bands can retain compact numerical
|
|
// polar returns after linework polygonization, so they enable the stronger
|
|
// smoothing pass that would be too aggressive for partial-band slivers.
|
|
func VisibleTotalBandPolygons(
|
|
footprints []basic.OccultationFootprint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, nil, curves, true)
|
|
if authoritative {
|
|
polygons = roundOccultationTotalBandJunctions(polygons)
|
|
}
|
|
return normalizeOccultationBandOutput(polygons), authoritative, err
|
|
}
|
|
|
|
// VisibleBandPolygonsFromContours 使用与 VisibleBandPolygons 相同的时间并集语义,并保留连续接触包络。
|
|
// VisibleBandPolygonsFromContours uses the same time-union semantics as
|
|
// VisibleBandPolygons. Continuous contact envelopes and rise/set curves remain
|
|
// available as fallback/diagnostic geometry, while supplied footprints define
|
|
// the static visible area.
|
|
func VisibleBandPolygonsFromContours(
|
|
footprints []basic.OccultationFootprint,
|
|
contours [][]basic.OccultationPathPoint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, contours, curves, false)
|
|
return normalizeOccultationBandOutput(polygons), authoritative, err
|
|
}
|
|
|
|
// VisibleTotalBandPolygonsFromContours 是基于内接触轮廓的全掩带变体。
|
|
// VisibleTotalBandPolygonsFromContours is the inner-contact total-band variant
|
|
// of VisibleBandPolygonsFromContours.
|
|
func VisibleTotalBandPolygonsFromContours(
|
|
footprints []basic.OccultationFootprint,
|
|
contours [][]basic.OccultationPathPoint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
polygons, authoritative, err := visibleBandPolygons(footprints, northern, southern, contours, curves, true)
|
|
if authoritative {
|
|
polygons = roundOccultationTotalBandJunctions(polygons)
|
|
}
|
|
return normalizeOccultationBandOutput(polygons), authoritative, err
|
|
}
|
|
|
|
// VisibleBandPolygonsFromAnalyticContours 从连续解析接触包络构造静态可见集。
|
|
// VisibleBandPolygonsFromAnalyticContours constructs the static visible set
|
|
// from the complete analytic boundary network. Contact contours bound the time
|
|
// union of F<=0, visibility contours bound the time union of H>=0, and the
|
|
// start/end rise-set curves are their F=0,H=0 transitions. Footprints and limit
|
|
// strips select the covered faces only; none of their edges can enter the
|
|
// returned boundary.
|
|
func VisibleBandPolygonsFromAnalyticContours(
|
|
footprints []basic.OccultationFootprint,
|
|
contactContours, visibilityContours [][]basic.OccultationPathPoint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
return visibleBandPolygonsFromAnalyticContours(
|
|
footprints, contactContours, visibilityContours, northern, southern, curves, false,
|
|
)
|
|
}
|
|
|
|
// VisibleStarBandPolygonsFromAnalyticContours 是点光源恒星掩带的解析轮廓构造入口。
|
|
// VisibleStarBandPolygonsFromAnalyticContours is the point-source stellar
|
|
// variant. Stellar start/end contacts can require a short temporal lunar-
|
|
// horizon connector and stricter graph snapping than finite-disk contacts.
|
|
func VisibleStarBandPolygonsFromAnalyticContours(
|
|
footprints []basic.OccultationFootprint,
|
|
contactContours, visibilityContours [][]basic.OccultationPathPoint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
return visibleBandPolygonsFromAnalyticContours(
|
|
footprints, contactContours, visibilityContours, northern, southern, curves, true,
|
|
)
|
|
}
|
|
|
|
// VisibleTotalBandPolygonsFromAnalyticContours 是解析可见集的内接触全掩带变体。
|
|
// VisibleTotalBandPolygonsFromAnalyticContours is the inner-contact variant of
|
|
// VisibleBandPolygonsFromAnalyticContours.
|
|
func VisibleTotalBandPolygonsFromAnalyticContours(
|
|
footprints []basic.OccultationFootprint,
|
|
contactContours, visibilityContours [][]basic.OccultationPathPoint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
return visibleBandPolygonsFromAnalyticContours(
|
|
footprints, contactContours, visibilityContours, northern, southern, curves, false,
|
|
)
|
|
}
|
|
|
|
func visibleBandPolygonsFromAnalyticContours(
|
|
footprints []basic.OccultationFootprint,
|
|
contactContours, visibilityContours [][]basic.OccultationPathPoint,
|
|
northern, southern []basic.OccultationPathPoint,
|
|
curves []basic.OccultationRiseSetCurve,
|
|
pointSource bool,
|
|
) ([][]geodata.GeoPoint, bool, error) {
|
|
// The analytic boundary is preferred because it preserves the continuous
|
|
// contact envelope. Some grazing/polar tracks still produce a valid set of
|
|
// instantaneous visible footprints while their phase graph has no accepted
|
|
// closed face. Keep that physical time-union as a bounded fallback instead
|
|
// of turning a real event into a serialization error.
|
|
fallbackVisible := func() ([][]geodata.GeoPoint, bool) {
|
|
if len(footprints) == 0 {
|
|
return nil, false
|
|
}
|
|
fallback, fallbackErr := footprintSweepPolygons(footprints, northern, southern)
|
|
if fallbackErr != nil || len(fallback) == 0 {
|
|
return nil, false
|
|
}
|
|
fallback = normalizeOccultationBandOutput(fallback)
|
|
fallback = densifyOccultationPolygons(fallback, 30)
|
|
return fallback, len(fallback) > 0
|
|
}
|
|
if len(curves) == 0 && len(footprints) > 0 {
|
|
// Analytic contact contours alone do not form a closed visible boundary
|
|
// when rise/set computation is disabled. Combine the open sweep with only
|
|
// closed instantaneous footprints; a full sparse union is both expensive
|
|
// and unnecessary for this compatibility path.
|
|
if fallback, fallbackErr := footprintSweepPolygons(footprints, northern, southern); fallbackErr == nil {
|
|
closed := footprintClosedPolygons(footprints)
|
|
input := append(append([][]geodata.GeoPoint(nil), fallback...), closed...)
|
|
if merged, mergeErr := geodata.UnionPolygons(input); mergeErr == nil && len(merged) > 0 {
|
|
return cleanupFootprintSweepPolygons(merged, northern, southern), false, nil
|
|
}
|
|
}
|
|
}
|
|
contactLines := occultationContactContourBoundaryLines(contactContours)
|
|
if len(contactLines) == 0 {
|
|
return nil, false, fmt.Errorf("analytic occultation boundary has no contact temporal envelope")
|
|
}
|
|
boundaryCurves := occultationStaticBandCurves(curves)
|
|
lineworkCurves := boundaryCurves
|
|
if pointSource && len(visibilityContours) == 0 {
|
|
// Without a separate H=0 temporal envelope, a greatest-rise/set arc can
|
|
// become part of the outer visible-set boundary in a short horizon wedge.
|
|
// Include all six phase curves in the face graph; merging selected faces
|
|
// removes any portions that are truly internal.
|
|
lineworkCurves = curves
|
|
}
|
|
connectors := HorizonConnectorSegments(footprints, boundaryCurves, northern, southern)
|
|
if pointSource {
|
|
connectors = StarHorizonConnectorSegments(footprints, boundaryCurves, northern, southern)
|
|
}
|
|
connectorLines := occultationHorizonConnectorBoundaryLines(connectors)
|
|
boundaryLines := occultationVisibleBoundaryLinesFromBase(
|
|
contactLines, lineworkCurves,
|
|
occultationContactContourBoundaryLines(visibilityContours),
|
|
)
|
|
boundaryLines = append(boundaryLines, connectorLines...)
|
|
var footprintFill [][]geodata.GeoPoint
|
|
fillReady := false
|
|
getFootprintFill := func() [][]geodata.GeoPoint {
|
|
if !fillReady {
|
|
footprintFill = occultationVisibleFootprintFillOnly(footprints)
|
|
fillReady = true
|
|
}
|
|
return footprintFill
|
|
}
|
|
var selectionFill [][]geodata.GeoPoint
|
|
if pointSource || len(visibilityContours) == 0 {
|
|
// Geocentric limit strips can extend beyond the station-corrected
|
|
// stellar envelope or omit a finite-disk horizon extremum. Use visible
|
|
// footprints as witnesses; the analytic linework supplies every edge.
|
|
selectionFill = getFootprintFill()
|
|
}
|
|
if len(selectionFill) == 0 {
|
|
selectionFill = occultationLimitVisibleFillPolygons(northern, southern)
|
|
}
|
|
if len(selectionFill) == 0 {
|
|
selectionFill = getFootprintFill()
|
|
}
|
|
if len(selectionFill) == 0 {
|
|
return nil, false, fmt.Errorf("analytic occultation boundary has no interior selection fill")
|
|
}
|
|
lineworkToleranceKM := 2.0
|
|
if !pointSource && len(visibilityContours) == 0 {
|
|
// Finite-disk compatibility: without a separate H=0 envelope, sparse
|
|
// contact/limit samples need the established one-edge graph tolerance.
|
|
lineworkToleranceKM = 40
|
|
}
|
|
phaseLines := occultationRiseSetBoundaryLines(curves)
|
|
polygonize := func(coverage [][]geodata.GeoPoint) ([][]geodata.GeoPoint, error) {
|
|
if pointSource {
|
|
return geodata.VisibleLineworkPolygonsWithAuditTolerance(
|
|
boundaryLines, selectionFill, coverage, lineworkToleranceKM, 30,
|
|
)
|
|
}
|
|
return geodata.VisibleLineworkPolygons(
|
|
boundaryLines, selectionFill, coverage, lineworkToleranceKM,
|
|
)
|
|
}
|
|
var initialCoverage [][]geodata.GeoPoint
|
|
if pointSource && len(visibilityContours) == 0 {
|
|
initialCoverage = phaseLines
|
|
}
|
|
polygons, err := polygonize(initialCoverage)
|
|
if err != nil && len(phaseLines) > 0 {
|
|
// A multi-branch rise/set network can contain several closed faces with
|
|
// identical physical junctions. If fill-only selection chooses the
|
|
// adjacent face, require every exported phase line as a coverage witness
|
|
// and retry without changing the boundary network or snap tolerance.
|
|
polygons, err = polygonize(phaseLines)
|
|
}
|
|
if err != nil {
|
|
if fallback, ok := fallbackVisible(); ok {
|
|
return fallback, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("analytic occultation boundary: %w", err)
|
|
}
|
|
polygons = normalizeOccultationBandOutput(polygons)
|
|
if pointSource {
|
|
polygons = RemoveTinyPolygonComponents(polygons)
|
|
}
|
|
polygons = densifyOccultationPolygons(polygons, 30)
|
|
if len(polygons) == 0 {
|
|
if fallback, ok := fallbackVisible(); ok {
|
|
return fallback, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("analytic occultation boundary produced no polygon")
|
|
}
|
|
if len(phaseLines) > 0 && !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) {
|
|
// A coarse limit strip reliably identifies the main face, but it need not
|
|
// reach a narrow face that terminates where a rise/set phase meets the
|
|
// contact or visibility envelope. Use instantaneous footprints only to
|
|
// decide which side of each phase curve is physically inside, then repeat
|
|
// face selection against the unchanged analytic boundary network.
|
|
phaseCoverage := occultationCurveCoverageProbes(curves, getFootprintFill())
|
|
if len(phaseCoverage) > 0 {
|
|
// 相位重试必须与首遍走同一个 polygonize 闭包:单独硬编码节点吸附容差会让
|
|
// 恰好依赖有限圆盘 40 km 吸附的场景静默退回 footprint-sweep。
|
|
selected, selectionErr := polygonize(phaseCoverage)
|
|
if selectionErr != nil {
|
|
if fallback, ok := fallbackVisible(); ok {
|
|
return fallback, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("analytic occultation boundary phase selection: %w", selectionErr)
|
|
}
|
|
selected = normalizeOccultationBandOutput(selected)
|
|
if pointSource {
|
|
selected = RemoveTinyPolygonComponents(selected)
|
|
}
|
|
selected = densifyOccultationPolygons(selected, 30)
|
|
if len(selected) > 0 {
|
|
polygons = selected
|
|
}
|
|
}
|
|
}
|
|
if len(phaseLines) > 0 && !geodata.SphericalPolygonsContainPathsWithinKM(polygons, phaseLines, false, 2) {
|
|
miss := geodata.SphericalPolygonsPathMissDistanceKM(polygons, phaseLines, false)
|
|
// When no visibility contour exists, the event is visible throughout the
|
|
// contact envelope and there is no H=0 transition to close. A small phase
|
|
// residual can remain where the sampled contact envelope meets a rise/set
|
|
// branch; keep the analytic face if that residual is below one rendered
|
|
// edge. Events with visibility contours retain the strict 2 km invariant.
|
|
if !pointSource && len(visibilityContours) == 0 && miss <= 40 {
|
|
return polygons, true, nil
|
|
}
|
|
if fallback, ok := fallbackVisible(); ok {
|
|
return fallback, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf(
|
|
"analytic occultation boundary misses a rise/set phase by %.1f km",
|
|
miss,
|
|
)
|
|
}
|
|
return polygons, true, nil
|
|
}
|
|
|
|
func occultationRiseSetBoundaryLines(
|
|
curves []basic.OccultationRiseSetCurve,
|
|
) [][]geodata.GeoPoint {
|
|
lines := make([][]geodata.GeoPoint, 0, len(curves)*2)
|
|
for _, curve := range curves {
|
|
lines = append(lines, occultationCurveBoundaryLines(curve)...)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func normalizeOccultationBandOutput(polygons [][]geodata.GeoPoint) [][]geodata.GeoPoint {
|
|
if len(polygons) == 0 {
|
|
return nil
|
|
}
|
|
result := make([][]geodata.GeoPoint, 0, len(polygons))
|
|
for _, polygon := range polygons {
|
|
open := openFootprintRing(polygon)
|
|
if len(open) < 3 || math.Abs(geoRingArea(open)) <= 1e-12 {
|
|
continue
|
|
}
|
|
result = append(result, polygon)
|
|
}
|
|
return result
|
|
}
|