Skip to content

fix: simplify tolerance conversion using longitude instead of latitude - #253

Open
nathancahill wants to merge 1 commit into
Outdooractive:mainfrom
GoatMaps:fix/simplify
Open

nathancahill wants to merge 1 commit into
Outdooractive:mainfrom
GoatMaps:fix/simplify

Conversation

@nathancahill

@nathancahill nathancahill commented Sep 29, 2026 •

Copy link
Copy Markdown

Problem

The meter-to-degree tolerance conversion in Simplify.simplify(coordinates:toleranceInMeters:highQuality:) used cos(longitude) where it must be cos(latitude):

let oneDegreeLongitudeDistanceInMeters = (cos(firstCoordinate.longitude * .pi / 180.0) * 111.0) * 1000.0

Longitude has no physical bearing on meters-per-degree of longitude, so for geometries away from the equator the conversion was nonsensical. Worst case near longitude ±90° (e.g. the western US: cos(-90°) ≈ 6e-17): one-degree-distance ≈ 0 → tolerance in degrees ≈ ∞ → valid geometry collapses almost completely with the default tolerance. (Visible to callers via GeoJson.simplified/simplify, which always pass meters into this path.)

Fix

Use cos(latitude) as everywhere else in the codebase (cf. GISTool.degrees(fromMeters:atLatitude:)):

let oneDegreeLongitudeDistanceInMeters: CLLocationDistance = (cos(firstCoordinate.latitude * .pi / 180.0) * 111.0) * 1000.0

This changes simplified output for geographic geometries away from longitude 0° vs. previous releases — toward correct behavior.

Notes / possible follow-ups (non-blocking)

  • At the poles cos(±90°) → 0 → infinite degree tolerance (pre-existing heuristic behavior).
  • simplifyVisvalingamWhyatt currently uses latitude-independent projection.crsLength(fromMeters:); aligning the two conventions could be a separate task.

@trasch trasch added the bug Something isn't working label Sep 30, 2026

@trasch trasch left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (bot-assisted, verified locally on the PR head dd62071):

  • The fix now matches GISTool.degrees(fromMeters:atLatitude:) and every other cos-of-latitude conversion in the codebase.

Two optional suggestions inline below: dedupe the conversion via the existing helper (mind the small constant change), and one cheap planar assertion to round out projection coverage.

Comment on lines +358 to 359
let oneDegreeLongitudeDistanceInMeters: CLLocationDistance = (cos(firstCoordinate.latitude * .pi / 180.0) * 111.0) * 1000.0
let toleranceInDegrees: CLLocationDegrees = toleranceInMeters / oneDegreeLongitudeDistanceInMeters

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, optional: this now duplicates the conversion that already exists as Coordinate3D.degrees(fromMeters:) (Sources/GISTools/Algorithms/Conversions.swift). Suggested change below reuses it.

Note that this also swaps the hardcoded 111.0 km/degree for GISTool.earthCircumference / 360.0 (~111.32 km/degree), i.e. it is slightly more accurate but does change results marginally. Entirely reasonable to defer this refactor to a follow-up if you want this PR strictly minimal.

Suggested change
let oneDegreeLongitudeDistanceInMeters: CLLocationDistance = (cos(firstCoordinate.latitude * .pi / 180.0) * 111.0) * 1000.0
let toleranceInDegrees: CLLocationDegrees = toleranceInMeters / oneDegreeLongitudeDistanceInMeters
let toleranceInDegrees = firstCoordinate.degrees(fromMeters: toleranceInMeters).longitudeDegrees

// vertices must survive regardless of the meridian.
#expect(simplifiedAtGreenwich.coordinates.count > 50)
#expect(simplifiedAtMinus90.coordinates.count == simplifiedAtGreenwich.coordinates.count)
#expect(simplifiedAtMinus111.coordinates.count == simplifiedAtGreenwich.coordinates.count)

@trasch trasch Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: bug fixes should include tests for all affected projections. The degree conversion only runs on the geographic branch, so one cheap planar assertion in the new test is enough (keeps the regression test self-contained; broader coverage already exists in simplify3857). Note Coordinate3D(x:y:) defaults to .epsg3857.

Suggested change
#expect(simplifiedAtMinus111.coordinates.count == simplifiedAtGreenwich.coordinates.count)
#expect(simplifiedAtMinus111.coordinates.count == simplifiedAtGreenwich.coordinates.count)
// The degree conversion only applies to geographic projections;
// planar projections (e.g. EPSG:3857) pass meters through directly.
let planarLine = try #require(LineString([
Coordinate3D(x: 0.0, y: 0.0),
Coordinate3D(x: 250.0, y: 250.0),
Coordinate3D(x: 500.0, y: 0.0),
]))
#expect(planarLine.simplified(tolerance: 100.0).projection == .epsg3857)

@trasch trasch left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(duplicate submission, retired — see the earlier review for the actual feedback)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants