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

1956 lines
62 KiB
Go

package geodata
import (
"fmt"
"math"
"sort"
)
type visibleLineworkNode struct {
point GeoPoint
outgoing []visibleLineworkHalfEdge
}
type visibleLineworkEdge struct {
points []GeoPoint
start, end int
}
type visibleLineworkHalfEdge struct {
edge int
reverse bool
}
type visibleLineworkBounds struct {
minLongitude float64
maxLongitude float64
minLatitude float64
maxLatitude float64
}
// visibleLineworkArcIndex caches the endpoint vectors and great-circle
// normals used by the source-boundary audit. The audit may visit thousands of
// polygon vertices, but its source segments are fixed for the whole pass.
// Caching only these derived values preserves the exact spherical-distance
// calculation while avoiding repeated trigonometric conversions in WASM.
type visibleLineworkArcIndex struct {
arcs []visibleLineworkArc
vertices map[visibleLineworkPointKey]GeoPoint
edges map[visibleLineworkEdgeKey][]int
}
type visibleLineworkArc struct {
start, end GeoPoint
startVector geoVector3
endVector geoVector3
arcRadians float64
normal geoVector3
normalValid bool
}
type visibleLineworkPointKey struct {
longitude int64
latitude int64
}
type visibleLineworkEdgeKey struct {
first visibleLineworkPointKey
second visibleLineworkPointKey
}
const visibleLineworkIndexCoordinateScale = 1e7
// Coarse historical solar footprints can leave a bounded spherical residual;
// larger residuals indicate a planar-union shortcut rather than sampling noise.
const visibleLineworkBoundaryMinimumAuditToleranceKM = 100
func newVisibleLineworkArcIndex(lines [][]GeoPoint) visibleLineworkArcIndex {
count := 0
for _, line := range lines {
if len(line) > 1 {
count += len(line) - 1
}
}
index := visibleLineworkArcIndex{
arcs: make([]visibleLineworkArc, 0, count),
vertices: make(map[visibleLineworkPointKey]GeoPoint, count+1),
edges: make(map[visibleLineworkEdgeKey][]int, count),
}
for _, line := range lines {
for segment := 1; segment < len(line); segment++ {
start, end := line[segment-1], line[segment]
startVector := geoPointVector(start)
endVector := geoPointVector(end)
arcRadians := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(startVector, endVector))))
normal, normalValid := geoVectorNormalize(geoVectorCross(startVector, endVector))
arcIndex := len(index.arcs)
index.arcs = append(index.arcs, visibleLineworkArc{
start: start, end: end,
startVector: startVector, endVector: endVector,
arcRadians: arcRadians,
normal: normal, normalValid: normalValid,
})
startKey := visibleLineworkIndexPointKey(start)
endKey := visibleLineworkIndexPointKey(end)
index.vertices[startKey] = start
index.vertices[endKey] = end
edgeKey := visibleLineworkIndexEdgeKey(startKey, endKey)
index.edges[edgeKey] = append(index.edges[edgeKey], arcIndex)
}
}
return index
}
func (index visibleLineworkArcIndex) pointDistanceKM(point GeoPoint) float64 {
if source, ok := index.vertices[visibleLineworkIndexPointKey(point)]; ok {
return geoPointDistanceKM(point, source)
}
pointVector := geoPointVector(point)
minimum := math.Inf(1)
for _, arc := range index.arcs {
minimum = math.Min(minimum, arc.pointDistanceKM(point, pointVector))
if minimum == 0 {
return 0
}
}
return minimum
}
func (index visibleLineworkArcIndex) edgePointDistanceKM(
first, second, point GeoPoint,
) (float64, bool) {
firstKey := visibleLineworkIndexPointKey(first)
secondKey := visibleLineworkIndexPointKey(second)
arcIndices := index.edges[visibleLineworkIndexEdgeKey(firstKey, secondKey)]
if len(arcIndices) == 0 {
return 0, false
}
pointVector := geoPointVector(point)
minimum := math.Inf(1)
matched := false
for _, arcIndex := range arcIndices {
arc := index.arcs[arcIndex]
direct := geoPointDistanceKM(first, arc.start) <= 0.001 &&
geoPointDistanceKM(second, arc.end) <= 0.001
reverse := geoPointDistanceKM(first, arc.end) <= 0.001 &&
geoPointDistanceKM(second, arc.start) <= 0.001
if !direct && !reverse {
continue
}
matched = true
minimum = math.Min(minimum, arc.pointDistanceKM(point, pointVector))
}
return minimum, matched
}
func (arc visibleLineworkArc) pointDistanceKM(point GeoPoint, pointVector geoVector3) float64 {
if arc.normalValid {
projection := geoVectorAdd(pointVector,
geoVectorScale(arc.normal, -geoVectorDot(pointVector, arc.normal)))
if projected, projectedOK := geoVectorNormalize(projection); projectedOK {
for _, candidate := range []geoVector3{projected, geoVectorScale(projected, -1)} {
if sphericalPointOnArcVectors(candidate, arc.startVector, arc.endVector, arc.arcRadians) {
angle := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(pointVector, candidate))))
return angle * 6378.1366
}
}
}
}
return math.Min(geoPointDistanceKM(point, arc.start), geoPointDistanceKM(point, arc.end))
}
func visibleLineworkIndexPointKey(point GeoPoint) visibleLineworkPointKey {
return visibleLineworkPointKey{
longitude: int64(math.Round(point.Longitude * visibleLineworkIndexCoordinateScale)),
latitude: int64(math.Round(point.Latitude * visibleLineworkIndexCoordinateScale)),
}
}
func visibleLineworkIndexEdgeKey(
first, second visibleLineworkPointKey,
) visibleLineworkEdgeKey {
if first.longitude > second.longitude ||
(first.longitude == second.longitude && first.latitude > second.latitude) {
first, second = second, first
}
return visibleLineworkEdgeKey{first: first, second: second}
}
func sphericalPointOnArcVectors(point, first, second geoVector3, arc float64) bool {
if arc <= 1e-14 {
return math.Acos(math.Max(-1, math.Min(1, geoVectorDot(first, point)))) <= 1e-9
}
firstDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(first, point))))
secondDistance := math.Acos(math.Max(-1, math.Min(1, geoVectorDot(point, second))))
return math.Abs(firstDistance+secondDistance-arc) <= 1e-9
}
// VisibleLineworkPolygons 将球面边界线网多边形化,并仅保留由给定填充多边形覆盖的面。
// VisibleLineworkPolygons polygonizes a spherical boundary network and keeps
// only faces covered by at least one supplied fill polygon. Boundary line
// endpoints within snapDistanceKM are treated as the same physical junction.
func VisibleLineworkPolygons(
boundaryLines [][]GeoPoint,
fillPolygons [][]GeoPoint,
coveragePaths [][]GeoPoint,
snapDistanceKM float64,
) ([][]GeoPoint, error) {
return visibleLineworkPolygons(
boundaryLines, fillPolygons, coveragePaths, snapDistanceKM, 1.5*snapDistanceKM,
)
}
// VisibleLineworkPolygonsWithAuditTolerance 将边界图节点吸附尺度与源填充审计容差分开。
// VisibleLineworkPolygonsWithAuditTolerance keeps graph junction snapping and
// source-fill auditing on independent scales. This is needed when a boundary
// is sampled at a fine spatial cadence but the interior witness is a coarser
// sweep: increasing the snap distance would merge nearby physical branches,
// while a bounded audit tolerance does not alter the boundary graph.
func VisibleLineworkPolygonsWithAuditTolerance(
boundaryLines [][]GeoPoint,
fillPolygons [][]GeoPoint,
coveragePaths [][]GeoPoint,
snapDistanceKM, sourceFillToleranceKM float64,
) ([][]GeoPoint, error) {
return visibleLineworkPolygons(
boundaryLines, fillPolygons, coveragePaths, snapDistanceKM, sourceFillToleranceKM,
)
}
func visibleLineworkPolygons(
boundaryLines [][]GeoPoint,
fillPolygons [][]GeoPoint,
coveragePaths [][]GeoPoint,
snapDistanceKM, sourceFillToleranceKM float64,
) ([][]GeoPoint, error) {
if len(boundaryLines) == 0 {
return nil, fmt.Errorf("visible linework has no boundary lines")
}
if !(snapDistanceKM >= 0) || !(sourceFillToleranceKM >= 0) ||
math.IsInf(snapDistanceKM, 0) || math.IsInf(sourceFillToleranceKM, 0) {
return nil, fmt.Errorf("visible linework tolerances must be finite and non-negative")
}
chartInputs := make([][]GeoPoint, 0, len(boundaryLines)+len(fillPolygons))
chartInputs = append(chartInputs, boundaryLines...)
chartInputs = append(chartInputs, fillPolygons...)
chart, ok := newPolygonUnionChart(chartInputs)
if !ok {
return nil, fmt.Errorf("visible linework has no stable spherical chart")
}
projectedLines := make([][]GeoPoint, 0, len(boundaryLines))
for _, source := range boundaryLines {
line := projectVisibleLine(chart, source)
if len(line) >= 2 {
projectedLines = append(projectedLines, line)
}
}
projectedFill := make([][]GeoPoint, 0, len(fillPolygons))
for _, source := range fillPolygons {
polygon := projectVisibleLine(chart, openGeoRing(source))
if len(polygon) >= 3 {
projectedFill = append(projectedFill, polygon)
}
}
if len(projectedLines) == 0 || len(projectedFill) == 0 {
return nil, fmt.Errorf("visible linework has no usable boundary or fill geometry")
}
projectedLines = splitVisibleLineworkIntersections(projectedLines)
if len(coveragePaths) == 0 {
coveragePaths = fillPolygons
}
projectedCoverage := make([][]GeoPoint, 0, len(coveragePaths))
for _, source := range coveragePaths {
path := projectVisibleLine(chart, source)
if len(path) > 0 {
projectedCoverage = append(projectedCoverage, path)
}
}
for _, probe := range visibleLineworkFilledEdgeProbes(projectedLines, projectedFill) {
projectedCoverage = append(projectedCoverage, []GeoPoint{probe})
}
nodes, edges, loops := buildVisibleLineworkGraph(projectedLines, chart, snapDistanceKM)
cycles := enumerateVisibleLineworkCycles(nodes, edges)
cycles = append(cycles, loops...)
// Endpoint snapping and face membership have different numerical scales.
// A tens-of-kilometres junction snap is useful for joining independently sampled phase
// curves, but using that same radius for point-in-face tests makes adjacent
// polar faces overlap and selects the wrong side of a narrow band.
faceToleranceKM := math.Min(5, math.Max(0.5, snapDistanceKM/10))
projectedTolerance := faceToleranceKM / 111.195
cycleProbes := visibleLineworkFilledCycleProbes(cycles, projectedFill, projectedTolerance)
if len(cycleProbes) > 0 {
for _, probe := range cycleProbes {
projectedCoverage = append(projectedCoverage, []GeoPoint{probe})
}
}
// Audit the selected boundary against the complete source fill, not just
// the sparse coverage probes used to choose faces. Otherwise a face can
// contain the probes while its outline still cuts across an unsampled part
// of the instantaneous footprint.
projectedFillSamples := sampleVisibleLineworkFillPaths(projectedFill, 16)
fillSamples := visibleLineworkPointPaths(
unprojectVisibleLineworkPaths(chart, projectedFillSamples),
)
selectedCycles := make([][]GeoPoint, 0)
if len(cycleProbes) > 0 {
priorityCoverage := make([][]GeoPoint, len(cycleProbes))
for index, probe := range cycleProbes {
priorityCoverage[index] = []GeoPoint{probe}
}
prioritySelected := selectVisibleLineworkCycles(
cycles, priorityCoverage, projectedTolerance,
)
if visibleLineworkCyclesCoverCoverage(prioritySelected, projectedCoverage, projectedTolerance) {
selectedCycles = prioritySelected
}
}
if len(selectedCycles) == 0 {
selectedCycles = selectVisibleLineworkCycles(
cycles, projectedCoverage, projectedTolerance,
)
}
selected := make([][]GeoPoint, 0, len(selectedCycles))
for _, cycle := range selectedCycles {
polygon := make([]GeoPoint, len(cycle))
for index, point := range cycle {
polygon[index] = chart.unproject(point)
}
selected = append(selected, polygon)
}
if len(selected) == 0 {
return nil, fmt.Errorf("visible linework produced no covering boundary from %d cycles (coverage=%d cycle_probes=%d misses=%d)",
len(cycles), len(projectedCoverage), len(cycleProbes),
visibleLineworkCoverageMissCount(cycles, projectedCoverage, projectedTolerance))
}
// A single simple face is already a polygon. Repeating a boolean union
// can lose tiny edges where tangential branches were split into the graph.
merged := selected
if len(selected) > 1 {
var err error
merged, err = UnionPolygons(selected)
if err != nil {
return nil, fmt.Errorf("visible linework face union: %w", err)
}
}
// The common output already consists of short projected edges. Running the
// exact spherical point-to-arc audit on every edge is disproportionately
// expensive in TinyGo/WASM, while an artificial polar chord is necessarily
// visible as a long projected edge. Keep the precise repair/audit only for
// that suspicious case.
suspiciousBoundary := visibleLineworkHasLongProjectedEdge(merged, 45) || visibleLineworkHasProjectedPolarBacktrack(merged)
if suspiciousBoundary {
merged = repairVisibleLineworkBoundaryChords(merged, boundaryLines, 5, 30)
// Re-run the exact source audit only if a suspicious edge remains after
// repair. Normal authoritative rings have no long edge or polar reversal
// at this point, so a full point-to-arc scan would be redundant.
if visibleLineworkHasLongProjectedEdge(merged, 45) || visibleLineworkHasProjectedPolarBacktrack(merged) {
if sourceMiss := visibleLineworkBoundarySourceMissDistanceKM(merged, boundaryLines); sourceMiss > math.Max(visibleLineworkBoundaryMinimumAuditToleranceKM, 2*snapDistanceKM) {
// A planar union can introduce a shortcut while each selected
// source cycle is still a valid physical boundary. Preserve those
// source cycles as separate polygons rather than falling through
// to an endpoint-only sweep that closes through a pole.
if selectedMiss := visibleLineworkBoundarySourceMissDistanceKM(selected, boundaryLines); selectedMiss <= math.Max(visibleLineworkBoundaryMinimumAuditToleranceKM, 2*snapDistanceKM) {
return selected, nil
}
return nil, fmt.Errorf("visible linework boundary leaves source linework by %.1f km", sourceMiss)
}
}
}
if miss := SphericalPolygonsPathMissDistanceKM(merged, fillSamples, true); miss > sourceFillToleranceKM {
return nil, fmt.Errorf("visible linework boundary misses source fill by %.1f km", miss)
}
return merged, nil
}
func visibleLineworkHasProjectedPolarBacktrack(polygons [][]GeoPoint) bool {
for _, polygon := range polygons {
for index := 1; index+1 < len(polygon); index++ {
first, middle, last := polygon[index-1], polygon[index], polygon[index+1]
if math.Abs(middle.Latitude) < 70 ||
visibleLineworkProjectedEdgeDistanceKM(first, middle) > 40 ||
visibleLineworkProjectedEdgeDistanceKM(middle, last) > 40 ||
visibleLineworkProjectedEdgeDistanceKM(first, last) > 55 {
continue
}
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 {
return true
}
}
}
return false
}
func visibleLineworkHasLongProjectedEdge(polygons [][]GeoPoint, maximumKM float64) bool {
if maximumKM <= 0 {
return true
}
for _, polygon := range polygons {
for index := 1; index < len(polygon); index++ {
if visibleLineworkProjectedEdgeDistanceKM(polygon[index-1], polygon[index]) > maximumKM {
return true
}
}
}
return false
}
func visibleLineworkProjectedEdgeDistanceKM(first, second GeoPoint) float64 {
latitude := (first.Latitude + second.Latitude) * math.Pi / 360
deltaLongitude := math.Remainder(second.Longitude-first.Longitude, 360) * math.Cos(latitude)
deltaLatitude := second.Latitude - first.Latitude
return math.Hypot(deltaLongitude, deltaLatitude) * 111.195
}
func visibleLineworkBoundarySourceMissDistanceKM(
polygons, boundaryLines [][]GeoPoint,
) float64 {
sourceArcs := newVisibleLineworkArcIndex(boundaryLines)
maximumDistance := 0.0
for _, polygon := range polygons {
for index, point := range polygon {
// Endpoints of a planar shortcut often coincide with real source
// vertices. They must not mask a midpoint that leaves the source
// boundary by hundreds or thousands of kilometres.
edgeMaximum := sourceArcs.pointDistanceKM(point)
if index > 0 {
midpoint := sphericalInterpolate(polygon[index-1], point, 0.5)
midpointDistance, matched := sourceArcs.edgePointDistanceKM(
polygon[index-1], point, midpoint,
)
if !matched {
midpointDistance = sourceArcs.pointDistanceKM(midpoint)
}
edgeMaximum = math.Max(edgeMaximum, midpointDistance)
}
maximumDistance = math.Max(maximumDistance, edgeMaximum)
}
}
return maximumDistance
}
// repairVisibleLineworkBoundaryChords restores a source-boundary run when a
// planar union closes a polar face with a shortcut between two vertices on the
// same physical curve. The shortcut is topologically valid in the chart but
// is not part of the supplied linework and renders as an artificial straight
// chord on the map.
func repairVisibleLineworkBoundaryChords(
polygons, boundaryLines [][]GeoPoint,
maxDeviationKM, endpointToleranceKM float64,
) [][]GeoPoint {
if len(polygons) == 0 || len(boundaryLines) == 0 || maxDeviationKM <= 0 {
return polygons
}
sourceArcs := newVisibleLineworkArcIndex(boundaryLines)
result := make([][]GeoPoint, len(polygons))
for polygonIndex, ring := range polygons {
if len(ring) < 4 {
result[polygonIndex] = ring
continue
}
repaired := make([]GeoPoint, 0, len(ring))
repaired = append(repaired, ring[0])
for index := 1; index < len(ring); index++ {
start, end := ring[index-1], ring[index]
midpoint := sphericalInterpolate(start, end, 0.5)
deviation, matched := sourceArcs.edgePointDistanceKM(start, end, midpoint)
if !matched {
deviation = sourceArcs.pointDistanceKM(midpoint)
}
if deviation <= maxDeviationKM {
repaired = append(repaired, end)
continue
}
bridge, ok := visibleLineworkBoundaryBridge(
start, end, boundaryLines, endpointToleranceKM,
)
if !ok {
repaired = append(repaired, end)
continue
}
repaired = append(repaired, bridge[1:]...)
}
result[polygonIndex] = sweepDeduplicateAdjacent(repaired)
}
return result
}
func visibleLineworkPointToLinesDistanceKM(
point GeoPoint,
lines [][]GeoPoint,
) float64 {
return newVisibleLineworkArcIndex(lines).pointDistanceKM(point)
}
func visibleLineworkBoundaryBridge(
start, end GeoPoint,
lines [][]GeoPoint,
toleranceKM float64,
) ([]GeoPoint, bool) {
originalStart := start
var best []GeoPoint
bestLength := math.Inf(1)
for _, line := range lines {
if len(line) < 3 {
continue
}
startIndex, startDistance := visibleLineworkNearestVertex(start, line)
endIndex, endDistance := visibleLineworkNearestVertex(end, line)
if startDistance > toleranceKM || endDistance > toleranceKM ||
startIndex == endIndex || absInt(startIndex-endIndex) < 2 {
continue
}
if startIndex > endIndex {
startIndex, endIndex = endIndex, startIndex
start, end = end, start
}
bridge := make([]GeoPoint, 0, endIndex-startIndex+1)
bridge = append(bridge, start)
bridge = append(bridge, line[startIndex+1:endIndex]...)
bridge = append(bridge, end)
if len(bridge) >= 3 {
if !visibleLineworkBridgeIsSimple(bridge, toleranceKM) {
continue
}
if geoPointDistanceKM(originalStart, bridge[0]) > geoPointDistanceKM(originalStart, bridge[len(bridge)-1]) {
reverseGeoPoints(bridge)
}
length := visibleLineworkBridgeLength(bridge)
if length < bestLength {
best, bestLength = bridge, length
}
}
}
if len(best) >= 3 {
return best, true
}
return visibleLineworkCrossLineBoundaryBridge(start, end, lines, toleranceKM)
}
// visibleLineworkCrossLineBoundaryBridge restores a source-boundary run whose
// endpoints lie on different line strings. Polar phase envelopes commonly
// meet a horizon connector at an endpoint; the planar graph can collapse that
// junction and leave a long shortcut between the two strings. Stitch only
// existing source vertices that are within the same endpoint tolerance, and
// choose the shortest resulting source-path so unrelated lines cannot become a
// new artificial edge.
func visibleLineworkCrossLineBoundaryBridge(
start, end GeoPoint,
lines [][]GeoPoint,
toleranceKM float64,
) ([]GeoPoint, bool) {
type location struct {
lineIndex, pointIndex int
distance float64
}
startLocations := make([]location, 0)
endLocations := make([]location, 0)
for lineIndex, line := range lines {
if len(line) < 2 {
continue
}
for pointIndex, point := range line {
if distance := geoPointDistanceKM(start, point); distance <= toleranceKM {
startLocations = append(startLocations, location{lineIndex, pointIndex, distance})
}
if distance := geoPointDistanceKM(end, point); distance <= toleranceKM {
endLocations = append(endLocations, location{lineIndex, pointIndex, distance})
}
}
}
var best []GeoPoint
bestLength := math.Inf(1)
for _, first := range startLocations {
for _, second := range endLocations {
if first.lineIndex == second.lineIndex {
continue
}
firstLine, secondLine := lines[first.lineIndex], lines[second.lineIndex]
firstIndices := []int{0, len(firstLine) - 1}
secondIndices := []int{0, len(secondLine) - 1}
for _, firstIndex := range firstIndices {
for _, secondIndex := range secondIndices {
if geoPointDistanceKM(firstLine[firstIndex], secondLine[secondIndex]) > toleranceKM {
continue
}
firstPath := visibleLineworkLinePath(firstLine, first.pointIndex, firstIndex, start, firstLine[firstIndex])
secondPath := visibleLineworkLinePath(secondLine, secondIndex, second.pointIndex, secondLine[secondIndex], end)
if len(firstPath) < 2 || len(secondPath) < 2 {
continue
}
bridge := append(append([]GeoPoint(nil), firstPath...), secondPath[1:]...)
if len(bridge) < 3 {
continue
}
if !visibleLineworkBridgeIsSimple(bridge, toleranceKM) {
continue
}
length := visibleLineworkBridgeLength(bridge)
if length < bestLength {
best, bestLength = bridge, length
}
}
}
}
}
if len(best) < 3 {
return nil, false
}
return best, true
}
func visibleLineworkBridgeIsSimple(points []GeoPoint, toleranceKM float64) bool {
if len(points) < 3 {
return false
}
endpointRepeatTolerance := math.Min(1, toleranceKM/10)
for index := 1; index+1 < len(points); index++ {
if geoPointDistanceKM(points[index], points[0]) <= endpointRepeatTolerance ||
geoPointDistanceKM(points[index], points[len(points)-1]) <= endpointRepeatTolerance {
return false
}
}
return true
}
func visibleLineworkLinePath(
line []GeoPoint,
startIndex, endIndex int,
start, end GeoPoint,
) []GeoPoint {
if startIndex < 0 || startIndex >= len(line) || endIndex < 0 || endIndex >= len(line) {
return nil
}
path := []GeoPoint{start}
if startIndex <= endIndex {
path = append(path, line[startIndex+1:endIndex+1]...)
} else {
for index := startIndex - 1; index >= endIndex; index-- {
path = append(path, line[index])
}
}
if len(path) == 0 || geoPointDistanceKM(path[len(path)-1], end) > 1e-9 {
path = append(path, end)
}
return path
}
func visibleLineworkBridgeLength(points []GeoPoint) float64 {
length := 0.0
for index := 1; index < len(points); index++ {
length += geoPointDistanceKM(points[index-1], points[index])
}
return length
}
func visibleLineworkNearestVertex(
point GeoPoint,
line []GeoPoint,
) (int, float64) {
bestIndex := -1
bestDistance := math.Inf(1)
for index, candidate := range line {
distance := geoPointDistanceKM(point, candidate)
if distance < bestDistance {
bestIndex = index
bestDistance = distance
}
}
return bestIndex, bestDistance
}
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
func visibleLineworkFilledEdgeProbes(lines, fillPolygons [][]GeoPoint) []GeoPoint {
fillBounds := make([]visibleLineworkBounds, len(fillPolygons))
for index, polygon := range fillPolygons {
fillBounds[index] = visibleLineworkPolygonBounds(polygon, 0)
}
probes := make([]GeoPoint, 0, len(lines))
for _, line := range lines {
if len(line) < 2 {
continue
}
segment := (len(line) - 1) / 2
start, end := line[segment], line[segment+1]
dx := end.Longitude - start.Longitude
dy := end.Latitude - start.Latitude
length := math.Hypot(dx, dy)
if length <= 1e-12 {
continue
}
midpoint := GeoPoint{
Longitude: (start.Longitude + end.Longitude) / 2,
Latitude: (start.Latitude + end.Latitude) / 2,
}
offset := math.Min(0.0025, 0.02*length)
for _, side := range []float64{-1, 1} {
probe := GeoPoint{
Longitude: midpoint.Longitude - side*dy*offset/length,
Latitude: midpoint.Latitude + side*dx*offset/length,
}
for fillIndex, polygon := range fillPolygons {
if fillBounds[fillIndex].contains(polygon, probe) &&
visibleLineworkContainsWithin(polygon, probe, 0) {
probes = append(probes, probe)
break
}
}
}
}
return probes
}
func visibleLineworkFilledCycleProbes(
cycles, fillPolygons [][]GeoPoint,
tolerance float64,
) []GeoPoint {
type candidate struct {
polygon []GeoPoint
probe GeoPoint
area float64
bounds visibleLineworkBounds
}
fillBounds := make([]visibleLineworkBounds, len(fillPolygons))
for index, polygon := range fillPolygons {
fillBounds[index] = visibleLineworkPolygonBounds(polygon, tolerance)
}
candidates := make([]candidate, 0, len(cycles))
for _, cycle := range cycles {
probe, ok := visibleLineworkPolygonCentroid(cycle)
if !ok || !visibleLineworkContainsWithin(cycle, probe, tolerance) {
continue
}
for fillIndex, polygon := range fillPolygons {
if fillBounds[fillIndex].contains(polygon, probe) &&
visibleLineworkContainsWithin(polygon, probe, tolerance) {
candidates = append(candidates, candidate{
polygon: cycle,
probe: probe,
area: math.Abs(visibleLineworkSignedArea(cycle)),
bounds: visibleLineworkPolygonBounds(cycle, tolerance),
})
break
}
}
}
sort.Slice(candidates, func(first, second int) bool {
return candidates[first].area < candidates[second].area
})
selected := make([]candidate, 0, len(candidates))
probes := make([]GeoPoint, 0, len(candidates))
for _, value := range candidates {
coveredBySmallerFace := false
for _, smaller := range selected {
if smaller.bounds.contains(smaller.polygon, value.probe) &&
visibleLineworkContainsWithin(smaller.polygon, value.probe, tolerance) {
coveredBySmallerFace = true
break
}
}
if coveredBySmallerFace {
continue
}
selected = append(selected, value)
probes = append(probes, value.probe)
}
return probes
}
func visibleLineworkPolygonCentroid(polygon []GeoPoint) (GeoPoint, bool) {
if len(polygon) < 3 {
return GeoPoint{}, false
}
area, longitude, latitude := 0.0, 0.0, 0.0
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
cross := polygon[previous].Longitude*polygon[current].Latitude -
polygon[current].Longitude*polygon[previous].Latitude
area += cross
longitude += (polygon[previous].Longitude + polygon[current].Longitude) * cross
latitude += (polygon[previous].Latitude + polygon[current].Latitude) * cross
}
if math.Abs(area) <= 1e-12 {
return GeoPoint{}, false
}
return GeoPoint{
Longitude: longitude / (3 * area),
Latitude: latitude / (3 * area),
}, true
}
type visibleLineworkSegmentSplit struct {
fraction float64
point GeoPoint
}
// splitVisibleLineworkIntersections turns interior crossings into graph
// nodes. Lunar rise/set curves can cross at a real polar horizon-grazing
// site, whereas solar-eclipse linework usually meets only at endpoints.
func splitVisibleLineworkIntersections(lines [][]GeoPoint) [][]GeoPoint {
if len(lines) < 2 {
return lines
}
splits := make([][][]visibleLineworkSegmentSplit, len(lines))
junctions := make([][]bool, len(lines))
for lineIndex, line := range lines {
if len(line) < 2 {
continue
}
splits[lineIndex] = make([][]visibleLineworkSegmentSplit, len(line)-1)
junctions[lineIndex] = make([]bool, len(line))
}
for firstLine := 0; firstLine < len(lines); firstLine++ {
for firstSegment := 0; firstSegment+1 < len(lines[firstLine]); firstSegment++ {
firstStart := lines[firstLine][firstSegment]
firstEnd := lines[firstLine][firstSegment+1]
for secondLine := firstLine; secondLine < len(lines); secondLine++ {
secondStartSegment := 0
if secondLine == firstLine {
secondStartSegment = firstSegment + 2
}
for secondSegment := secondStartSegment; secondSegment+1 < len(lines[secondLine]); secondSegment++ {
secondStart := lines[secondLine][secondSegment]
secondEnd := lines[secondLine][secondSegment+1]
longitudeShift := 360 * math.Round(
((firstStart.Longitude+firstEnd.Longitude)-(secondStart.Longitude+secondEnd.Longitude))/720,
)
shiftedStart := secondStart
shiftedEnd := secondEnd
shiftedStart.Longitude += longitudeShift
shiftedEnd.Longitude += longitudeShift
firstFraction, secondFraction, point, ok := visibleLineworkSegmentIntersection(
firstStart, firstEnd, shiftedStart, shiftedEnd,
)
if !ok {
continue
}
visibleLineworkRecordSplit(
splits[firstLine], junctions[firstLine], firstSegment, firstFraction, point,
)
point.Longitude -= longitudeShift
visibleLineworkRecordSplit(
splits[secondLine], junctions[secondLine], secondSegment, secondFraction, point,
)
}
}
}
}
result := make([][]GeoPoint, 0, len(lines)*2)
for lineIndex, line := range lines {
if len(line) < 2 {
continue
}
current := []GeoPoint{line[0]}
for segmentIndex := 0; segmentIndex+1 < len(line); segmentIndex++ {
if segmentIndex > 0 && junctions[lineIndex][segmentIndex] {
if len(current) >= 2 {
result = append(result, current)
}
current = []GeoPoint{line[segmentIndex]}
}
segmentSplits := splits[lineIndex][segmentIndex]
sort.Slice(segmentSplits, func(first, second int) bool {
return segmentSplits[first].fraction < segmentSplits[second].fraction
})
for _, split := range segmentSplits {
if len(current) == 0 || !SameGeoPoint(current[len(current)-1], split.point) {
current = append(current, split.point)
}
if len(current) >= 2 {
result = append(result, current)
}
current = []GeoPoint{split.point}
}
if !SameGeoPoint(current[len(current)-1], line[segmentIndex+1]) {
current = append(current, line[segmentIndex+1])
}
}
if len(current) >= 2 {
result = append(result, current)
}
}
return result
}
func visibleLineworkRecordSplit(
splits [][]visibleLineworkSegmentSplit,
junctions []bool,
segment int,
fraction float64,
point GeoPoint,
) {
const endpointTolerance = 1e-8
if fraction <= endpointTolerance {
junctions[segment] = true
return
}
if fraction >= 1-endpointTolerance {
junctions[segment+1] = true
return
}
for _, existing := range splits[segment] {
if math.Abs(existing.fraction-fraction) <= endpointTolerance {
return
}
}
splits[segment] = append(splits[segment], visibleLineworkSegmentSplit{
fraction: fraction,
point: point,
})
}
func visibleLineworkSegmentIntersection(
firstStart, firstEnd, secondStart, secondEnd GeoPoint,
) (float64, float64, GeoPoint, bool) {
firstX := firstEnd.Longitude - firstStart.Longitude
firstY := firstEnd.Latitude - firstStart.Latitude
secondX := secondEnd.Longitude - secondStart.Longitude
secondY := secondEnd.Latitude - secondStart.Latitude
denominator := firstX*secondY - firstY*secondX
if math.Abs(denominator) <= 1e-12 {
return 0, 0, GeoPoint{}, false
}
offsetX := secondStart.Longitude - firstStart.Longitude
offsetY := secondStart.Latitude - firstStart.Latitude
firstFraction := (offsetX*secondY - offsetY*secondX) / denominator
secondFraction := (offsetX*firstY - offsetY*firstX) / denominator
const intersectionTolerance = 1e-9
if firstFraction < -intersectionTolerance || firstFraction > 1+intersectionTolerance ||
secondFraction < -intersectionTolerance || secondFraction > 1+intersectionTolerance {
// 参数判定只容忍 1e-9,旋转图幅下 T 型交点会被拒绝,这里按垂距回退判定。
if fraction, ok := visibleLineworkEndpointOnSegment(secondStart, firstStart, firstEnd); ok {
return fraction, 0, secondStart, true
}
if fraction, ok := visibleLineworkEndpointOnSegment(secondEnd, firstStart, firstEnd); ok {
return fraction, 1, secondEnd, true
}
if fraction, ok := visibleLineworkEndpointOnSegment(firstStart, secondStart, secondEnd); ok {
return 0, fraction, firstStart, true
}
if fraction, ok := visibleLineworkEndpointOnSegment(firstEnd, secondStart, secondEnd); ok {
return 1, fraction, firstEnd, true
}
return 0, 0, GeoPoint{}, false
}
firstFraction = math.Max(0, math.Min(1, firstFraction))
secondFraction = math.Max(0, math.Min(1, secondFraction))
return firstFraction, secondFraction, GeoPoint{
Longitude: firstStart.Longitude + firstFraction*firstX,
Latitude: firstStart.Latitude + firstFraction*firstY,
}, true
}
// visibleLineworkTJunctionToleranceDeg 是端点落在另一段内部的垂距容差(度)。
const visibleLineworkTJunctionToleranceDeg = 1e-5
// visibleLineworkEndpointOnSegment 返回端点落在段内部时的投影参数。
func visibleLineworkEndpointOnSegment(point, start, end GeoPoint) (float64, bool) {
deltaX := end.Longitude - start.Longitude
deltaY := end.Latitude - start.Latitude
lengthSquared := deltaX*deltaX + deltaY*deltaY
if !(lengthSquared > 0) {
return 0, false
}
offsetX := point.Longitude - start.Longitude
offsetY := point.Latitude - start.Latitude
fraction := (offsetX*deltaX + offsetY*deltaY) / lengthSquared
if !(fraction > 0 && fraction < 1) {
return 0, false
}
distance := math.Hypot(offsetX-fraction*deltaX, offsetY-fraction*deltaY)
if !(distance <= visibleLineworkTJunctionToleranceDeg) {
return 0, false
}
return fraction, true
}
func selectVisibleLineworkCycles(
cycles, coveragePaths [][]GeoPoint,
boundaryTolerance float64,
) [][]GeoPoint {
return selectVisibleLineworkCyclesWithProbeLimit(cycles, coveragePaths, boundaryTolerance, 768)
}
func selectVisibleLineworkCyclesWithProbeLimit(
cycles, coveragePaths [][]GeoPoint,
boundaryTolerance float64,
maximumProbes int,
) [][]GeoPoint {
type candidate struct {
polygon []GeoPoint
area float64
bounds visibleLineworkBounds
index visibleLineworkPolygonIndex
indexed bool
covers []bool
count int
}
probes := visibleLineworkCoverageProbes(coveragePaths, 8)
if len(probes) == 0 {
return nil
}
probes = limitVisibleLineworkCoverageProbes(probes, maximumProbes)
candidates := make([]candidate, 0, len(cycles))
for _, source := range cycles {
polygon := append([]GeoPoint(nil), source...)
area := visibleLineworkSignedArea(polygon)
if math.Abs(area) <= 1e-10 {
continue
}
if area < 0 {
reverseSweepGeoPoints(polygon)
area = -area
}
candidates = append(candidates, candidate{
polygon: polygon,
area: area,
bounds: visibleLineworkPolygonBounds(polygon, boundaryTolerance),
})
}
sort.SliceStable(candidates, func(first, second int) bool {
return candidates[first].area < candidates[second].area
})
// Most physical line networks have one face containing every footprint
// probe. A small subset cheaply rejects impossible faces; candidates that
// survive still have to cover the original complete probe set.
sentinels := sampleVisibleLineworkProbes(probes, 16)
best := -1
for candidateIndex := range candidates {
value := &candidates[candidateIndex]
if best >= 0 && value.area >= candidates[best].area {
continue
}
if !visibleLineworkBoundsContainAll(value.bounds, value.polygon, sentinels) ||
!visibleLineworkBoundsContainAll(value.bounds, value.polygon, probes) {
continue
}
if !value.indexed {
value.index = newVisibleLineworkPolygonIndex(value.polygon)
value.indexed = true
}
if !visibleLineworkContainsAllIndexed(
&value.index, value.bounds, sentinels, boundaryTolerance,
) {
continue
}
if visibleLineworkContainsAllIndexed(
&value.index, value.bounds, probes, boundaryTolerance,
) {
best = candidateIndex
}
}
if best >= 0 {
return [][]GeoPoint{candidates[best].polygon}
}
for candidateIndex := range candidates {
value := &candidates[candidateIndex]
value.covers = make([]bool, len(probes))
for probeIndex, probe := range probes {
if !value.bounds.contains(value.polygon, probe) {
continue
}
if !value.indexed {
value.index = newVisibleLineworkPolygonIndex(value.polygon)
value.indexed = true
}
if visibleLineworkContainsWithinIndexed(&value.index, probe, boundaryTolerance) {
value.covers[probeIndex] = true
value.count++
}
}
}
covered := make([]bool, len(probes))
remaining := len(probes)
selected := make([][]GeoPoint, 0, len(candidates))
used := make([]bool, len(candidates))
for remaining > 0 {
best, bestGain := -1, 0
for index, value := range candidates {
if used[index] {
continue
}
gain := 0
for probeIndex, contains := range value.covers {
if contains && !covered[probeIndex] {
gain++
}
}
if gain > bestGain || gain == bestGain && gain > 0 &&
(best < 0 || value.area < candidates[best].area) {
best, bestGain = index, gain
}
}
if best < 0 || bestGain == 0 {
// A handful of probes can land exactly on a numerically ambiguous
// junction after projection. Keep the already selected faces when
// they cover the overwhelming majority of source probes; the miss
// audit below still rejects a materially incomplete outline.
allowedUncovered := len(probes) / 100
if allowedUncovered < 1 {
allowedUncovered = 1
}
if allowedUncovered > 8 {
allowedUncovered = 8
}
if remaining <= allowedUncovered && len(selected) > 0 {
break
}
return nil
}
used[best] = true
selected = append(selected, candidates[best].polygon)
for probeIndex, contains := range candidates[best].covers {
if contains && !covered[probeIndex] {
covered[probeIndex] = true
remaining--
}
}
}
return selected
}
func visibleLineworkBoundsContainAll(
bounds visibleLineworkBounds,
polygon, probes []GeoPoint,
) bool {
for _, probe := range probes {
if !bounds.contains(polygon, probe) {
return false
}
}
return true
}
func sampleVisibleLineworkProbes(probes []GeoPoint, maximumPoints int) []GeoPoint {
if maximumPoints < 2 || len(probes) <= maximumPoints {
return probes
}
result := make([]GeoPoint, maximumPoints)
for index := range result {
probeIndex := index * (len(probes) - 1) / (maximumPoints - 1)
result[index] = probes[probeIndex]
}
return result
}
func limitVisibleLineworkCoverageProbes(probes []GeoPoint, maximum int) []GeoPoint {
if maximum < 1 || len(probes) <= maximum {
return probes
}
selected := make([]bool, len(probes))
reserve := maximum / 4
if reserve < 1 {
reserve = 1
}
indices := make([]int, len(probes))
for index := range indices {
indices[index] = index
}
sort.SliceStable(indices, func(first, second int) bool {
return probes[indices[first]].Latitude > probes[indices[second]].Latitude
})
for index := 0; index < reserve && index < len(indices); index++ {
selected[indices[index]] = true
selected[indices[len(indices)-1-index]] = true
}
remaining := maximum
for _, value := range selected {
if value {
remaining--
}
}
if remaining < 1 {
remaining = 1
}
stride := float64(len(probes)) / float64(remaining)
result := make([]GeoPoint, 0, maximum)
for index, value := range selected {
if value {
result = append(result, probes[index])
}
}
for cursor := 0.0; len(result) < maximum && int(cursor) < len(probes); cursor += stride {
index := int(cursor)
if selected[index] {
continue
}
selected[index] = true
result = append(result, probes[index])
}
return result
}
func visibleLineworkContainsAll(
polygon []GeoPoint,
bounds visibleLineworkBounds,
probes []GeoPoint,
tolerance float64,
) bool {
for _, probe := range probes {
if !bounds.contains(polygon, probe) ||
!visibleLineworkContainsWithin(polygon, probe, tolerance) {
return false
}
}
return true
}
const visibleLineworkLatitudeBins = 64
type visibleLineworkPolygonIndex struct {
polygon []GeoPoint
minLatitude float64
maxLatitude float64
bins [][]int
marks []uint32
generation uint32
}
func newVisibleLineworkPolygonIndex(polygon []GeoPoint) visibleLineworkPolygonIndex {
index := visibleLineworkPolygonIndex{polygon: polygon}
if len(polygon) == 0 {
return index
}
index.minLatitude, index.maxLatitude = polygon[0].Latitude, polygon[0].Latitude
for _, point := range polygon[1:] {
index.minLatitude = math.Min(index.minLatitude, point.Latitude)
index.maxLatitude = math.Max(index.maxLatitude, point.Latitude)
}
index.bins = make([][]int, visibleLineworkLatitudeBins)
index.marks = make([]uint32, len(polygon))
for edge := range polygon {
previous := (edge + len(polygon) - 1) % len(polygon)
first, second := polygon[previous], polygon[edge]
firstBin := index.latitudeBin(math.Min(first.Latitude, second.Latitude))
lastBin := index.latitudeBin(math.Max(first.Latitude, second.Latitude))
for bin := firstBin; bin <= lastBin; bin++ {
index.bins[bin] = append(index.bins[bin], edge)
}
}
return index
}
func (index visibleLineworkPolygonIndex) latitudeBin(latitude float64) int {
if index.maxLatitude <= index.minLatitude {
return 0
}
fraction := (latitude - index.minLatitude) / (index.maxLatitude - index.minLatitude)
if fraction <= 0 {
return 0
}
if fraction >= 1 {
return visibleLineworkLatitudeBins - 1
}
return int(fraction * visibleLineworkLatitudeBins)
}
func (index *visibleLineworkPolygonIndex) nextGeneration() uint32 {
index.generation++
if index.generation == 0 {
for mark := range index.marks {
index.marks[mark] = 0
}
index.generation = 1
}
return index.generation
}
func visibleLineworkContainsAllIndexed(
index *visibleLineworkPolygonIndex,
bounds visibleLineworkBounds,
probes []GeoPoint,
tolerance float64,
) bool {
for _, probe := range probes {
if !bounds.contains(index.polygon, probe) ||
!visibleLineworkContainsWithinIndexed(index, probe, tolerance) {
return false
}
}
return true
}
func visibleLineworkContainsWithinIndexed(
index *visibleLineworkPolygonIndex,
point GeoPoint,
tolerance float64,
) bool {
if index == nil {
return visibleLineworkContainsWithin(nil, point, tolerance)
}
polygon := index.polygon
if len(polygon) < 3 || tolerance <= 0 {
return visibleLineworkContainsWithin(polygon, point, tolerance)
}
point = visibleLineworkPointNearPolygon(polygon, point)
if point.Latitude < index.minLatitude-tolerance || point.Latitude > index.maxLatitude+tolerance {
return false
}
firstBin := index.latitudeBin(point.Latitude-tolerance) - 2
lastBin := index.latitudeBin(point.Latitude+tolerance) + 2
if firstBin < 0 {
firstBin = 0
}
if lastBin >= visibleLineworkLatitudeBins {
lastBin = visibleLineworkLatitudeBins - 1
}
generation := index.nextGeneration()
inside := false
for bin := firstBin; bin <= lastBin; bin++ {
for _, edge := range index.bins[bin] {
if index.marks[edge] == generation {
continue
}
index.marks[edge] = generation
current, previous := edge, (edge+len(polygon)-1)%len(polygon)
first, second := polygon[previous], polygon[current]
if (first.Latitude > point.Latitude) == (second.Latitude > point.Latitude) {
continue
}
intersection := first.Longitude +
(point.Latitude-first.Latitude)*(second.Longitude-first.Longitude)/(second.Latitude-first.Latitude)
if intersection >= point.Longitude {
inside = !inside
}
}
}
if inside {
return true
}
generation = index.nextGeneration()
toleranceSquared := tolerance * tolerance
for bin := firstBin; bin <= lastBin; bin++ {
for _, edge := range index.bins[bin] {
if index.marks[edge] == generation {
continue
}
index.marks[edge] = generation
current, previous := edge, (edge+len(polygon)-1)%len(polygon)
start, end := polygon[previous], polygon[current]
if point.Longitude < math.Min(start.Longitude, end.Longitude)-tolerance ||
point.Longitude > math.Max(start.Longitude, end.Longitude)+tolerance ||
point.Latitude < math.Min(start.Latitude, end.Latitude)-tolerance ||
point.Latitude > math.Max(start.Latitude, end.Latitude)+tolerance {
continue
}
if visibleLineworkPointSegmentDistanceSquared(point, start, end) <= toleranceSquared {
return true
}
}
}
return false
}
func visibleLineworkCyclesCoverCoverage(
cycles, coveragePaths [][]GeoPoint,
tolerance float64,
) bool {
return visibleLineworkCoverageMissCount(cycles, coveragePaths, tolerance) == 0
}
func visibleLineworkCoverageMissCount(
cycles, coveragePaths [][]GeoPoint,
tolerance float64,
) int {
if len(cycles) == 0 {
return -1
}
probes := visibleLineworkCoverageProbes(coveragePaths, 8)
probes = limitVisibleLineworkCoverageProbes(probes, 768)
if len(probes) == 0 {
return -1
}
bounds := make([]visibleLineworkBounds, len(cycles))
for index, cycle := range cycles {
bounds[index] = visibleLineworkPolygonBounds(cycle, tolerance)
}
misses := 0
for _, probe := range probes {
covered := false
for index, cycle := range cycles {
if bounds[index].contains(cycle, probe) &&
visibleLineworkContainsWithin(cycle, probe, tolerance) {
covered = true
break
}
}
if !covered {
misses++
}
}
return misses
}
func visibleLineworkPolygonBounds(polygon []GeoPoint, tolerance float64) visibleLineworkBounds {
if len(polygon) == 0 {
return visibleLineworkBounds{}
}
bounds := visibleLineworkBounds{
minLongitude: polygon[0].Longitude,
maxLongitude: polygon[0].Longitude,
minLatitude: polygon[0].Latitude,
maxLatitude: polygon[0].Latitude,
}
for _, point := range polygon[1:] {
bounds.minLongitude = math.Min(bounds.minLongitude, point.Longitude)
bounds.maxLongitude = math.Max(bounds.maxLongitude, point.Longitude)
bounds.minLatitude = math.Min(bounds.minLatitude, point.Latitude)
bounds.maxLatitude = math.Max(bounds.maxLatitude, point.Latitude)
}
return visibleLineworkBounds{
minLongitude: bounds.minLongitude - tolerance,
maxLongitude: bounds.maxLongitude + tolerance,
minLatitude: bounds.minLatitude - tolerance,
maxLatitude: bounds.maxLatitude + tolerance,
}
}
func (bounds visibleLineworkBounds) contains(polygon []GeoPoint, point GeoPoint) bool {
point = visibleLineworkPointNearPolygon(polygon, point)
return point.Longitude >= bounds.minLongitude && point.Longitude <= bounds.maxLongitude &&
point.Latitude >= bounds.minLatitude && point.Latitude <= bounds.maxLatitude
}
func visibleLineworkCoverageProbes(paths [][]GeoPoint, maximumPoints int) []GeoPoint {
if maximumPoints < 2 {
maximumPoints = 2
}
probes := make([]GeoPoint, 0, len(paths)*maximumPoints)
for _, path := range paths {
step := (len(path) + maximumPoints - 1) / maximumPoints
if step < 1 {
step = 1
}
for index := 0; index < len(path); index += step {
probes = append(probes, path[index])
}
if len(path) > 0 && (len(path)-1)%step != 0 {
probes = append(probes, path[len(path)-1])
}
}
return probes
}
func visibleLineworkContainsWithin(polygon []GeoPoint, point GeoPoint, tolerance float64) bool {
if len(polygon) < 3 {
return false
}
point = visibleLineworkPointNearPolygon(polygon, point)
if tolerance <= 0 {
return sweepPointInPolygon(polygon, point)
}
inside := false
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
first, second := polygon[previous], polygon[current]
if (first.Latitude > point.Latitude) == (second.Latitude > point.Latitude) {
continue
}
intersection := first.Longitude +
(point.Latitude-first.Latitude)*(second.Longitude-first.Longitude)/(second.Latitude-first.Latitude)
if intersection >= point.Longitude {
inside = !inside
}
}
if inside {
return true
}
toleranceSquared := tolerance * tolerance
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
start, end := polygon[previous], polygon[current]
if point.Longitude < math.Min(start.Longitude, end.Longitude)-tolerance ||
point.Longitude > math.Max(start.Longitude, end.Longitude)+tolerance ||
point.Latitude < math.Min(start.Latitude, end.Latitude)-tolerance ||
point.Latitude > math.Max(start.Latitude, end.Latitude)+tolerance {
continue
}
if visibleLineworkPointSegmentDistanceSquared(point, start, end) <= toleranceSquared {
return true
}
}
return false
}
func visibleLineworkPointNearPolygon(polygon []GeoPoint, point GeoPoint) GeoPoint {
if len(polygon) == 0 {
return point
}
longitude := point.Longitude
for longitude-polygon[0].Longitude > 180 {
longitude -= 360
}
for longitude-polygon[0].Longitude < -180 {
longitude += 360
}
point.Longitude = longitude
return point
}
func visibleLineworkPointSegmentDistanceSquared(point, start, end GeoPoint) float64 {
dx := end.Longitude - start.Longitude
dy := end.Latitude - start.Latitude
denominator := dx*dx + dy*dy
if denominator <= 1e-20 {
dx = point.Longitude - start.Longitude
dy = point.Latitude - start.Latitude
return dx*dx + dy*dy
}
fraction := ((point.Longitude-start.Longitude)*dx + (point.Latitude-start.Latitude)*dy) / denominator
fraction = math.Max(0, math.Min(1, fraction))
dx = point.Longitude - (start.Longitude + fraction*dx)
dy = point.Latitude - (start.Latitude + fraction*dy)
return dx*dx + dy*dy
}
func sampleVisibleLineworkFillPaths(paths [][]GeoPoint, maximumPoints int) [][]GeoPoint {
if maximumPoints < 3 {
maximumPoints = 3
}
result := make([][]GeoPoint, 0, len(paths))
for _, path := range paths {
path = openGeoRing(path)
if len(path) <= maximumPoints {
result = append(result, path)
continue
}
indices := make([]int, 0, maximumPoints)
seen := make(map[int]bool, maximumPoints)
for direction := 0; direction < maximumPoints; direction++ {
angle := 2 * math.Pi * float64(direction) / float64(maximumPoints)
x, y := math.Cos(angle), math.Sin(angle)
bestIndex := 0
bestValue := path[0].Longitude*x + path[0].Latitude*y
for index := 1; index < len(path); index++ {
value := path[index].Longitude*x + path[index].Latitude*y
if value > bestValue {
bestIndex, bestValue = index, value
}
}
if !seen[bestIndex] {
seen[bestIndex] = true
indices = append(indices, bestIndex)
}
}
sort.Ints(indices)
sampled := make([]GeoPoint, 0, len(indices))
for _, index := range indices {
sampled = append(sampled, path[index])
}
result = append(result, sampled)
}
return result
}
func unprojectVisibleLineworkPaths(chart polygonUnionChart, paths [][]GeoPoint) [][]GeoPoint {
result := make([][]GeoPoint, len(paths))
for pathIndex, path := range paths {
result[pathIndex] = make([]GeoPoint, len(path))
for pointIndex, point := range path {
result[pathIndex][pointIndex] = chart.unproject(point)
}
}
return result
}
func visibleLineworkPointPaths(paths [][]GeoPoint) [][]GeoPoint {
result := make([][]GeoPoint, 0)
for _, path := range paths {
for _, point := range path {
result = append(result, []GeoPoint{point})
}
}
return result
}
type visibleLineworkCycleStep struct {
edge int
reverse bool
}
func enumerateVisibleLineworkCycles(
nodes []visibleLineworkNode,
edges []visibleLineworkEdge,
) [][]GeoPoint {
// Walk the face on each side of every edge once. The exterior face of
// each component is retained as a candidate for its complete outer band.
// Enumerating arbitrary combinations of interior faces grows exponentially.
order := make([][]int, len(nodes))
position := make([]int, 2*len(edges))
halfAngle := make([]float64, 2*len(edges))
for index, edge := range edges {
// 正向半边的方向取"首点到相邻的第二点",与反向半边的"末点到倒数第二点"对称;
// 取首末两点会在折线上给出不同的排序角度,进而改变环枚举结果。
first, next := edge.points[0], edge.points[1]
previous, last := edge.points[len(edge.points)-2], edge.points[len(edge.points)-1]
halfAngle[2*index] = math.Atan2(next.Latitude-first.Latitude, next.Longitude-first.Longitude)
halfAngle[2*index+1] = math.Atan2(previous.Latitude-last.Latitude, previous.Longitude-last.Longitude)
}
for node, value := range nodes {
for _, half := range value.outgoing {
id := 2 * half.edge
if half.reverse {
id++
}
order[node] = append(order[node], id)
}
sort.SliceStable(order[node], func(i, j int) bool {
return halfAngle[order[node][i]] < halfAngle[order[node][j]]
})
for i, half := range order[node] {
position[half] = i
}
}
visited := make([]bool, 2*len(edges))
cycles := make([][]GeoPoint, 0, len(edges))
seen := make(map[uint64][][]int32)
for start := range visited {
if visited[start] {
continue
}
var steps []visibleLineworkCycleStep
for half := start; !visited[half]; {
visited[half] = true
step := visibleLineworkCycleStep{edge: half / 2, reverse: half%2 != 0}
if len(steps) > 0 && steps[len(steps)-1].edge == step.edge && steps[len(steps)-1].reverse != step.reverse {
steps = steps[:len(steps)-1]
} else {
steps = append(steps, step)
}
next := edges[step.edge].end
if step.reverse {
next = edges[step.edge].start
}
out := order[next]
half = out[(position[half^1]+len(out)-1)%len(out)]
}
for len(steps) > 1 && steps[0].edge == steps[len(steps)-1].edge && steps[0].reverse != steps[len(steps)-1].reverse {
steps = steps[1 : len(steps)-1]
}
if len(steps) < 1 {
continue
}
for _, simple := range visibleLineworkSimpleCycles(edges, steps) {
if visibleLineworkCycleSeen(seen, simple) {
continue
}
if ring := visibleLineworkCyclePoints(edges, simple); len(ring) >= 3 && math.Abs(visibleLineworkSignedArea(ring)) > 1e-12 {
cycles = append(cycles, ring)
}
}
}
return cycles
}
// The exterior face can revisit an articulation node. Separate its lobes so
// each candidate is a simple ring, including components joined by a bridge.
func visibleLineworkSimpleCycles(edges []visibleLineworkEdge, steps []visibleLineworkCycleStep) [][]visibleLineworkCycleStep {
var cycles [][]visibleLineworkCycleStep
var path []visibleLineworkCycleStep
positions := make(map[int]int)
for _, step := range steps {
start, end := edges[step.edge].start, edges[step.edge].end
if step.reverse {
start, end = end, start
}
positions[start] = len(path)
path = append(path, step)
if at, ok := positions[end]; ok {
cycles = append(cycles, append([]visibleLineworkCycleStep(nil), path[at:]...))
for _, removed := range path[at:] {
node := edges[removed.edge].start
if removed.reverse {
node = edges[removed.edge].end
}
delete(positions, node)
}
path = path[:at]
}
}
return cycles
}
// visibleLineworkCycleSeen 按与方向无关的边集合去重,返回该环是否已出现。
func visibleLineworkCycleSeen(seen map[uint64][][]int32, steps []visibleLineworkCycleStep) bool {
key := make([]int32, len(steps))
for index, step := range steps {
key[index] = int32(step.edge)
}
sort.Slice(key, func(first, second int) bool { return key[first] < key[second] })
hash := uint64(14695981039346656037)
for _, edge := range key {
hash = (hash ^ uint64(uint32(edge))) * 1099511628211
}
for _, existing := range seen[hash] {
if len(existing) != len(key) {
continue
}
same := true
for index, edge := range key {
if existing[index] != edge {
same = false
break
}
}
if same {
return true
}
}
seen[hash] = append(seen[hash], key)
return false
}
func visibleLineworkCyclePoints(
edges []visibleLineworkEdge,
steps []visibleLineworkCycleStep,
) []GeoPoint {
ring := make([]GeoPoint, 0, len(steps)*4)
for _, step := range steps {
points := visibleLineworkDirectedPoints(edges[step.edge], step.reverse)
if len(ring) == 0 {
ring = append(ring, points...)
} else {
ring = append(ring, points[1:]...)
}
}
return sweepDeduplicateAdjacent(openGeoRing(ring))
}
func projectVisibleLine(chart polygonUnionChart, source []GeoPoint) []GeoPoint {
if len(source) == 0 {
return nil
}
line := make([]GeoPoint, 0, len(source))
for sourceIndex, point := range source {
projected := chart.project(point)
if len(line) > 0 {
for projected.Longitude-line[len(line)-1].Longitude > 180 {
projected.Longitude -= 360
}
for projected.Longitude-line[len(line)-1].Longitude < -180 {
projected.Longitude += 360
}
}
if len(line) > 0 {
if math.Hypot(projected.Longitude-line[len(line)-1].Longitude, projected.Latitude-line[len(line)-1].Latitude) > 1e-10 {
line = appendVisibleProjectedArc(line, chart, source[sourceIndex-1], point, line[len(line)-1], projected, 0)
}
continue
}
if len(line) == 0 || math.Hypot(
projected.Longitude-line[len(line)-1].Longitude,
projected.Latitude-line[len(line)-1].Latitude,
) > 1e-10 {
line = append(line, projected)
}
}
return line
}
// A great-circle arc becomes curved after rotation into the topology chart.
// Bound its projected chord error before intersections and face containment.
func appendVisibleProjectedArc(
points []GeoPoint,
chart polygonUnionChart,
first, last, start, end GeoPoint,
depth int,
) []GeoPoint {
if depth < 12 && (math.Abs(end.Longitude-start.Longitude) > 0.25 ||
math.Abs(end.Latitude-start.Latitude) > 0.25 ||
math.Max(math.Abs(start.Latitude), math.Abs(end.Latitude)) > 75) {
middle := sphericalInterpolate(first, last, 0.5)
projected := chart.project(middle)
projected.Longitude = start.Longitude + math.Remainder(projected.Longitude-start.Longitude, 360)
if visibleLineworkPointSegmentDistanceSquared(projected, start, end) > 0.002*0.002 {
points = appendVisibleProjectedArc(points, chart, first, middle, start, projected, depth+1)
return appendVisibleProjectedArc(points, chart, middle, last, projected, end, depth+1)
}
}
return append(points, end)
}
func buildVisibleLineworkGraph(
lines [][]GeoPoint,
chart polygonUnionChart,
snapDistanceKM float64,
) ([]visibleLineworkNode, []visibleLineworkEdge, [][]GeoPoint) {
const commonJunctionKM = 1e-5
nodes, edges, loops := buildVisibleLineworkGraphAtTolerance(lines, chart, commonJunctionKM)
if snapDistanceKM <= 0 {
snapDistanceKM = 1
}
changed := false
for {
first, second := -1, -1
minimum := snapDistanceKM
// Connected intersections are distinct even when physically close.
// Only an open endpoint can require the wider sampling snap radius.
for index, node := range nodes {
if len(node.outgoing) != 1 {
continue
}
point := chart.unproject(node.point)
for otherIndex, other := range nodes {
if index == otherIndex || len(other.outgoing) == 0 {
continue
}
if distance := geoPointDistanceKM(point, chart.unproject(other.point)); distance < minimum {
first, second, minimum = index, otherIndex, distance
}
}
}
if first < 0 {
break
}
changed = true
for _, half := range nodes[first].outgoing {
edge := &edges[half.edge]
if edge.start == first {
edge.start, edge.points[0] = second, nodes[second].point
}
if edge.end == first {
edge.end, edge.points[len(edge.points)-1] = second, nodes[second].point
}
nodes[second].outgoing = append(nodes[second].outgoing, half)
}
nodes[first].outgoing = nil
}
if !changed {
return nodes, edges, loops
}
joined := make([][]GeoPoint, 0, len(edges)+len(loops))
for _, edge := range edges {
joined = append(joined, edge.points)
}
for _, loop := range loops {
joined = append(joined, append(append([]GeoPoint(nil), loop...), loop[0]))
}
// Moving an endpoint can create a crossing on its adjacent segment.
// Re-node those intersections before ordering directed edges into faces.
return buildVisibleLineworkGraphAtTolerance(splitVisibleLineworkIntersections(joined), chart, commonJunctionKM)
}
func buildVisibleLineworkGraphAtTolerance(
lines [][]GeoPoint,
chart polygonUnionChart,
snapDistanceKM float64,
) ([]visibleLineworkNode, []visibleLineworkEdge, [][]GeoPoint) {
if snapDistanceKM <= 0 {
snapDistanceKM = 1
}
nodes := make([]visibleLineworkNode, 0, len(lines))
edges := make([]visibleLineworkEdge, 0, len(lines))
loops := make([][]GeoPoint, 0, 2)
for _, source := range lines {
line := append([]GeoPoint(nil), source...)
start := visibleLineworkNodeIndex(nodes, chart, line[0], snapDistanceKM)
if start < 0 {
start = len(nodes)
nodes = append(nodes, visibleLineworkNode{point: line[0]})
}
end := visibleLineworkNodeIndex(nodes, chart, line[len(line)-1], snapDistanceKM)
if end < 0 {
end = len(nodes)
nodes = append(nodes, visibleLineworkNode{point: line[len(line)-1]})
}
line[0] = nodes[start].point
line[len(line)-1] = nodes[end].point
line = sweepDeduplicateAdjacent(line)
if len(line) < 2 {
continue
}
if start == end && len(openGeoRing(line)) < 3 {
continue
}
duplicate := false
for _, edge := range edges {
if len(edge.points) != len(line) {
continue
}
reverse := edge.start == end && edge.end == start
if !reverse && (edge.start != start || edge.end != end) {
continue
}
same := true
for i, point := range line {
j := i
if reverse {
j = len(line) - 1 - i
}
if !SameGeoPoint(point, edge.points[j]) {
same = false
break
}
}
if same {
duplicate = true
break
}
}
if duplicate {
continue
}
edgeIndex := len(edges)
edges = append(edges, visibleLineworkEdge{points: line, start: start, end: end})
nodes[start].outgoing = append(nodes[start].outgoing, visibleLineworkHalfEdge{
edge: edgeIndex,
})
nodes[end].outgoing = append(nodes[end].outgoing, visibleLineworkHalfEdge{
edge: edgeIndex, reverse: true,
})
}
return nodes, edges, loops
}
func visibleLineworkNodeIndex(
nodes []visibleLineworkNode,
chart polygonUnionChart,
projected GeoPoint,
snapDistanceKM float64,
) int {
target := chart.unproject(projected)
bestIndex := -1
bestDistance := math.Inf(1)
for index, node := range nodes {
distance := geoPointDistanceKM(chart.unproject(node.point), target)
if distance <= snapDistanceKM && distance < bestDistance {
bestIndex, bestDistance = index, distance
}
}
return bestIndex
}
func visibleLineworkDirectedPoints(edge visibleLineworkEdge, reverse bool) []GeoPoint {
if !reverse {
return edge.points
}
points := make([]GeoPoint, len(edge.points))
for index := range edge.points {
points[index] = edge.points[len(edge.points)-1-index]
}
return points
}
func visibleLineworkSignedArea(polygon []GeoPoint) float64 {
if len(polygon) < 3 {
return 0
}
area := 0.0
for current, previous := 0, len(polygon)-1; current < len(polygon); previous, current = current, current+1 {
area += polygon[previous].Longitude*polygon[current].Latitude -
polygon[current].Longitude*polygon[previous].Latitude
}
return area / 2
}