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

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
}