Skip to content

Add inverse function#38

Merged
nighthawk merged 3 commits into
mainfrom
6-inverse
May 9, 2026
Merged

Add inverse function#38
nighthawk merged 3 commits into
mainfrom
6-inverse

Conversation

@nighthawk
Copy link
Copy Markdown
Member

Closes #6

claude added 2 commits May 8, 2026 23:44
…/lon

Adds a required `inverse(_:)` method to the `Projection` protocol and
implements it for all 8 in-tree projections, plus a high-level
`coordinate(at:size:zoomTo:insets:coordinateSystem:)` API that turns a
screen-pixel `Point` into a `GeoJSON.Position?`. EqualEarth and
NaturalEarth use Newton-Raphson; the rest are closed-form. Out-of-image
clicks (e.g. off the globe of an Orthographic map) return nil via a new
`MapBounds.contains` helper.

https://claude.ai/code/session_01CsF6py8qHa7t2ZQpmsaHh7
Adds a bottom status bar to the macOS Cassini sample that shows the
geographic coordinate under the cursor, computed via the new
`Projection.coordinate(at:size:zoomTo:insets:coordinateSystem:)` API.
Clicking the map locks the coordinate; the status bar then offers Copy
(to clipboard, returns to live mode) and Discard (returns to live mode
without copying), with ⌘C and Esc shortcuts. Exposes
`GeoDrawer.zoomTo` so the example can mirror the drawer's projected
zoom rect when computing inverses.

https://claude.ai/code/session_01CsF6py8qHa7t2ZQpmsaHh7
@nighthawk nighthawk self-assigned this May 9, 2026
@nighthawk nighthawk added the enhancement New feature or request label May 9, 2026
@nighthawk nighthawk merged commit c043d34 into main May 9, 2026
8 checks passed
@nighthawk nighthawk deleted the 6-inverse branch May 9, 2026 00:54
nighthawk added a commit that referenced this pull request May 9, 2026
- Note that we now tag releases (the package is on `0.x` so far). Replace
  the "no tagged versions yet" branch dependency with a `from: "0.1.0"`
  example.
- Add `GeoProjectorDanseiji` to the library list, with a short note that
  it's a separate product so apps that don't use it don't pull in the
  ~1 MB of mesh data.
- Mention `inverse` and `coordinate(at:...)` as a first-class capability
  of the projection API (added in #38, but the README still spoke of
  forward projection only). Add a short example that maps a click back
  to lat/lon.
- Drop the dangling `?.0` tuple in the projection example — the API
  returns `Point?` since the boundary-aware split rewrite landed.
- Tidy the platform-conventions sentence (the previous version had
  swapped lat/lon and only mentioned macOS); call out that
  `GeoMapView` is `NSView` on macOS and `UIView` elsewhere.
- Mention that `GeoDrawer` can also draw to a `CGContext` or to SVG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nighthawk added a commit that referenced this pull request May 9, 2026
- Add Danseiji projection family (see https://github.com/jkunimune/Map-Projections)
- Boundary-aware split for polygons & lines
- Speed-up projection pipeline
- Fix a number of wrap glitches

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---

* Add Danseiji projection family (#3)

Adds all six Danseiji map projections (I–VI) by Justin Kunimune as a new
optional SPM target, GeoProjectorDanseiji. Kept separate from the core
GeoProjector library so consumers that don't want the ~6.9 MB of mesh data
don't pay the cost.

- Vendor the six danseijiX.csv mesh files under
  Sources/GeoProjectorDanseiji/Resources/, unchanged from upstream
  (https://github.com/jkunimune/Map-Projections, MIT licensed).
- Port the forward-projection algorithm from upstream Danseiji.java
  (mesh-cell barycentric interpolation). Inverse projection is intentionally
  not exposed — the Projection protocol only requires forward.
- Expose Projections.DanseijiI … Projections.DanseijiVI, matching the
  one-struct-per-projection pattern used by NaturalEarth, EqualEarth, etc.
- Add DanseijiVariant — a sibling of ProjectionMode for clients that want
  Codable, enum-driven selection across the six variants.
- Cache parsed mesh data per variant under an NSLock so initialisers stay
  cheap.
- Wire the Cassini example app to expose the six new variants in its picker.

Closes part of #3.

* Add inverse projection support for Danseiji variants

The merged inverse-projection feature added a required `inverse(_:)` method
to the `Projection` protocol, which Danseiji structs need to implement.

- Extend the CSV parser to read the `pixels` section (phi, lam samples on a
  rectangular grid) plus the edge polygon's bounding box, both of which were
  previously skipped.
- Port `DanseijiProjection.inverse` from upstream `Danseiji.java`: ray-cast
  point-in-polygon test against the edge, then bilinear interpolation of the
  pixel grid in 3-D Cartesian space (so meridians don't blow up at the poles)
  before converting back to (lon, lat).
- Apply the reference longitude shift on the way out.
- Add inverse round-trip tests over a coarse lat/lon grid for all six variants
  (loose tolerance — ~3° — given the bilinear interpolation step).

* Address a test failure

* Boundary-aware split for polygons & lines (issue #10)

Replace the willWrap-based splitter with a generic boundary-crossing
detector that consults each projection's mapBounds in projected radian
space. Polygons whose exterior crosses the projection edge are now split
at the crossing and closed along the boundary, instead of drawn as long
chords across (or beyond) the visible map.

Fixes the "huge sweeping triangles / horizontal stripes" rendering
glitches on Danseiji III & IV (whose bezier outlines are interrupted),
the orthographic limb chord, and the antimeridian-crossing chord on
cylindrical projections (Antarctica's exterior wraps -180° to +180°).

Removes Projection.willWrap entirely — the boundary-aware split subsumes
its job in the renderer, and the per-projection overrides existed only
to feed it. Projection.point(for:size:…) drops its tupled Bool return
in the same change (callers were only the renderer).

- Sources/GeoProjector/MapBounds+Contains.swift: new public helpers
  firstIntersection(from:to:projectionSize:) and
  boundaryArc(from:to:projectionSize:) covering rectangle, ellipse, and
  bezier outlines. contains(_:projectionSize:) made public.
- Sources/GeoDrawer/GeoDrawer+BoundarySplit.swift: new helper file with
  boundarySplit and boundarySplitClosed.
- Sources/GeoDrawer/GeoDrawer.swift: convertLine rewritten on top of the
  new helpers; new convertPolygon variant closes each split piece along
  the projection boundary so polygon fill follows the edge.
- Tests/GeoDrawerTests/BoundarySplitTests.swift: covers the geometric
  primitives plus end-to-end splits through Equirectangular, Orthographic,
  and a synthetic interrupted bezier.

Antarctica's residual unfilled gap near the south pole (the GeoJSON
stops at lat -85.6° and never reaches -90°) is left as a follow-up.

* Fix wrap glitches in pseudocylindrical, azimuthal, and Danseiji projections

Three independent visual bugs all shared the same root cause family —
the boundary splitter not handling antimeridian/antipode wrap cleanly,
plus floating-point edge cases.

- `GeoDrawer+BoundarySplit`: detect wrap-length jumps in every prev/curr
  case (inside↔inside, inside↔outside, outside↔inside) and shift the
  far-side point onto the near-side before computing the boundary
  intersection. Also detect paired exit/entry crossings for concave
  bezier notches, and merge first/last raw pieces when the input ring
  is closed and starts/ends inside the bounds.
- `MapBounds.allIntersections`: generalises `firstIntersection` to
  return every crossing with a de-dup pass for segment-through-vertex
  cases. Powers the concave-notch split.
- `AzimuthalEquidistant.project`: clamp `cos(c)` to `[-1, 1]` before
  `acos` and reject points within `1e-6` of the antipode where
  `k = c / sin(c)` blows up.

Tests cover the regressions:
- equirectangular and bezier antimeridian splits (closed polygon and
  bare line)
- bezier concave-notch split
- Equal Earth + Natural Earth real-continents at non-zero longitude
  references
- Azimuthal antipode rejection and grid-wide finiteness

* Speed up projection pipeline (5× Debug, 4× Release)

The slider-drag and projection-switch lag in the example app was driven by
re-projecting all continents per slider tick. A few hot paths were doing
significantly more work than they needed to:

- `Interpolator.interpolate` projected `a` and `b` at every recursion step,
  even though both are already projected by the caller (and re-projected
  again by the parent recursion). Refactor to a tail-recursive helper that
  threads `aProj`/`bProj` through, so each unprojected midpoint is
  projected exactly once. Also drops the `[c_proj].compactMap.map`
  allocation and the `lefty + middy + righty` array concatenations in
  favour of a single in-place output buffer.

- `GeoDrawer.projectLine` was projecting every interior polygon vertex
  twice (once at the end of pair (i-1, i), once at the start of pair
  (i, i+1)). Project the whole vertex list once and feed those
  projections into the new `Interpolator.interpolateInto`. As a
  side-effect, the SVG output no longer contains duplicated vertices
  back-to-back, so the SVG snapshot tests are updated.

- `Point.distanceSquared` and `Point.halfway` now do `dx * dx` instead of
  `pow(_, 2)` and direct multiplication instead of division. Tagged
  `@inline(__always)` since they sit in the deepest hot path. EqualEarth's
  `poly9`/`poly8` likewise pull `pow` out in favour of explicit products.

- `MapBounds.pointInPolygon` swaps array subscripts for
  `withUnsafeBufferPointer`, which removes the per-element bounds checks
  that dominate Debug builds. Hoists the loop-invariant `p.x`/`p.y` to
  locals.

- `boundarySplit` no longer pays for the `mapBounds.allIntersections`
  search on every (true, true) pair when the boundary is convex
  (`.rectangle`, `.ellipse`, or convex-bezier projections like Equal
  Earth and Natural Earth). A convex shape can't be exited and re-entered
  by a single straight segment with both endpoints inside, so the
  multi-crossing detector is only useful for the genuinely concave
  Danseiji notches. Convexity is detected once per `boundarySplit` call
  via a new `MapBounds.isConvex` helper.

Bench (98 polygons × 5 reps, world continents):
- Equal Earth     706ms → 136ms Debug   |   17.5ms → 4.6ms Release
- Orthographic    129ms →  35ms Debug   |   14.0ms → 4.8ms Release

Tests: 55 pass (3 SVG snapshots updated for de-duplicated vertices,
2 new `PerfBench` cases).

* Pin Danseiji V & VI reference at the origin

V's mesh emphasises continents over oceans; VI weighs population alongside
area. Both are hand-tuned, asymmetric meshes — rotating the reference
longitude shears the deformations relative to the underlying geography in
a way that stops being meaningful. (IV is similarly hand-tuned but its
distortions are unique enough that playing with the reference is fun, so
keep that one.)

V and VI now ignore the constructor's `reference` argument and pin
`reference` at `(0, 0)`. The Cassini example marks them as not using the
reference longitude, so that slider is disabled when V or VI is selected.

New `vAndVIIgnoreReference` test in `DanseijiTests` confirms a non-zero
reference produces the same projected coordinates as the centred default.

* Centre asymmetric Danseiji projections in the canvas

Danseiji III–VI have edge polygons that aren't symmetric around the
geographic origin (the mesh data was hand-tuned that way: III's centre
sits at (+0.26, +0.56), IV's at (-0.49, +1.45), V's at (+0.34, +0.25),
VI's at (+0.59, +0.72)). The canvas-fitting code in `simpleTranslate`
assumed the projection rectangle was `[-w/2, +w/2] × [-h/2, +h/2]` and
fit `projectionSize` (computed as 2 × max(|xMin|, |xMax|) etc.) into the
canvas. With asymmetric edges that meant the actual map sat off-centre
and partway off-canvas, and edge insets weren't respected.

Add a `visibleBounds: Rect` to `Projection` describing the rectangle in
projection-radians that should be fit to the canvas. Default is
`projectionSize` centred at the origin, so all existing projections are
unaffected. `simpleTranslate` and `simpleUntranslate` now normalise
against `visibleBounds` rather than assuming centred-at-origin.

Danseiji projections override `visibleBounds` to return the
`DanseijiData.edgeBounds` rectangle that the mesh parser already
computed, so canvas fitting hugs the actual edge polygon for every
variant. I and II are unchanged because their edges happen to be
centred at the origin already.

New `edgePolygonIsCentredInCanvas` and `edgeInsetsShrinkProportionally`
tests run all six variants and assert opposing canvas margins match,
every edge vertex is inside the canvas, and edge insets shrink the map
on every side.

* Fix CI: linux build + iOS resource bundle

- The `PerfBench` struct ended up *outside* the `#if canImport(Testing)`
  guard, so Linux builds (which lack swift-testing) failed compiling
  with "unknown attribute 'Test'" on every benchmark method. Move the
  struct back inside the guard.
- Switch the Danseiji resource declaration from `.copy("Resources")`
  to `.process("Resources")`. SPM's `.copy` produces a bare directory
  bundle that fails iOS CodeSign with "bundle format unrecognized,
  invalid, or unsuitable" — `.process` emits a properly-structured
  bundle (with Info.plist) that signs cleanly. The CSV files are still
  loaded via `Bundle.module.url(forResource:withExtension:)` and the
  Danseiji unit tests continue to pass.

Verified locally with `swift test` (62/62 pass), plus
`xcodebuild -sdk iphoneos` and Cassini on `iphonesimulator` both build.

* Update README for tagged versions, GeoProjectorDanseiji, and inverse

- Note that we now tag releases (the package is on `0.x` so far). Replace
  the "no tagged versions yet" branch dependency with a `from: "0.1.0"`
  example.
- Add `GeoProjectorDanseiji` to the library list, with a short note that
  it's a separate product so apps that don't use it don't pull in the
  ~1 MB of mesh data.
- Mention `inverse` and `coordinate(at:...)` as a first-class capability
  of the projection API (added in #38, but the README still spoke of
  forward projection only). Add a short example that maps a click back
  to lat/lon.
- Drop the dangling `?.0` tuple in the projection example — the API
  returns `Point?` since the boundary-aware split rewrite landed.
- Tidy the platform-conventions sentence (the previous version had
  swapped lat/lon and only mentioned macOS); call out that
  `GeoMapView` is `NSView` on macOS and `UIView` elsewhere.
- Mention that `GeoDrawer` can also draw to a `CGContext` or to SVG.

* Credit Claude, too

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add inverse function to Projection protocol

2 participants