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