diff --git a/Sources/GeoDrawer/GeoDrawer+BoundarySplit.swift b/Sources/GeoDrawer/GeoDrawer+BoundarySplit.swift index a4fae10..3e3cbfb 100644 --- a/Sources/GeoDrawer/GeoDrawer+BoundarySplit.swift +++ b/Sources/GeoDrawer/GeoDrawer+BoundarySplit.swift @@ -214,6 +214,13 @@ extension GeoDrawer { /// invariant that consecutive points are within `Interpolator.maxDiff` in /// projected space — a jump of `halfWidth` (or `halfHeight`) is far above /// any noise from interpolation. + /// + /// That invariant is not free: `Interpolator.interpolateInto` stops + /// subdividing as soon as a span projects straight, so `projectLine` runs + /// `Interpolator.densify` afterwards to guarantee it. Without that pass a + /// smooth-but-sparse span — a meridian spanning most of a cylindrical + /// map, say — reads exactly like a seam crossing here, and gets split + /// into two pieces with an empty middle. private static func wrapShift(from a: Point, to b: Point, projectionSize: Size) -> Point? { let halfW = projectionSize.width / 2 let halfH = projectionSize.height / 2 diff --git a/Sources/GeoDrawer/GeoDrawer.swift b/Sources/GeoDrawer/GeoDrawer.swift index 53557cb..e87e661 100644 --- a/Sources/GeoDrawer/GeoDrawer.swift +++ b/Sources/GeoDrawer/GeoDrawer.swift @@ -372,7 +372,22 @@ extension GeoDrawer { ) } output.append((unprojected.last!, projectedVertices.last!)) - return output + + // 4. Step 3's shortcut bails as soon as a span projects straight, which + // can leave adjacent points half a map apart. `boundarySplit` reads + // the gap between consecutive points as a wrap signal, so restore the + // invariant before handing off. The quarter-size step keeps a + // comfortable margin under `wrapShift`'s half-size trigger while + // costing only a couple of extra bisections per straight span. + return Interpolator.densify( + output, + maxProjectedStep: Point( + x: projection.projectionSize.width / 4, + y: projection.projectionSize.height / 4 + ), + minUnprojectedStep: maxDiff, + projector: projection.project(_:) + ) } private static func convertLine(_ positions: [GeoJSON.Position], projection: Projection?, size: Size, zoomTo: Rect?, insets: EdgeInsets, coordinateSystem: CoordinateSystem, converter: (GeoJSON.Position, CoordinateSystem) -> Point?, close: Bool) -> [[Point]] { diff --git a/Sources/GeoProjector/Interpolator.swift b/Sources/GeoProjector/Interpolator.swift index f179452..1566589 100644 --- a/Sources/GeoProjector/Interpolator.swift +++ b/Sources/GeoProjector/Interpolator.swift @@ -66,6 +66,110 @@ public enum Interpolator { diffSquared: diffSquared, projector: projector, output: &output) } + /// Restores the invariant that consecutive points are close in *projected* + /// space, which ``interpolateInto(from:aProj:to:bProj:diffSquared:projector:output:)`` + /// deliberately breaks. + /// + /// That shortcut stops subdividing the moment the projected midpoint agrees + /// with the straight-line midpoint. For drawing that's right and cheap — the + /// segment really is straight — but it can leave two adjacent output points + /// arbitrarily far apart whenever the projection happens to be linear over a + /// long span. Downstream code that reads the gap between consecutive points + /// as a signal (antimeridian wrap detection) then can't tell "linear over a + /// long span" from "jumped across a seam". + /// + /// This pass bisects any pair further apart than `maxProjectedStep` on either + /// axis. A smooth span closes up after a couple of levels; a genuine + /// discontinuity never does, so recursion stops at `minUnprojectedStep` and + /// leaves a tight jump straddling the seam — exactly what wrap detection + /// wants to see. + /// + /// - Parameters: + /// - points: `(unprojected, projected)` pairs, in order. + /// - maxProjectedStep: Per-axis gap to subdivide below, in projected units. + /// - minUnprojectedStep: Recursion floor, in unprojected radians. + public static func densify( + _ points: [(Point, Point?)], + maxProjectedStep: Point, + minUnprojectedStep: Double, + projector: (Point) -> Point? + ) -> [(Point, Point?)] { + guard points.count >= 2 else { return points } + let minStepSquared = minUnprojectedStep * minUnprojectedStep + + var output: [(Point, Point?)] = [] + output.reserveCapacity(points.count) + output.append(points[0]) + for i in 1.. Point?, + output: inout [(Point, Point?)] + ) { + // An unprojectable endpoint is already handled downstream as "outside"; + // there's nothing to measure a gap against. + guard let aProj = a.1, let bProj = b.1 else { return } + if abs(aProj.x - bProj.x) <= maxProjectedStep.x, + abs(aProj.y - bProj.y) <= maxProjectedStep.y { return } + // Can't resolve further: this is a genuine discontinuity, and the tight + // jump left behind is the signal wrap detection needs. + if pathDistanceSquared(a.0, b.0) <= minStepSquared { return } + + let c = pathHalfway(a.0, b.0) + let cPair = (c, projector(c)) + densifyInto(from: a, to: cPair, maxProjectedStep: maxProjectedStep, + minStepSquared: minStepSquared, projector: projector, output: &output) + output.append(cPair) + densifyInto(from: cPair, to: b, maxProjectedStep: maxProjectedStep, + minStepSquared: minStepSquared, projector: projector, output: &output) + } + + /// Longitude delta along the path the geometry is meant to follow: the + /// shorter way round, since an edge from 170° to -170° means the 20° hop + /// across the antimeridian, not the 340° trip back through 0°. + /// + /// An edge spanning a full 360° is the exception — it means "all the way + /// round" (a graticule parallel given as -180°→180°), so it keeps its long + /// path instead of collapsing to zero. + private static func pathDeltaX(_ a: Point, _ b: Point) -> Double { + let fullSweep = 2 * Double.pi + var dx = b.x - a.x + guard abs(abs(dx) - fullSweep) > 1e-6 else { return dx } + if dx > .pi { + dx -= fullSweep + } else if dx < -.pi { + dx += fullSweep + } + return dx + } + + private static func pathHalfway(_ a: Point, _ b: Point) -> Point { + var x = a.x + pathDeltaX(a, b) / 2 + if x > .pi { + x -= 2 * .pi + } else if x < -.pi { + x += 2 * .pi + } + return Point(x: x, y: (a.y + b.y) * 0.5) + } + + private static func pathDistanceSquared(_ a: Point, _ b: Point) -> Double { + let dx = pathDeltaX(a, b) + let dy = b.y - a.y + return dx * dx + dy * dy + } + // Legacy 4-arg wrapper kept for compatibility (used by projection setup // for bezier outline generation, etc.). private static func legacyInterpolate( diff --git a/Tests/GeoDrawerTests/BoundarySplitTests.swift b/Tests/GeoDrawerTests/BoundarySplitTests.swift index 4038751..8028c72 100644 --- a/Tests/GeoDrawerTests/BoundarySplitTests.swift +++ b/Tests/GeoDrawerTests/BoundarySplitTests.swift @@ -416,6 +416,146 @@ struct BoundarySplitTests { } } } + + // MARK: - Meridian rendering in cylindrical projections + // + // Regression for the "vertical graticule lines missing in Mercator / + // Equirectangular / Gall-Peters" bug. A two-point meridian from + // (lon, -70) to (lon, 70) projects to two points whose y-separation + // exceeds half the projection height, and the old wrap heuristic + // treated that as a top/bottom seam crossing and split the line into + // top+bottom fragments with an empty middle. + + private func fullMeridianCovers(projection: Projection, size: GeoProjector.Size, lon: Double, latSpan: Double = 70) -> Bool { + let drawer = GeoDrawer(size: size, projection: projection) + let meridian = GeoJSON.LineString(positions: [ + GeoJSON.Position(latitude: -latSpan, longitude: lon), + GeoJSON.Position(latitude: latSpan, longitude: lon), + ]) + let pieces = drawer.project(meridian, coordinateSystem: .bottomLeft) + // A well-drawn meridian is a single continuous vertical line — one + // piece. Even if the algorithm ever returns multiple pieces, the + // union of their y ranges should still cover the two endpoints + // without an interior gap. + guard let allYs = pieces.first?.points.map(\.y), !pieces.isEmpty else { return false } + var yMin = allYs.min() ?? .infinity + var yMax = allYs.max() ?? -.infinity + for piece in pieces.dropFirst() { + for pt in piece.points { + yMin = min(yMin, pt.y) + yMax = max(yMax, pt.y) + } + } + // Endpoints should span most of the canvas height. + return yMax - yMin > size.height * 0.5 + } + + @Test func mercatorMeridianIsContinuous() { + let size = GeoProjector.Size(width: 1000, height: 800) + for lon in stride(from: -170.0, through: 170.0, by: 10.0) { + let proj = Projections.Mercator(reference: .init(x: 77.5.toRadians(), y: -3.6.toRadians())) + #expect(fullMeridianCovers(projection: proj, size: size, lon: lon), + "Mercator meridian at lon=\(lon) is missing / truncated") + } + } + + @Test func equirectangularMeridianIsContinuous() { + let size = GeoProjector.Size(width: 1000, height: 500) + for lon in stride(from: -170.0, through: 170.0, by: 30.0) { + let proj = Projections.Equirectangular() + #expect(fullMeridianCovers(projection: proj, size: size, lon: lon), + "Equirectangular meridian at lon=\(lon) is missing / truncated") + } + } + + @Test func gallPetersMeridianIsContinuous() { + let size = GeoProjector.Size(width: 1000, height: 640) + for lon in stride(from: -170.0, through: 170.0, by: 30.0) { + let proj = Projections.GallPeters() + #expect(fullMeridianCovers(projection: proj, size: size, lon: lon), + "Gall-Peters meridian at lon=\(lon) is missing / truncated") + } + } + + /// Total on-screen length of a projected polyline set. + private func drawnLength(_ pieces: [GeoDrawer.ProjectedLineString]) -> Double { + var total = 0.0 + for piece in pieces { + for (a, b) in zip(piece.points.dropLast(), piece.points.dropFirst()) { + total += ((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)).squareRoot() + } + } + return total + } + + /// A full-circumference parallel must draw across the canvas whatever the + /// reference longitude. At reference 0 the endpoints land exactly on the + /// antimeridian seam, the interpolator's linearity shortcut emits no + /// interior points (midpoint lon 0 projects to x=0, which *is* the + /// straight-line midpoint of ±π), and `boundarySplit` then split a + /// two-point line whose endpoints already sit on the seam — yielding two + /// zero-length pieces hugging the edges and nothing visible. + @Test func parallelsDrawAtAnyReferenceLongitude() { + let size = GeoProjector.Size(width: 1000, height: 1000) + for refLon in [0.0, 45.0, 77.5, -120.0] { + let ref = Point(x: refLon.toRadians(), y: 0) + let projections: [(String, Projection)] = [ + ("Equirectangular", Projections.Equirectangular(reference: ref)), + ("Mercator", Projections.Mercator(reference: ref)), + ("GallPeters", Projections.GallPeters(reference: ref)), + ] + for (name, proj) in projections { + let drawer = GeoDrawer(size: size, projection: proj) + for lat in [0.0, 30.0, -60.0] { + let parallel = GeoJSON.LineString(positions: [ + .init(latitude: lat, longitude: -180), + .init(latitude: lat, longitude: 180), + ]) + let pieces = drawer.project(parallel, coordinateSystem: .bottomLeft) + let length = drawnLength(pieces) + #expect(length > size.width * 0.9, + "\(name) ref=\(refLon) lat=\(lat): parallel drew \(Int(length))px, expected ~\(Int(size.width))px") + } + } + } + } + + // Cassini genuinely wraps top-to-bottom: its transverse cylinder folds + // the far hemisphere (|lon| > 90° from centre) around the top and + // bottom edges. A far-hemisphere meridian must therefore render as at + // least two pieces anchored on opposite y-edges — a straight vertical + // line across the full canvas would be wrong. + @Test func cassiniFarHemisphereMeridianWraps() { + let size = GeoProjector.Size(width: 500, height: 1000) + let proj = Projections.Cassini() + for lon in [-170.0, -140.0, -110.0, 110.0, 140.0, 170.0] { + let drawer = GeoDrawer(size: size, projection: proj) + let meridian = GeoJSON.LineString(positions: [ + GeoJSON.Position(latitude: -70, longitude: lon), + GeoJSON.Position(latitude: 70, longitude: lon), + ]) + let pieces = drawer.project(meridian, coordinateSystem: .bottomLeft) + #expect(pieces.count >= 2, + "Cassini far-hemisphere meridian at lon=\(lon) should split at the top/bottom seam; got \(pieces.count) piece(s)") + } + } + + // Near-hemisphere Cassini meridians must NOT wrap — they're regular + // continuous curves in the middle of the projection. + @Test func cassiniNearHemisphereMeridianIsContinuous() { + let size = GeoProjector.Size(width: 500, height: 1000) + let proj = Projections.Cassini() + for lon in [-80.0, -40.0, 0.0, 40.0, 80.0] { + let drawer = GeoDrawer(size: size, projection: proj) + let meridian = GeoJSON.LineString(positions: [ + GeoJSON.Position(latitude: -70, longitude: lon), + GeoJSON.Position(latitude: 70, longitude: lon), + ]) + let pieces = drawer.project(meridian, coordinateSystem: .bottomLeft) + #expect(pieces.count == 1, + "Cassini near-hemisphere meridian at lon=\(lon) should not split; got \(pieces.count) piece(s)") + } + } } // MARK: - Performance probes (manual; not part of regular suite) diff --git a/Tests/GeoDrawerTests/GeoDrawerSVGTests.swift b/Tests/GeoDrawerTests/GeoDrawerSVGTests.swift index 5410d28..cf05961 100644 --- a/Tests/GeoDrawerTests/GeoDrawerSVGTests.swift +++ b/Tests/GeoDrawerTests/GeoDrawerSVGTests.swift @@ -32,7 +32,7 @@ let expectedSVG = """ - + """ diff --git a/Tests/GeoDrawerTests/GraticuleCoverageTests.swift b/Tests/GeoDrawerTests/GraticuleCoverageTests.swift new file mode 100644 index 0000000..6b858f3 --- /dev/null +++ b/Tests/GeoDrawerTests/GraticuleCoverageTests.swift @@ -0,0 +1,120 @@ +// +// GraticuleCoverageTests.swift +// GeoDrawerTests +// +// Broad "does anything vanish?" sweep over a full graticule. Individual +// projections had their own spot-checks, but the failures that actually +// shipped were whole families of lines quietly rendering as zero-length +// pieces — invisible, yet not absent enough for a point-count check to +// notice. Measuring drawn length across every meridian and parallel +// catches that class directly. +// +// GeoProjector - Native Swift library for drawing map projections +// Copyright (C) 2026 Corporoni Pty Ltd. See LICENSE. + +#if canImport(Testing) +import Testing +import Foundation + +import GeoJSONKit +import GeoProjector +import GeoProjectorDanseiji +@testable import GeoDrawer + +struct GraticuleCoverageTests { + + /// Meridians every 10° (spans matching `Examples/Data/graticule-2.geojson`) + /// plus parallels every 10°, each given as a bare two-point LineString — + /// which is what exposes the interpolation shortcuts. + private static func graticule() -> [(name: String, positions: [GeoJSON.Position])] { + var lines: [(String, [GeoJSON.Position])] = [] + for lonI in stride(from: -170, through: 180, by: 10) { + let lon = Double(lonI) + let span: Double = (lonI % 90 == 0) ? 90 : ((lonI % 30 == 0) ? 80 : 70) + lines.append(("meridian \(lonI)°", [ + .init(latitude: -span, longitude: lon), + .init(latitude: span, longitude: lon), + ])) + } + for latI in stride(from: -80, through: 80, by: 10) { + let lat = Double(latI) + lines.append(("parallel \(latI)°", [ + .init(latitude: lat, longitude: -180), + .init(latitude: lat, longitude: 180), + ])) + } + return lines + } + + private static func projections(reference: Point) -> [(String, Projection)] { + [ + ("Equirectangular", Projections.Equirectangular(reference: reference)), + ("Mercator", Projections.Mercator(reference: reference)), + ("GallPeters", Projections.GallPeters(reference: reference)), + ("Cassini", Projections.Cassini(reference: reference)), + ("EqualEarth", Projections.EqualEarth(reference: reference)), + ("NaturalEarth", Projections.NaturalEarth(reference: reference)), + ("DanseijiI", Projections.DanseijiI(reference: reference)), + ("DanseijiIV", Projections.DanseijiIV(reference: reference)), + ] + } + + /// Total on-screen length of a projected polyline set. Zero-length pieces — + /// the degenerate output of splitting a two-point line whose endpoints both + /// already sit on the projection seam — contribute nothing, which is the + /// point. + private func drawnLength(_ pieces: [GeoDrawer.ProjectedLineString]) -> Double { + var total = 0.0 + for piece in pieces { + for (a, b) in zip(piece.points.dropLast(), piece.points.dropFirst()) { + total += ((b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)).squareRoot() + } + } + return total + } + + /// A meridian lying exactly on the projection's seam (reference ± 180°) + /// projects every one of its points onto the outline itself. For `bezier` + /// bounds `MapBounds.pointInPolygon` accepts an exact *vertex* match but + /// classifies a point lying on an *edge* as outside, so the whole meridian + /// is discarded. That's a separate, pre-existing defect in the + /// point-in-polygon test — not something interpolation can fix — and + /// changing it alters polygon clipping semantics on a documented hot path, + /// so it's excluded here rather than silently absorbed. + private func isSeamMeridianOnBezierOutline( + lineName: String, projection: Projection, referenceLongitude: Double + ) -> Bool { + guard case .bezier = projection.mapBounds else { return false } + var seam = referenceLongitude + 180 + if seam > 180 { seam -= 360 } + return lineName == "meridian \(Int(seam))°" + } + + @Test(arguments: [0.0, 77.5, -120.0]) + func everyGraticuleLineDrawsSomething(referenceLongitude: Double) { + let size = GeoProjector.Size(width: 1000, height: 1000) + let reference = Point(x: referenceLongitude.toRadians(), y: 0) + let lines = Self.graticule() + + for (projectionName, projection) in Self.projections(reference: reference) { + let drawer = GeoDrawer(size: size, projection: projection) + for (lineName, positions) in lines { + if isSeamMeridianOnBezierOutline( + lineName: lineName, projection: projection, referenceLongitude: referenceLongitude + ) { continue } + + let pieces = drawer.project( + GeoJSON.LineString(positions: positions), + coordinateSystem: .bottomLeft + ) + let length = drawnLength(pieces) + // 50px on a 1000px canvas: far below any legitimate graticule line, + // far above the zero-ish length of a degenerate split. + #expect(length > 50, + "\(projectionName) ref=\(referenceLongitude)°: \(lineName) drew only \(Int(length))px") + } + } + } +} + +#endif