Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Sources/GeoDrawer/GeoDrawer+BoundarySplit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion Sources/GeoDrawer/GeoDrawer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]] {
Expand Down
104 changes: 104 additions & 0 deletions Sources/GeoProjector/Interpolator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<points.count {
densifyInto(
from: points[i - 1], to: points[i],
maxProjectedStep: maxProjectedStep, minStepSquared: minStepSquared,
projector: projector, output: &output
)
output.append(points[i])
}
return output
}

private static func densifyInto(
from a: (Point, Point?), to b: (Point, Point?),
maxProjectedStep: Point, minStepSquared: Double,
projector: (Point) -> 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(
Expand Down
140 changes: 140 additions & 0 deletions Tests/GeoDrawerTests/BoundarySplitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Tests/GeoDrawerTests/GeoDrawerSVGTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
let expectedSVG = """
<?xml version="1.0" encoding="UTF-8"?>
<svg width="400.0" height="200.0" viewBox="0 0 400.0 200.0" xmlns="http://www.w3.org/2000/svg">
<path d="M 100.0 100.0 L 300.0 100.0" stroke="#FF0000" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="M 100.0 100.0 L 200.0 100.0 L 300.0 100.0" stroke="#FF0000" stroke-width="2.0" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
"""

Expand Down
Loading
Loading