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

1283 lines
50 KiB
Go

package occultationgeo
import (
"b612.me/astro/basic"
"b612.me/astro/internal/geodata"
)
func visibleBandPolygons(
footprints []basic.OccultationFootprint,
northern, southern []basic.OccultationPathPoint,
contours [][]basic.OccultationPathPoint,
curves []basic.OccultationRiseSetCurve,
strongPolarSmoothing bool,
) ([][]geodata.GeoPoint, bool, error) {
contactLines := occultationContactContourBoundaryLines(contours)
useContactContours := len(contactLines) > 0
// Only a single north/south contour pair is a stable global envelope. A
// contour set split into several branch fragments (common near polar folds)
// must retain the sampled visible-union path; feeding those fragments to the
// generic linework selector creates one polygon per numerical branch and is
// both slower and less faithful than the time-union sweep.
stableContactEnvelope := useContactContours && len(contours) == 2
// A station-corrected north/south contact pair already parameterizes the
// complete time-union boundary. Clip each adjacent time cell to the lunar
// horizon and merge those cells directly. This is the physical definition
// of the visible band; it avoids asking a planar polygonizer to choose among
// folded polar faces and keeps partial/total bands on the same residual
// model. Footprints remain independent witnesses/timeline data.
var (
fallback [][]geodata.GeoPoint
swept [][]geodata.GeoPoint
sweepErr error
sweepComputed bool
visibleUnion [][]geodata.GeoPoint
visibleUnionComputed bool
)
computeSweep := func() ([][]geodata.GeoPoint, error) {
if !sweepComputed {
swept, sweepErr = footprintSweepPolygons(footprints, northern, southern)
sweepComputed = true
}
return swept, sweepErr
}
computeVisibleUnion := func() [][]geodata.GeoPoint {
if !visibleUnionComputed {
visibleUnion = footprintVisibleUnionPolygons(footprints)
visibleUnionComputed = true
}
return visibleUnion
}
if !useContactContours && len(footprints) > 0 && len(curves) > 0 {
// Footprints are already clipped to the lunar horizon at each sampled
// instant. With no supplied contact contour there is no alternative
// analytic boundary to select, so the continuous footprint sweep is the
// authoritative sampled time-union rather than a legacy fallback.
swept, err := computeSweep()
if err != nil {
return nil, false, err
}
if len(swept) > 0 {
return cleanupOccultationAuthoritativeBandPolygons(swept), true, nil
}
}
if len(curves) == 0 && len(footprints) > 0 {
// Without rise/set curves there is no closed phase boundary to select a
// face from. Return the horizon-visible footprint sweep directly, even
// when contact contours are present; feeding an open contour network to
// the polygonizer can select a complement face and drop the greatest
// point from the static band.
fallback, err := computeSweep()
if err != nil {
return nil, false, err
}
// The sweep has already passed the ordinary topology cleanup; deleting
// another span here can remove intermediate samples around a polar fold
// and recreate a long straight chord. Densify the cleaned ring instead.
fallback = densifyOccultationPolygons(fallback, 50)
for index := range fallback {
fallback[index] = smoothOccultationHairpins(fallback[index], 180, 25, 16, 50)
fallback[index] = removeOccultationSharpCorners(fallback[index], 20, 30)
}
fallback = mergeStaticFootprintRepairs(fallback, footprints)
// If the sparse open sweep is split at horizon transitions, add only the
// already-closed instantaneous footprints. This restores a closed face
// around greatest without running a boolean union over every sampled
// footprint (which is prohibitively expensive for historical events).
closed := footprintClosedPolygons(footprints)
if len(closed) > 0 {
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
}
}
return fallback, false, nil
}
if len(footprints) > 0 && !stableContactEnvelope {
if candidate, authoritative, handled := occultationEarlyVisibleBandCandidate(
footprints, northern, southern, contours, curves,
useContactContours, strongPolarSmoothing, computeSweep, computeVisibleUnion,
); handled {
return candidate, authoritative, nil
}
}
if useContactContours && len(contours) > 2 {
// Split contact contours describe several valid numerical branches. A
// planar face selector cannot reliably decide their complement, while the
// horizon-clipped sweep is a deterministic union of the same instantaneous
// states. Keep that union authoritative when it remains one component:
// mutually discontinuous contour fragments cannot witness its boundary, so
// the single-component construction contract is the applicable check here.
if swept, err := computeSweep(); err == nil && len(swept) == 1 {
return cleanupOccultationAuthoritativeBandPolygons(swept), true, nil
}
}
if !useContactContours {
var err error
fallback, err = computeSweep()
if err != nil {
return nil, false, err
}
}
baseBoundaryLines := occultationFallbackPolygonBoundaryLines(fallback)
// Greatest is a diagnostic meridian, not an exterior edge of the static
// visible band. Feeding it to the polygonizer lets a polar fold be selected
// as a legitimate face boundary, which creates a sharp polar-end kink. Keep
// it in curve coverage probes and in
// the exported line features, but exclude it from the authoritative fill
// topology whenever continuous contact contours are available.
boundaryCurves := curves
if useContactContours {
boundaryCurves = occultationStaticBandCurves(curves)
}
// Horizon connectors are part of the authoritative visible boundary, not
// merely a last-resort repair. When the contact contours and rise/set
// curves are both open at a moonrise/moonset transition, omitting this
// short physical arc lets polygonization succeed on a different face and
// leaves the exported phase curve outside (or inside) the filled band.
initialConnectorLines := occultationHorizonConnectorBoundaryLines(
HorizonConnectorSegments(footprints, boundaryCurves, northern, southern),
)
if useContactContours {
baseBoundaryLines = contactLines
}
visibleFill, coveragePaths := occultationVisibleFillAndCoverage(footprints)
contourFill, _ := occultationContourFillAndCoverage(useContactContours, northern, southern, nil)
// Phase-cycle construction and its spherical witness checks are expensive,
// especially for minute-sampled curves. Several fallback branches need
// the same candidate, so build and validate it once per band generation.
var (
preferredPhaseBandRaw [][]geodata.GeoPoint
preferredPhaseBandRawOK bool
preferredPhaseBandComputed bool
preferredPhaseBandCandidate [][]geodata.GeoPoint
preferredPhaseBandAccepted bool
)
computePreferredPhaseBand := func() ([][]geodata.GeoPoint, bool) {
if !preferredPhaseBandComputed {
preferredPhaseBandRaw, preferredPhaseBandRawOK = occultationPreferredPhaseBand(
useContactContours, footprints, curves, northern, southern, contourFill,
)
if preferredPhaseBandRawOK {
physicalBoundary := preferredPhaseBandRaw
candidate := cleanupOccultationAuthoritativeBandPolygons(preferredPhaseBandRaw)
candidate = preserveOccultationPhaseBoundaryEnvelope(candidate, physicalBoundary)
if occultationPreferredPhaseBandAccepted(candidate, contourFill, visibleFill) {
preferredPhaseBandCandidate = candidate
preferredPhaseBandAccepted = true
}
}
preferredPhaseBandComputed = true
}
return preferredPhaseBandRaw, preferredPhaseBandRawOK
}
computeAcceptedPreferredPhaseBand := func() ([][]geodata.GeoPoint, bool) {
computePreferredPhaseBand()
return preferredPhaseBandCandidate, preferredPhaseBandAccepted
}
var (
contactSweepLines [][]geodata.GeoPoint
contactSweepLinesComputed bool
)
computeContactSweepLines := func() [][]geodata.GeoPoint {
if !contactSweepLinesComputed {
contactSweepLines = ContactSweepBoundaryLines(footprints)
contactSweepLinesComputed = true
}
return contactSweepLines
}
boundaryLines := occultationVisibleBoundaryLinesFromBase(baseBoundaryLines, boundaryCurves, initialConnectorLines)
if preferred, ok := occultationPreferredContactBandCandidate(
useContactContours, computePreferredPhaseBand, computeAcceptedPreferredPhaseBand,
computeVisibleUnion, visibleFill, contourFill, coveragePaths, curves,
); ok {
return preferred, true, nil
}
if fast, ok := occultationAcceptedFastBandCandidate(
useContactContours, strongPolarSmoothing, boundaryLines,
visibleFill, contourFill, coveragePaths, curves, computePreferredPhaseBand,
); ok {
return fast, true, nil
}
if combined, ok := occultationContactSweepPhaseCandidate(
useContactContours, footprints, computeSweep, computePreferredPhaseBand,
visibleFill, contourFill, coveragePaths, curves,
); ok {
return combined, true, nil
}
if candidate, authoritative, ok := occultationBoundedContactSweepCandidate(
useContactContours, computeSweep, contours, strongPolarSmoothing,
); ok {
return candidate, authoritative, nil
}
if len(visibleFill) == 0 && len(contourFill) == 0 {
return occultationNoVisibleBandFill(
useContactContours, computeSweep, fallback, strongPolarSmoothing,
)
}
selectionFill, coveragePaths, curveCoveragePaths, visibleFillCoveragePaths :=
occultationVisibleBandLineworkInputs(
useContactContours, strongPolarSmoothing,
visibleFill, contourFill, coveragePaths, curves,
)
visible, lineworkErr := occultationRetryVisibleBandLinework(
occultationVisibleBandLineworkOptions{
useContactContours: useContactContours,
footprints: footprints,
northern: northern,
southern: southern,
curves: curves,
boundaryCurves: boundaryCurves,
boundaryLines: boundaryLines,
baseBoundaryLines: baseBoundaryLines,
selectionFill: selectionFill,
coveragePaths: coveragePaths,
curveCoveragePaths: curveCoveragePaths,
visibleFill: visibleFill,
visibleFillCoveragePaths: visibleFillCoveragePaths,
initialConnectorLines: initialConnectorLines,
contactLines: contactLines,
computeSweep: computeSweep,
computeContactSweepLines: computeContactSweepLines,
},
)
visible = occultationMergePreferredVisibleBand(
visible, useContactContours, footprints, curves, northern, southern,
contourFill, visibleFill, visibleFillCoveragePaths,
)
if lineworkErr == nil {
lineworkErr = validateOccultationVisibleBandWitnesses(
visible, useContactContours, visibleFill, contourFill, visibleFillCoveragePaths,
)
}
if lineworkErr != nil && useContactContours && len(visibleFill) > 0 {
if stableVisible, stableErr := occultationRetryStableContactBoundary(
occultationVisibleBandLineworkOptions{
useContactContours: useContactContours,
boundaryCurves: boundaryCurves,
visibleFill: visibleFill,
visibleFillCoveragePaths: visibleFillCoveragePaths,
initialConnectorLines: initialConnectorLines,
computeSweep: computeSweep,
computeContactSweepLines: computeContactSweepLines,
},
); stableErr == nil {
visible, lineworkErr = stableVisible, nil
}
}
if lineworkErr != nil {
if useContactContours {
// A direct open-footprint sweep is a coverage-preserving fallback only.
// It does not share the phase/contact cycle used by the authoritative
// boundary, so never let it replace a successful contour polygonization.
if direct, directOK := DirectVisibleBandPolygons(footprints); directOK {
return cleanupOccultationVisibleBandPolygons(direct, strongPolarSmoothing), false, nil
}
if fallback, err := computeSweep(); err == nil {
if authoritative, ok := authoritativeFallbackBandPolygons(fallback, contours, strongPolarSmoothing); ok {
return authoritative, true, nil
}
return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil
}
if len(visibleFill) > 0 {
return cleanupOccultationVisibleBandPolygons(visibleFill, strongPolarSmoothing), false, nil
}
return nil, false, lineworkErr
}
return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil
}
if lineworkErr == nil && useContactContours && len(visibleFill) > 0 {
// A folded phase cycle can be topologically valid yet select an inner
// branch at a polar horizon. The instantaneous visible footprints provide
// an independent witness for the actual outer envelope. Promote their
// union only when it substantially covers the phase cycle while the phase
// cycle does not cover the union; this keeps ordinary smooth cycles on the
// cheaper contact/rise-set path.
if footprintUnion, unionErr := geodata.UnionPolygons(visibleFill); unionErr == nil && len(footprintUnion) > 0 {
phaseMiss := geodata.SphericalPolygonsPathMissDistanceKM(visible, footprintUnion, true)
unionMiss := geodata.SphericalPolygonsPathMissDistanceKM(footprintUnion, visible, true)
if phaseMiss > 20 && unionMiss <= 50 {
visible = footprintUnion
}
}
}
if !useContactContours {
// The phase-boundary polygonizer supplies the smooth global outline, but a
// polar fold can leave a very narrow face out of its selected cycle. Merge
// the horizon-clipped instantaneous faces back into that result: every
// added point is already Moon-altitude-visible, so this repairs coverage
// without restoring the below-horizon part of the fallback sweep.
combinedVisible := append([][]geodata.GeoPoint(nil), visible...)
combinedVisible = append(combinedVisible, visibleFill...)
if augmented, unionErr := geodata.UnionPolygons(combinedVisible); unionErr == nil {
visible = augmented
}
}
if useContactContours {
return roundOccultationAuthoritativeBandJunctions(
cleanupOccultationAuthoritativeBandPolygons(visible),
), true, nil
}
return cleanupOccultationVisibleBandPolygons(visible, strongPolarSmoothing), true, nil
}
func occultationPreferredContactBandCandidate(
useContactContours bool,
computePreferred func() ([][]geodata.GeoPoint, bool),
computeAcceptedPreferred func() ([][]geodata.GeoPoint, bool),
computeVisibleUnion func() [][]geodata.GeoPoint,
visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
) ([][]geodata.GeoPoint, bool) {
if !useContactContours {
return nil, false
}
// The static display contract combines every horizon-visible footprint with
// the exported start/end phase envelope. Try that complete physical union
// before a generic polygonizer can select a folded complement face.
if preferred, ok := computePreferred(); ok {
if visibleUnion := computeVisibleUnion(); len(visibleUnion) > 0 {
combined := append([][]geodata.GeoPoint(nil), visibleUnion...)
combined = append(combined, preferred...)
if merged, err := geodata.UnionPolygons(combined); err == nil && len(merged) > 0 {
merged = cleanupOccultationAuthoritativeBandPolygons(merged)
merged = preserveOccultationPhaseBoundaryEnvelope(merged, preferred)
if occultationFastPolarBandAccepted(
merged, visibleFill, contourFill, coveragePaths, curves,
) {
return roundOccultationAuthoritativeBandJunctions(merged), true
}
}
}
}
// A complete start/end phase cycle is itself the physical outer boundary of
// a horizon-clipped band. The acceptance gate has already checked its
// contour and footprint witnesses.
if preferred, ok := computeAcceptedPreferred(); ok {
return roundOccultationAuthoritativeBandJunctions(preferred), true
}
return nil, false
}
func occultationAcceptedFastBandCandidate(
useContactContours, strongPolarSmoothing bool,
boundaryLines, visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
computePreferred func() ([][]geodata.GeoPoint, bool),
) ([][]geodata.GeoPoint, bool) {
visible, ok := occultationFastPolarBand(
useContactContours, strongPolarSmoothing, boundaryLines,
visibleFill, contourFill, coveragePaths, curves,
)
if !ok {
return nil, false
}
visible = cleanupOccultationAuthoritativeBandPolygons(visible)
if occultationFastPolarBandAccepted(visible, visibleFill, contourFill, coveragePaths, curves) {
return visible, true
}
if !useContactContours {
return nil, false
}
// A fast polar face can choose an inner branch. Union it with the explicit
// phase cycle and accept only when the same physical witnesses still pass.
preferred, preferredOK := computePreferred()
if !preferredOK {
return nil, false
}
combined := append([][]geodata.GeoPoint(nil), visible...)
combined = append(combined, preferred...)
merged, err := geodata.UnionPolygons(combined)
if err != nil {
return nil, false
}
merged = cleanupOccultationAuthoritativeBandPolygons(merged)
merged = preserveOccultationPhaseBoundaryEnvelope(merged, preferred)
if !occultationFastPolarBandAccepted(merged, visibleFill, contourFill, coveragePaths, curves) {
return nil, false
}
return merged, true
}
func occultationContactSweepPhaseCandidate(
useContactContours bool,
footprints []basic.OccultationFootprint,
computeSweep func() ([][]geodata.GeoPoint, error),
computePreferred func() ([][]geodata.GeoPoint, bool),
visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
) ([][]geodata.GeoPoint, bool) {
if !useContactContours {
return nil, false
}
// The normal sweep is cheaper; the direct sweep is retained for ribbons
// whose cross-arcs need its denser union. Both must be joined to the same
// accepted phase family and pass identical witnesses.
if swept, err := computeSweep(); err == nil {
if merged, ok := occultationMergeSweepWithPreferredPhase(
swept, computePreferred, visibleFill, contourFill, coveragePaths, curves,
); ok {
return roundOccultationAuthoritativeBandJunctions(merged), true
}
}
if direct, ok := DirectVisibleBandPolygons(footprints); ok {
if merged, ok := occultationMergeSweepWithPreferredPhase(
direct, computePreferred, visibleFill, contourFill, coveragePaths, curves,
); ok {
return roundOccultationAuthoritativeBandJunctions(merged), true
}
}
return nil, false
}
func occultationMergeSweepWithPreferredPhase(
sweep [][]geodata.GeoPoint,
computePreferred func() ([][]geodata.GeoPoint, bool),
visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
) ([][]geodata.GeoPoint, bool) {
if len(sweep) == 0 {
return nil, false
}
preferred, ok := computePreferred()
if !ok {
return nil, false
}
combined := append([][]geodata.GeoPoint(nil), sweep...)
combined = append(combined, preferred...)
merged, err := geodata.UnionPolygons(combined)
if err != nil {
return nil, false
}
merged = cleanupOccultationAuthoritativeBandPolygons(merged)
merged = preserveOccultationPhaseBoundaryEnvelope(merged, preferred)
if !occultationFastPolarBandAccepted(merged, visibleFill, contourFill, coveragePaths, curves) {
return nil, false
}
return merged, true
}
func occultationBoundedContactSweepCandidate(
useContactContours bool,
computeSweep func() ([][]geodata.GeoPoint, error),
contours [][]basic.OccultationPathPoint,
strongPolarSmoothing bool,
) ([][]geodata.GeoPoint, bool, bool) {
if !useContactContours {
return nil, false, false
}
swept, err := computeSweep()
if err != nil || len(swept) == 0 {
return nil, false, false
}
// Bound the expensive linework retry tree with the already computed,
// coverage-preserving temporal sweep. It is authoritative only when the
// continuous contact contours independently witness its boundary.
if authoritative, ok := authoritativeFallbackBandPolygons(
swept, contours, strongPolarSmoothing,
); ok {
return authoritative, true, true
}
cleaned := cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing)
if len(cleaned) == 0 {
return nil, false, false
}
return cleaned, false, true
}
func occultationEarlyVisibleBandCandidate(
footprints []basic.OccultationFootprint,
northern, southern []basic.OccultationPathPoint,
contours [][]basic.OccultationPathPoint,
curves []basic.OccultationRiseSetCurve,
useContactContours, strongPolarSmoothing bool,
computeSweep func() ([][]geodata.GeoPoint, error),
computeVisibleUnion func() [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, bool, bool) {
// A split contour set can still use the bounded endpoint fallback when
// north/south branches remain paired. Odd or incomplete branches continue
// through the phase/linework selector for a smooth envelope.
if useContactContours && len(contours) > 2 && len(contours)%2 == 0 {
contourFill, _ := occultationContourFillAndCoverage(true, northern, southern, nil)
if fallback, ok := footprintEndpointContourFallback(footprints, contourFill); ok && len(fallback) == 1 {
// A continuous sweep is more faithful when it proves that all
// horizon-visible source faces belong to the same temporal ribbon.
if swept, sweepErr := computeSweep(); sweepErr == nil && len(swept) == 1 {
visibleUnion := computeVisibleUnion()
if len(visibleUnion) > 1 && footprintSweepCoversSamples(swept, footprints) {
return cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing), true, true
}
}
return fallback, true, true
}
}
sweepRequiresLinework := false
// Prefer one continuous temporal sweep over independently closed
// instantaneous footprints. The sweep carries the moving contact arc
// between samples, avoiding a staircase from a single horizon closure.
if swept, sweepErr := computeSweep(); sweepErr == nil && len(swept) > 0 {
// The same contact-contour witness used by the later linework fallback can
// be checked before constructing the expensive horizon-visible union. When
// it accepts a single sweep, the union cannot change the selected band: its
// only purpose on that path is to discover a fragmented mask that is already
// implied by the witnessed contact envelope.
if useContactContours && len(swept) == 1 && hasBracketedClosedFootprintRun(footprints) {
if authoritative, authoritativeOK := authoritativeFallbackBandPolygons(
swept, contours, strongPolarSmoothing,
); authoritativeOK {
return authoritative, true, true
}
}
visibleUnion := computeVisibleUnion()
if len(visibleUnion) > 0 && footprintSweepNeedsHorizonClipping(swept, visibleUnion, footprints) {
if len(visibleUnion) > 1 && len(swept) == 1 && footprintSweepCoversSamples(swept, footprints) {
return cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing), true, true
}
outsideVisibleKM := 0.0
// A fragmented union already decides the linework path; avoid the
// expensive edge-distance calculation in that case.
if !(len(visibleUnion) > 1 && len(swept) == 1) {
outsideVisibleKM = geodata.SphericalPolygonsPathMissDistanceKM(visibleUnion, swept, true)
}
if len(visibleUnion) > 1 && len(swept) == 1 {
sweepRequiresLinework = len(curves) > 0
}
if sweepRequiresLinework && useContactContours {
if authoritative, authoritativeOK := authoritativeFallbackBandPolygons(
swept, contours, strongPolarSmoothing,
); authoritativeOK {
return authoritative, true, true
}
}
if outsideVisibleKM > 20 && len(curves) > 0 {
sweepRequiresLinework = true
}
if outsideVisibleKM <= 20 && len(curves) > 0 && len(northern) > 0 && len(southern) > 0 {
contourFill, _ := occultationContourFillAndCoverage(true, northern, southern, nil)
if phaseBand, ok := occultationPreferredPhaseBand(
true, footprints, curves, northern, southern, contourFill,
); ok {
combined := append([][]geodata.GeoPoint(nil), swept...)
combined = append(combined, phaseBand...)
if merged, mergeErr := geodata.UnionPolygons(combined); mergeErr == nil && len(merged) > 0 {
return cleanupOccultationAuthoritativeBandPolygons(merged), true, true
}
}
}
// A sweep entering the below-horizon complement cannot be repaired by
// splitting at classified vertices without manufacturing polar faces.
visibleUnion = cleanupOccultationFootprintUnionPolygons(visibleUnion, strongPolarSmoothing)
if len(visibleUnion) == 1 {
return visibleUnion, true, true
}
if !sweepRequiresLinework {
return cleanupOccultationVisibleBandPolygons(swept, strongPolarSmoothing), true, true
}
}
}
// Legacy footprints without open boundary arcs retain their exact visible
// union as a bounded fallback when continuous linework is unnecessary.
if !sweepRequiresLinework {
if visibleUnion := computeVisibleUnion(); len(visibleUnion) > 0 {
return cleanupOccultationFootprintUnionPolygons(
visibleUnion, strongPolarSmoothing && !useContactContours,
), true, true
}
}
return nil, false, false
}
func hasBracketedClosedFootprintRun(footprints []basic.OccultationFootprint) bool {
firstClosed, lastClosed := -1, -1
for index, footprint := range footprints {
if !footprint.Closed {
continue
}
if firstClosed < 0 {
firstClosed = index
}
lastClosed = index
}
return firstClosed >= 3 && lastClosed > firstClosed && lastClosed+3 < len(footprints)
}
// footprintEndpointContourFallback is the bounded fallback for a contact
// envelope split into incompatible branches. The paired contact fill carries
// the interval interior while the open-footprint sweep provides temporal caps.
// Unlike a union of every instantaneous horizon polygon, this construction
// cannot turn small sampling seams into hundreds of static-band components.
// The sweep is authoritative only when its boundary is independently witnessed
// by the contact contours supplied by the caller.
func footprintEndpointContourFallback(
footprints []basic.OccultationFootprint,
contourFill [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, bool) {
if len(footprints) == 0 || len(contourFill) == 0 {
return nil, false
}
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})
}
endpointOutlines, err := geodata.OpenBoundaryEndpointOutlines(samples)
if err != nil || len(endpointOutlines) == 0 {
return nil, false
}
inputs := append([][]geodata.GeoPoint(nil), contourFill...)
inputs = append(inputs, endpointOutlines...)
merged, err := geodata.UnionPolygons(inputs)
if err != nil || len(merged) == 0 {
return nil, false
}
// A horizon transition can make the full visible-source union fail at one
// numerically open edge. Recover the missing time-union lobe by greedily
// adding only source faces that (a) have vertices outside the current face
// and (b) union into that same single connected face. Detached components and
// the malformed source are skipped, so this cannot turn a local repair into
// a collection of sampled fragments.
merged = mergeEndpointFallbackVisibleSources(merged, footprints)
merged = normalizeOccultationBandOutput(merged)
if len(merged) == 0 {
return nil, false
}
return densifyOccultationPolygons(merged, 40), true
}
func mergeEndpointFallbackVisibleSources(
base [][]geodata.GeoPoint,
footprints []basic.OccultationFootprint,
) [][]geodata.GeoPoint {
if len(base) != 1 || len(base[0]) < 3 {
return base
}
sources := occultationVisibleFootprintFillOnly(footprints)
if len(sources) == 0 {
return base
}
merged := base
accepted := 0
const maximumAcceptedSources = 48
// Start at the final temporal samples: endpoint fallback already carries the
// first/last contact caps, so the missing lobe is normally adjacent to one
// of the last visible faces. Working backwards avoids adding an unrelated
// early-time face that can move the opposite boundary by a few kilometres.
for sourceIndex := len(sources) - 1; sourceIndex >= 0; sourceIndex-- {
source := sources[sourceIndex]
if accepted >= maximumAcceptedSources || len(source) < 3 {
break
}
// Skip faces already covered by the current candidate. Sampling vertices
// keeps this gate bounded while still catching a narrow endpoint lobe.
outside := false
touches := false
step := (len(source) + 31) / 32
if step < 1 {
step = 1
}
for index := 0; index < len(source); index += step {
point := source[index]
if !geodata.SphericalPolygonsContainPoints(merged, []geodata.GeoPoint{point})[0] {
outside = true
_, distance := nearestPolygonBoundaryPoint(merged, point)
if distance <= 120 {
touches = true
}
}
}
if !outside || !touches {
continue
}
input := append([][]geodata.GeoPoint(nil), merged...)
input = append(input, source)
candidate, err := geodata.UnionPolygons(input)
if err != nil || len(candidate) != 1 {
continue
}
merged = candidate
accepted++
}
return merged
}
type occultationVisibleBandLineworkOptions struct {
useContactContours bool
footprints []basic.OccultationFootprint
northern []basic.OccultationPathPoint
southern []basic.OccultationPathPoint
curves []basic.OccultationRiseSetCurve
boundaryCurves []basic.OccultationRiseSetCurve
boundaryLines [][]geodata.GeoPoint
baseBoundaryLines [][]geodata.GeoPoint
selectionFill [][]geodata.GeoPoint
coveragePaths [][]geodata.GeoPoint
curveCoveragePaths [][]geodata.GeoPoint
visibleFill [][]geodata.GeoPoint
visibleFillCoveragePaths [][]geodata.GeoPoint
initialConnectorLines [][]geodata.GeoPoint
contactLines [][]geodata.GeoPoint
computeSweep func() ([][]geodata.GeoPoint, error)
computeContactSweepLines func() [][]geodata.GeoPoint
}
// occultationRetryVisibleBandLinework owns the bounded candidate sequence for
// events that have no accepted direct phase cycle. Keeping the retries in one
// helper makes their order explicit and keeps visibleBandPolygons focused on
// selecting between the fast physical constructions and this slow path.
func occultationRetryVisibleBandLinework(
options occultationVisibleBandLineworkOptions,
) ([][]geodata.GeoPoint, error) {
useContactContours := options.useContactContours
footprints := options.footprints
northern, southern := options.northern, options.southern
curves, boundaryCurves := options.curves, options.boundaryCurves
boundaryLines := options.boundaryLines
baseBoundaryLines := options.baseBoundaryLines
selectionFill := options.selectionFill
coveragePaths := options.coveragePaths
curveCoveragePaths := options.curveCoveragePaths
visibleFill := options.visibleFill
visibleFillCoveragePaths := options.visibleFillCoveragePaths
initialConnectorLines := options.initialConnectorLines
contactLines := options.contactLines
computeContactSweepLines := options.computeContactSweepLines
visible, lineworkErr := geodata.VisibleLineworkPolygons(
boundaryLines, selectionFill, coveragePaths, 75,
)
if lineworkErr != nil && useContactContours && len(visibleFill) > 0 {
if retryVisible, retryErr := geodata.VisibleLineworkPolygons(
boundaryLines, visibleFill, visibleFillCoveragePaths, 75,
); retryErr == nil {
visible, lineworkErr = retryVisible, nil
}
}
if lineworkErr != nil && useContactContours && len(visibleFill) > 0 {
if stableVisible, stableErr := occultationRetryStableContactBoundary(options); stableErr == nil {
visible, lineworkErr = stableVisible, nil
}
}
if lineworkErr != nil {
// A polar rise/set curve can contain a fold with two physical branches.
// Retry a bounded set of branch-specific boundary candidates.
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
baseBoundaryLines, boundaryCurves, initialConnectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, coveragePaths, 75,
)
if lineworkErr == nil {
break
}
}
}
if lineworkErr != nil && len(curveCoveragePaths) > 0 {
// Polar footprint probes can land on a numerically ambiguous junction.
// Retry with only curve-side probes while retaining the source audit.
curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048)
visible, lineworkErr = geodata.VisibleLineworkPolygons(
boundaryLines, selectionFill, curveCoverage, 75,
)
if lineworkErr != nil {
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
baseBoundaryLines, boundaryCurves, initialConnectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, curveCoverage, 75,
)
if lineworkErr == nil {
break
}
}
}
}
if lineworkErr != nil && len(initialConnectorLines) == 0 {
connectorLines := occultationHorizonConnectorBoundaryLines(
HorizonConnectorSegments(footprints, curves, northern, southern),
)
if len(connectorLines) > 0 {
connectorBoundaryLines := append([][]geodata.GeoPoint(nil), boundaryLines...)
connectorBoundaryLines = append(connectorBoundaryLines, connectorLines...)
visible, lineworkErr = geodata.VisibleLineworkPolygons(
connectorBoundaryLines, selectionFill, coveragePaths, 75,
)
if lineworkErr != nil {
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
baseBoundaryLines, boundaryCurves, connectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, coveragePaths, 75,
)
if lineworkErr == nil {
break
}
}
}
if lineworkErr != nil && len(curveCoveragePaths) > 0 {
curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048)
visible, lineworkErr = geodata.VisibleLineworkPolygons(
connectorBoundaryLines, selectionFill, curveCoverage, 75,
)
if lineworkErr != nil {
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
baseBoundaryLines, boundaryCurves, connectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, curveCoverage, 75,
)
if lineworkErr == nil {
break
}
}
}
}
}
}
if lineworkErr != nil && useContactContours {
if sweepLines := computeContactSweepLines(); len(sweepLines) > 0 {
sweepBaseBoundaryLines := append(append([][]geodata.GeoPoint(nil), contactLines...), sweepLines...)
sweepBoundaryLines := occultationVisibleBoundaryLinesFromBase(
sweepBaseBoundaryLines, boundaryCurves, initialConnectorLines,
)
visible, lineworkErr = geodata.VisibleLineworkPolygons(
sweepBoundaryLines, selectionFill, coveragePaths, 75,
)
if lineworkErr != nil {
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
sweepBaseBoundaryLines, boundaryCurves, initialConnectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, coveragePaths, 75,
)
if lineworkErr == nil {
break
}
}
}
if lineworkErr != nil && len(curveCoveragePaths) > 0 {
curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048)
visible, lineworkErr = geodata.VisibleLineworkPolygons(
sweepBoundaryLines, selectionFill, curveCoverage, 75,
)
if lineworkErr != nil {
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
sweepBaseBoundaryLines, boundaryCurves, initialConnectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, curveCoverage, 75,
)
if lineworkErr == nil {
break
}
}
}
}
if lineworkErr != nil && len(initialConnectorLines) == 0 {
connectorLines := occultationHorizonConnectorBoundaryLines(
HorizonConnectorSegments(footprints, curves, northern, southern),
)
if len(connectorLines) > 0 {
connectorBoundaryLines := append([][]geodata.GeoPoint(nil), sweepBoundaryLines...)
connectorBoundaryLines = append(connectorBoundaryLines, connectorLines...)
visible, lineworkErr = geodata.VisibleLineworkPolygons(
connectorBoundaryLines, selectionFill, coveragePaths, 75,
)
if lineworkErr != nil {
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
sweepBaseBoundaryLines, boundaryCurves, connectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, coveragePaths, 75,
)
if lineworkErr == nil {
break
}
}
}
if lineworkErr != nil && len(curveCoveragePaths) > 0 {
curveCoverage := limitOccultationCoveragePaths(curveCoveragePaths, 2048)
visible, lineworkErr = geodata.VisibleLineworkPolygons(
connectorBoundaryLines, selectionFill, curveCoverage, 75,
)
if lineworkErr != nil {
for _, candidate := range occultationCurveBoundaryAlternativesFromBase(
sweepBaseBoundaryLines, boundaryCurves, connectorLines,
) {
visible, lineworkErr = geodata.VisibleLineworkPolygons(
candidate, selectionFill, curveCoverage, 75,
)
if lineworkErr == nil {
break
}
}
}
}
}
}
}
}
return visible, lineworkErr
}
func occultationRetryStableContactBoundary(
options occultationVisibleBandLineworkOptions,
) ([][]geodata.GeoPoint, error) {
stableBoundary, err := options.computeSweep()
if err != nil {
return nil, err
}
stableBoundaryLines := occultationVisibleBoundaryLines(
stableBoundary, options.boundaryCurves, options.initialConnectorLines,
)
if options.useContactContours {
if sweepLines := options.computeContactSweepLines(); len(sweepLines) > 0 {
stableBoundaryLines = occultationVisibleBoundaryLinesFromBase(
sweepLines, options.boundaryCurves, options.initialConnectorLines,
)
}
}
return geodata.VisibleLineworkPolygons(
stableBoundaryLines, options.visibleFill, options.visibleFillCoveragePaths, 75,
)
}
// occultationSweepWitnessedByContactContours 报告扫掠边界是否被连续接触包络见证。
func occultationSweepWitnessedByContactContours(
polygons [][]geodata.GeoPoint,
contours [][]basic.OccultationPathPoint,
) bool {
if len(polygons) == 0 || len(contours) == 0 {
return false
}
contactLines := occultationContactContourBoundaryLines(contours)
if len(contactLines) == 0 {
return false
}
return geodata.SphericalPolygonsContainPathsWithinKM(polygons, contactLines, true, 25)
}
// authoritativeFallbackBandPolygons accepts the instantaneous sweep only when
// its resulting boundary remains tightly attached to the sampled contact
// contours. Some polar Saturn events expose several valid phase branches but
// no single start/end/connector cycle; the sweep is still an authoritative
// geometry in that case because its boundary is independently witnessed by the
// continuous contact envelope. A generous miss would turn an arbitrary face
// into a false authoritative result, so keep this gate deliberately small.
func authoritativeFallbackBandPolygons(
fallback [][]geodata.GeoPoint,
contours [][]basic.OccultationPathPoint,
strongPolarSmoothing bool,
) ([][]geodata.GeoPoint, bool) {
if !occultationSweepWitnessedByContactContours(fallback, contours) {
return nil, false
}
// The fallback is accepted as authoritative only because its boundary is
// witnessed by the continuous contact contours. First complete the shared
// cleanup, then round the finished sweep ring once. Applying this rounder to
// intermediate linework can preserve a seam which the later union would
// otherwise remove.
cleaned := cleanupOccultationAuthoritativeBandPolygons(fallback)
return roundOccultationAuthoritativeBandJunctions(cleaned), true
}
func occultationContourFillAndCoverage(
useContactContours bool,
northern, southern []basic.OccultationPathPoint,
coveragePaths [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, [][]geodata.GeoPoint) {
if !useContactContours {
return nil, coveragePaths
}
contourFill := occultationLimitVisibleFillPolygons(northern, southern)
if len(contourFill) > 0 {
coveragePaths = append(coveragePaths, contourFill...)
}
return contourFill, coveragePaths
}
func occultationNoVisibleBandFill(
useContactContours bool,
computeSweep func() ([][]geodata.GeoPoint, error),
fallback [][]geodata.GeoPoint,
strongPolarSmoothing bool,
) ([][]geodata.GeoPoint, bool, error) {
if useContactContours {
fallback, err := computeSweep()
if err != nil {
return nil, false, err
}
return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil
}
return cleanupOccultationVisibleBandPolygons(fallback, strongPolarSmoothing), false, nil
}
func occultationPreferredPhaseBand(
useContactContours bool,
footprints []basic.OccultationFootprint,
curves []basic.OccultationRiseSetCurve,
northern, southern []basic.OccultationPathPoint,
contourFill [][]geodata.GeoPoint,
) ([][]geodata.GeoPoint, bool) {
if !useContactContours {
return nil, false
}
return occultationPhaseBoundaryPolygons(
curves,
HorizonConnectorSegments(footprints, curves, northern, southern),
contourFill,
)
}
func occultationMergePreferredVisibleBand(
visible [][]geodata.GeoPoint,
useContactContours bool,
footprints []basic.OccultationFootprint,
curves []basic.OccultationRiseSetCurve,
northern, southern []basic.OccultationPathPoint,
contourFill, visibleFill, visibleFillCoveragePaths [][]geodata.GeoPoint,
) [][]geodata.GeoPoint {
if len(visible) > 1 {
visible = removeOccultationPolarSliverComponents(visible)
}
if !useContactContours || len(visible) == 0 {
return visible
}
preferredVisible, preferredOK := occultationPreferredPhaseBand(
useContactContours, footprints, curves, northern, southern, contourFill,
)
if !preferredOK || len(preferredVisible) == 0 {
return visible
}
candidate := append([][]geodata.GeoPoint(nil), visible...)
candidate = append(candidate, preferredVisible...)
merged, unionErr := geodata.UnionPolygons(candidate)
if unionErr != nil {
return visible
}
pruned := RemoveTinyPolygonComponents(merged)
if len(pruned) == 0 {
return visible
}
if witnessErr := validateOccultationVisibleBandWitnesses(
pruned, useContactContours, visibleFill, contourFill, visibleFillCoveragePaths,
); witnessErr != nil {
return visible
}
return pruned
}
func occultationFastPolarBand(
useContactContours, strongPolarSmoothing bool,
boundaryLines, visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
) ([][]geodata.GeoPoint, bool) {
if !useContactContours || !strongPolarSmoothing {
return nil, false
}
// The fast polar attempt only needs the source fills as witnesses. Building
// curve-side probes here duplicates the expensive spherical containment pass
// that the general retry path performs after this candidate is rejected.
selectionFill := append([][]geodata.GeoPoint(nil), contourFill...)
selectionFill = append(selectionFill, visibleFill...)
fastCoveragePaths := coveragePaths
visible, err := geodata.VisibleLineworkPolygons(
boundaryLines, selectionFill, fastCoveragePaths, 75,
)
if err != nil {
return nil, false
}
return visible, true
}
// occultationFastPolarBandAccepted keeps the cheap polar polygonizer on the
// normal path only when its selected face agrees with both independent
// witnesses: the instantaneous visible footprints and every exported phase
// curve. A folded horizon can otherwise produce a valid complement that
// looks like a staircase and leaves the purple visibility line outside the
// blue fill. The miss-distance checks are bounded by the existing probe
// budgets, so rejected faces fall through to the deterministic retries below.
func occultationFastPolarBandAccepted(
visible, visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
) bool {
if len(visible) == 0 {
return false
}
if validateOccultationVisibleBandWitnesses(
visible, true, visibleFill, contourFill, coveragePaths,
) != nil {
return false
}
phasePaths := make([][]geodata.GeoPoint, 0, len(curves)*2)
for _, curve := range curves {
for _, line := range occultationCurveBoundaryLines(curve) {
if len(line) >= 2 {
phasePaths = append(phasePaths, line)
}
}
}
if len(phasePaths) == 0 {
return true
}
// A phase curve may lie exactly on the static ring; allow a small numerical
// tolerance for the spherical edge/midpoint probes but reject a different
// polygonizer face by a clearly visible (>75 km) displacement.
return geodata.SphericalPolygonsContainPathsWithinKM(
visible, phasePaths, true, 10,
)
}
func occultationPreferredPhaseBandAccepted(
preferred, contourFill, visibleFill [][]geodata.GeoPoint,
) bool {
if len(preferred) == 0 || len(contourFill) == 0 {
return false
}
// Open instantaneous footprints can become very long at lunar rise/set.
// They are valid timeline geometry, but their horizon closure is not the
// compact band's outer envelope. The phase-cycle constructor has already
// checked both directions against the continuous contact contour; repeat
// that bounded check here without allowing the horizon-extended footprint
// fill to reject the physical compact cycle.
if !geodata.SphericalPolygonsContainPathsWithinKM(preferred, contourFill, true, 150) {
return false
}
if !geodata.SphericalPolygonsContainPathsWithinKM(contourFill, preferred, true, 200) {
return false
}
// The phase cycle is only an outer closure candidate. When instantaneous
// visible witnesses extend farther along the event interval, accepting the
// compact cycle would silently turn the full time-union into a moonrise/
// moonset-only strip. Require the candidate to cover those witnesses before
// promoting it to the static band.
if len(visibleFill) > 0 {
if !geodata.SphericalPolygonsContainPathsWithinKM(preferred, visibleFill, true, 50) {
return false
}
}
return true
}
func occultationVisibleBandLineworkInputs(
useContactContours, strongPolarSmoothing bool,
visibleFill, contourFill, coveragePaths [][]geodata.GeoPoint,
curves []basic.OccultationRiseSetCurve,
) (
[][]geodata.GeoPoint,
[][]geodata.GeoPoint,
[][]geodata.GeoPoint,
[][]geodata.GeoPoint,
) {
footprintCoveragePaths := append([][]geodata.GeoPoint(nil), coveragePaths...)
// Contact contours remain the primary static envelope for both partial and
// total bands, but the footprint polygons still carry real interior witness
// points. Feed both into face selection so the chosen face stays anchored to
// the contour while also inheriting the footprint lobe that only the sweep
// sees.
selectionFill := visibleFill
if useContactContours && len(contourFill) > 0 {
selectionFill = append(append([][]geodata.GeoPoint(nil), contourFill...), visibleFill...)
}
if !useContactContours {
// Legacy callers provide rise/set curves without a continuous contact
// contour. Their footprint fill already supplies the face witnesses; curve
// side probes would repeat an O(curve-points * fill-edges) spherical scan
// without adding a reliable distinction between faces.
return selectionFill, coveragePaths, nil, footprintCoveragePaths
}
curveProbeFill := selectionFill
if len(contourFill) > 0 {
curveProbeFill = contourFill
}
curveCoveragePaths := occultationCurveCoverageProbes(curves, curveProbeFill)
coveragePaths = append(coveragePaths, curveCoveragePaths...)
coveragePaths = limitOccultationCoveragePaths(coveragePaths, 2048)
visibleFillCoveragePaths := append([][]geodata.GeoPoint(nil), footprintCoveragePaths...)
visibleFillCoveragePaths = append(visibleFillCoveragePaths, curveCoveragePaths...)
visibleFillCoveragePaths = limitOccultationCoveragePaths(visibleFillCoveragePaths, 2048)
return selectionFill, coveragePaths, curveCoveragePaths, visibleFillCoveragePaths
}
func occultationVisibleFillAndCoverage(
footprints []basic.OccultationFootprint,
) ([][]geodata.GeoPoint, [][]geodata.GeoPoint) {
visibleFill := make([][]geodata.GeoPoint, 0, len(footprints)*2)
coveragePaths := make([][]geodata.GeoPoint, 0, len(footprints))
for _, footprint := range footprints {
for _, source := range footprint.Polygons {
if len(source) < 3 {
continue
}
if occultationInteriorPolygon(source, footprint.InteriorPolygons) {
continue
}
visibleSource := clipOccultationPolygonToHorizon(source)
if len(visibleSource) < 3 {
continue
}
polygon := make([]geodata.GeoPoint, len(visibleSource))
for index, point := range visibleSource {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
visibleFill = append(visibleFill, polygon)
if !occultationPolygonNeedsInteriorProbes(visibleSource) {
continue
}
for _, probe := range occultationVisibleFootprintProbes(visibleSource, polygon) {
if occultationCoverageProbeExists(coveragePaths, probe) {
continue
}
coveragePaths = append(coveragePaths, []geodata.GeoPoint{probe})
}
}
}
return visibleFill, coveragePaths
}
// occultationVisibleFootprintFillOnly builds the horizon-clipped source faces
// without the interior probe set used by polygon selection. The mask audit
// only needs the faces themselves, so keeping probe generation out of this
// path avoids repeating the expensive polar witness calculations.
func occultationVisibleFootprintFillOnly(
footprints []basic.OccultationFootprint,
) [][]geodata.GeoPoint {
visibleFill := make([][]geodata.GeoPoint, 0, len(footprints)*2)
for _, footprint := range footprints {
for _, source := range footprint.Polygons {
if len(source) < 3 || occultationInteriorPolygon(source, footprint.InteriorPolygons) {
continue
}
visibleSource := clipOccultationPolygonToHorizon(source)
if len(visibleSource) < 3 {
continue
}
polygon := make([]geodata.GeoPoint, len(visibleSource))
for index, point := range visibleSource {
polygon[index] = geodata.GeoPoint{Longitude: point.Longitude, Latitude: point.Latitude}
}
visibleFill = append(visibleFill, polygon)
}
}
return visibleFill
}
func occultationCoverageProbeExists(
coveragePaths [][]geodata.GeoPoint,
probe geodata.GeoPoint,
) bool {
for _, existing := range coveragePaths {
if geoDistanceKM(existing[0], probe) < 10 {
return true
}
}
return false
}