Skip to content

Feature/bitmap drawing support#42

Open
nighthawk wants to merge 27 commits into
mainfrom
feature/bitmap-drawing-support
Open

Feature/bitmap drawing support#42
nighthawk wants to merge 27 commits into
mainfrom
feature/bitmap-drawing-support

Conversation

@nighthawk
Copy link
Copy Markdown
Member

@nighthawk nighthawk commented May 11, 2026

Adds support for drawing bitmap base maps. Looks very cool with the NASA Blue Marble.

Also includes support for slippy tiles, such as OSM. The Cassini example includes that.

Danseiji IV showing Blue Marble and a graticule

Closes #7

claude and others added 24 commits May 9, 2026 20:35
Drape an equirectangular bitmap (e.g. NASA Blue Marble) under the existing
vector layers by iterating output pixels and using each projection's
`Projection.inverse(_:)` to look up a source pixel. The new
`Content.baseMap(BaseMap)` case flows through the same pipeline as lines and
polygons, so both the live `GeoMapView` path and the `drawImage(_:)` export
path pick it up. SVG output ignores raster base maps in v1.

Per-output-pixel work runs across rows via `DispatchQueue.concurrentPerform`,
and the rendered `CGImage` is cached on the `GeoDrawer` instance so vector-
layer toggles don't re-rasterise. The cache is dropped automatically when
`GeoMapView` discards the drawer on projection / zoom / insets / size change.
The async pre-projection task pre-warms the cache off the main thread so the
first frame doesn't block the run loop.

Cassini gets a "Show procedural texture" toggle that synthesises a small
equirectangular pattern on the fly, demonstrating the feature without
bundling a real-world raster.
Bundle the 5400×2700 Blue Marble Next Generation 2004-08 composite from
NASA Earth Observatory in Cassini's asset catalogue and load it on first
toggle via UIImage / NSImage. The procedural pattern was useful as a
sanity check while the renderer was being built, but the real raster
shows projection-specific behaviour (continents, polar caps, ocean
shading) that the synthetic version can't.

The asset catalogue folder reference already covers new imagesets, so
no project.pbxproj changes are required. The 5400-pixel source is
downscaled by `BaseMap.decode` to fit the default 4096 maxDimension cap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
OptionsView only renders the LayersList on macOS, so iOS has no UI for
toggling individual layer visibility. With the Blue Marble raster now
available cross-platform, the most useful default on iOS is to show
just the raster — the green vector outline on top of the photographic
texture is muddy and the user can't switch it off there anyway.

Add a `showBaseMap:` parameter to `Model.init` and seed iOS launches
with the continents layer hidden and `showBaseMap = true`. macOS keeps
the current default (continents visible, Blue Marble off; user toggles).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`CGContext.draw(image:in:)` ignores the user-space y-axis direction
and always lays out the image's row 0 at the rect's `maxY` in
Quartz's own y-up frame. On AppKit that matches the unflipped user-
space, so the raster lands upright. On UIKit the surrounding CTM is
flipped so user-space is y-down — and the image ends up upside-down
relative to the screen, even though the surrounding vector layers
(which use path drawing, not image drawing) come out correctly.

Save the CTM, translate by `bounds.maxY` and scale y by -1 just for
the `context.draw(raster, in:)` call when `coordinateSystem ==
.topLeft`, then restore. The clip path established by the outer
`saveGState` is unaffected because it was set in user-space before
the inner save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Confirms `drawImage` / `draw(_:in:)` correctly composites
`Content.baseMap` onto an offscreen CGContext (and produces a PNG
that's eyeballable). Gated behind RENDER_SAMPLES=1 so it stays out
of CI:

    RENDER_SAMPLES=1 swift test --filter RenderSamples

Adds GeoProjectorDanseiji to the GeoDrawerTests target so the
sample renderer can use Danseiji IV.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the equirectangular-only `BaseMap.SourceProjection` enum
with an `any Projection` field, so a `BaseMap` can wrap any
GeoProjector-supported projection: Mercator (Web Mercator-style
imagery), Gall–Peters, Cassini, even pseudocylindrical or azimuthal
sources for niche cases. Equirectangular remains the default.

The per-pixel sampler now forward-projects each output pixel's
geographic coordinate through the source projection via
`Projection.point(for:size:coordinateSystem: .topLeft)` to find the
backing source-image pixel — no more hardcoded UV math. Out-of-image
samples wrap longitudinally for cylindrical sources (where the new
`wrapsLongitudinally` protocol property is true) or fall through as
transparent for everything else.

Adds three protocol overrides — Equirectangular, Mercator, and
Gall–Peters return `wrapsLongitudinally = true`; Cassini and the
non-cylindrical projections inherit the `false` default. The
`BaseMap.Hashable` conformance is split out and uses image identity
plus projection type/reference as the cache key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GeoDrawer is one library with conditional compilation: the SVG
renderer, projection pipeline, boundary-splitting, and Content type
all build on Linux today; the CoreGraphics drawing path, UIKit/AppKit
view classes, and the (currently CG-gated) base-map decoder/sampler
do not. Group the Apple-only files under apple/ so the top-level
listing reflects what's actually cross-platform.

No code changes — git renames only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure-Swift, Linux-friendly. Defines the slippy-map XYZ addressing
scheme (`TileKey(z, x, y)`, north-west origin) and an opaque
`TileImage` (RGBA8 premultiplied, row-major). Implementations can
fetch over HTTP, read from disk, or hold pre-decoded tiles in memory.

`StaticTileSource` is the simplest concrete implementation — an
in-memory dictionary of pre-decoded tiles. It covers the high-
resolution Blue Marble case (split the 21600×10800 NASA composite
into a fixed grid, decode once at startup) and gives tests a
deterministic source without network or filesystem.

No renderer integration yet; that's the next step.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`URLTemplateTileSource` resolves `{z}/{x}/{y}` placeholders against
the supplied template string, fetches with `URLSession`, and decodes
via a caller-supplied closure. The closure-based decoder hook keeps
the type platform-agnostic — Apple users can pass the bundled
`TileImage.coreGraphicsDecoder` (in apple/) or the convenience
initialiser that defaults to it; Linux users plug in their own
(e.g. wrapping swift-png).

HTTP 404 maps to a nil tile (some services skip ocean / no-data
areas); other 4xx/5xx throw `URLTemplateTileSourceError.httpStatus`.
A `userAgent` field is exposed because OSM and several other public
tile services 429 unidentified clients.

Tests cover the CoreGraphics decoder round-tripping a synthesised
PNG and rejecting garbage. The HTTP path is exercised by
constructor wiring and `contains(_:)` checks for now; full mock
coverage will land alongside the renderer integration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the rendering side of the tile pipeline:

- `Content.tiledBaseMap(TiledBaseMap)` (CG-gated) — parallel to the
  existing `.baseMap`, but backed by a `TileSource` rather than a
  single in-memory image. `TiledBaseMap` is `Hashable` via the source's
  `tileSourceID`.

- Pre-fetch step: `GeoDrawer.prefetchTiles(for:)` walks the canvas on
  a coarse stride, computes which tiles cover the visible projection
  (via the source projection's forward transform), and fetches them in
  parallel into a class-backed `TileCache` on the drawer.

- Renderer: `GeoDrawer.renderedTiledBaseMap(_:coordinateSystem:)`
  reuses the per-pixel inverse-projection sweep but routes the
  source-pixel sample through `(tile, sub-x, sub-y)` lookups in the
  pre-fetched cache. Raster output is cached on the drawer alongside
  the existing single-image base-map cache.

- `GeoMapView` (UIKit + AppKit) pre-fetches tiles before pre-warming
  the raster, so first-frame draw is hit-only on the run loop.

- `TileSource.tileSourceID: AnyHashable` lets the cache and
  `Content.Hashable` distinguish sources; URL templates use the
  template string, static sources default to a UUID per construction.

The CoreGraphics drawer composites both `.baseMap` and `.tiledBaseMap`
through the same UIKit-flip-aware path, behind the same map-bounds
clip. SVG output continues to skip raster underlays.

Existing Danseiji IV / Blue Marble single-image render is bit-
identical to before (re-verified by RENDER_SAMPLES).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
End-to-end verification of the TileSource pipeline. Splits the
bundled Blue Marble JPEG (5400×2700 equirectangular) into 8 tiles of
1350×1350 (a 4×2 content grid placed inside a virtual 4×4 zoom=2
grid; the top and bottom rows are empty because equirectangular
content is 2:1, not square). Builds a StaticTileSource over those
tiles, wraps it in a TiledBaseMap, pre-fetches via
drawer.prefetchTiles(for:), and renders Danseiji IV through it.

The output matches the single-image baseline (modulo a 1-pixel hard
seam at inter-tile boundaries — cross-tile bilinear blending isn't
implemented in v1). Re-run with:

    RENDER_SAMPLES=1 swift test --filter render_danseiji_iv_blue_marble_tiled

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`TiledBaseMap.zoom` becomes a `Zoom` enum: `.fixed(Int)` for the
existing explicit-level behaviour, or `.auto` (the new default) for
the renderer to pick a level matching the canvas resolution at draw
time:

    z = round(log2(max(canvas.width, canvas.height) / source.tileSize))

clamped to `[source.minZoom, source.maxZoom]`. Slippy-map
consumers (URLTemplateTileSource over a responsive `GeoMapView`)
no longer have to recompute z each time the canvas resizes; the
existing `init(source:zoom: Int, …)` initialiser is preserved as a
convenience that wraps the integer in `.fixed`.

Cache key now uses the resolved zoom level so `.auto` and an
explicit `.fixed(z)` that pick the same level share the same
rendered raster.

Doesn't account for `zoomTo`-region scaling — for tightly zoomed
regions the heuristic is too low and `.fixed(_:)` is the right
escape hatch. Documented as a v1 limitation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a "Base maps" section under the existing Maps section covering
both single-image (`BaseMap`) and tiled (`TiledBaseMap` +
`TileSource`) usage. Calls out the Linux-friendly tile-source
protocol and notes that only the default tile-bytes decoder is
gated behind CoreGraphics.

Updates the GeoDrawer bullet at the top and the Goals list to
mention raster underlays.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "Base map" toggle becomes a 3-way segmented picker — None, Blue
Marble, OpenStreetMap — backing into `BaseMap` for the bundled
JPEG and `URLTemplateTileSource` + `TiledBaseMap` for the slippy
OSM tiles. Switch projections to see OSM imagery re-projected
through Equal Earth, Orthographic, Danseiji IV, etc.

- New `BaseMapMode` enum on `ContentView` (`@AppStorage`-backed so
  the choice persists across launches).
- `OpenStreetMap.makeTiledBaseMap()` factory wires the official OSM
  tile-server template through `Projections.Mercator`, with auto
  zoom-level selection from the canvas size and a non-default
  User-Agent (OSM's tile usage policy requires identifying the app).
- `AttributionLabel` overlay in the bottom-right corner shows OSM's
  attribution string when the OSM source is active; the Blue Marble
  source declares no attribution.
- Adds `com.apple.security.network.client` to Cassini's entitlements
  so the sandboxed app can hit OSM.
- `CassiniApp` seeds macOS with `.none` (continents-only) and iOS
  with `.blueMarble`, matching the previous defaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs in `GeoMapView`'s OSM rendering:

1. Switching projection emptied the tile cache because `tileCache`
   was reset along with the drawer. The new drawer's raster
   sampler then found no tiles and produced a transparent layer
   until something forced a re-fetch (e.g. toggling the base-map
   picker off and on). Fix: `GeoDrawer.tileCache` becomes a `var`
   and `GeoMapView` owns a class-backed instance that gets
   assigned into every freshly-built drawer. Tile bytes are
   projection-independent, so reusing them across
   projection/size/zoom changes is correct and avoids re-hitting
   the network.

2. Tile rasters were rendered at canvas-point size and then
   upscaled by Core Graphics onto the Retina backing store,
   producing a visibly blurry composite while the vector layer
   on top stayed sharp (CG paths render natively at the
   destination context's resolution). Fix: add
   `GeoDrawer.pixelDensity` (default 1.0); the
   `renderBaseMap`/`renderTiledBaseMap` paths now allocate a
   `points × pixelDensity` buffer, convert per-pixel coordinates
   back to points before asking the projection, and feed that
   higher-resolution CGImage into the existing point-sized
   `context.draw(_:in:)` call. Both UIKit and AppKit GeoMapView
   set `pixelDensity` from `traitCollection.displayScale` /
   `window.backingScaleFactor` and rebuild the drawer when the
   view moves to a new window. Auto-zoom uses the same scaled
   canvas so Retina displays fetch the next zoom level up rather
   than upscaling a coarser tile. Caches include the pixel
   density so a 1x raster isn't served at 2x.

Default `pixelDensity = 1.0` keeps offscreen renders (tests,
`drawImage(_:)` from a default-scale renderer) bit-identical to
before — the Danseiji IV sample renders unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two bugs in projection-switch + tile-load behaviour:

1. After switching to a projection that needed more tiles than the
   prior one had cached, half the world stayed transparent.
   `draw(_:)` painted the busy state with the previously-projected
   vector content against the *new* drawer, which then rendered the
   tiled raster with whatever partial coverage the shared tile cache
   currently held — and cached that partial result. Subsequent draws
   returned the stale cached render, so tiles arriving later never
   showed up. Fix: when busy, hold the prior frame on screen instead
   of running the buggy hybrid render. The new render lands once
   projection completes.

2. `prefetchTiles` waited for the entire tile set to land before
   notifying anyone, so there was no opportunity for "tiles pop in
   as they load". Now `prefetchTiles` accepts an `onTileLoaded`
   callback, invalidates the drawer's rendered-raster cache for the
   affected source on each tile arrival, and fires the callback so
   the caller can schedule a redraw. `BaseMapCache` gains
   `invalidateTiled(matching:)` for the cache drop.

`GeoMapView` rewires the async pipeline:
- Pre-warm with whatever tiles the shared cache already holds
  (typically faster — tiles from the prior projection cover the
  geographic overlap).
- Transition to `.finished` and `setNeedsDisplay` right away so the
  user sees the new projection immediately, with partial coverage
  if any.
- Then run tile prefetch as a background task group. Each tile
  arrival schedules a debounced (150ms trailing) background
  re-render + `setNeedsDisplay` — a burst of arrivals collapses
  into one render per debounce window so the main thread doesn't
  thrash.

Cancellation: a fresh projection change cancels the prior task,
which carries the prefetch with it; the debounced re-render also
checks `Task.isCancelled` so in-flight tile completions don't
clobber the new state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The library was reading the display's backing scale itself and using
that as `GeoDrawer.pixelDensity`, which forced ~4× the per-pixel work
on Retina even during interactive exploration where the user would
gladly accept a blurrier preview to keep things responsive. The
consumer (e.g. Cassini, where someone is dragging the reference
sliders or cycling projections) is the right place to make that
trade-off.

New `GeoMap.Quality` enum (declared on both UIViewRepresentable and
NSViewRepresentable variants of `GeoMap`):

  - `.draft`        → pixelDensity 0.5 (fast, soft)
  - `.standard`     → pixelDensity 1.0
  - `.matchDisplay` → backing scale factor of the current display (default)
  - `.custom(Double)` → explicit override

`GeoMapView` gets a `quality` property that nukes the drawer on
change. Its lazy drawer construction now reads
`resolvedPixelDensity`, which switches on `quality`; `.matchDisplay`
keeps the prior behaviour of picking up
`traitCollection.displayScale` / `window.backingScaleFactor`. The
existing `viewDidMoveToWindow`/`didMoveToWindow` drawer rebuilds
remain — they're how `.matchDisplay` repicks the scale when the
view moves between displays.

`GeoMap` (SwiftUI) gains a `quality:` init parameter and threads
through both `makeUI/NSView` and `updateUI/NSView`, matching the
existing pattern for `mapBackground` etc.

Cassini adds a "Render quality" segmented picker (Draft / Standard
/ Display), backed by an `@AppStorage`-persisted `RenderQuality`
enum that maps to `GeoMap.Quality`. The `.custom(Double)` case isn't
exposed in the demo UI (no good free-form-Double control) but
remains available to direct API consumers.

Default `.matchDisplay` keeps existing behaviour for callers that
don't specify, so test renders (which use `GeoDrawer` directly with
`pixelDensity = 1.0`) stay bit-identical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Returning early from `draw(_:)` while busy left `super.draw(_:)`
unrun, and NSView/UIView's default `isOpaque = false` then cleared
the backing store to white between every slider tick — the
projection-reference sliders flashed white continuously while
being dragged.

Instead of skipping `draw(_:)`, keep the *old* drawer alongside
the new one and repaint with that during the busy state. The old
drawer's projection, raster caches, and `_drawer.baseMapCache` are
all internally consistent — drawing its own previously-finished
ProjectedContent reproduces the prior frame exactly, no hybrid
"new drawer rendering old projected content" partial-coverage
cache bug.

Mechanics:

- New `_previousDrawer: GeoDrawer?` slot on `GeoMapView` (both
  UIKit + AppKit).
- Helper `cycleDrawer()` saves the current `_drawer` into
  `_previousDrawer` *if not already saved* before nulling
  `_drawer`. The save is sticky across burst slider drags so the
  frame painted during the burst is always the one from the last
  finished render, not an intermediate cancelled state.
- All sites that previously did `_drawer = nil` (projection /
  zoomTo / insets / quality / frame / `didMoveToWindow`) now call
  `cycleDrawer()` instead.
- On the next `.finished` transition the prior drawer is cleared
  (`self._previousDrawer = nil`), so it doesn't pin the prior
  raster caches once they're not useful.
- `draw(_:)` picks `_previousDrawer + previously` during busy and
  the current `drawer + finished` content otherwise.

Plus a 500 ms grey-tint cue: if the busy state outlasts that
window, a translucent grey wash is painted on top of the prior
frame so the user can tell the visible map is stale. Cancelled on
the `.finished` transition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`GeoMap+AppKit.swift` started the projection task with
`Task(priority: .high)`. Without `.detached`, the closure inherits
the enclosing context's actor isolation — and `NSView` is
main-actor — so every `await` continuation hopped back onto the
main thread. With nothing else blocking, the symptom was subtle: a
slider drag's gesture handler would set `model.refLng`, the
@published change would queue a SwiftUI body invalidation, but the
projection task body would then immediately pick up the same run-
loop tick and run to completion on main before the slider's
binding got a chance to refresh the thumb. Result: the slider
thumb only advanced when the map finished updating, instead of
moving smoothly under the user's drag.

The UIKit side already used `Task.detached(priority: .high)` for
exactly this reason; AppKit just diverged. Match it.

The grey-tint timer (which the user noted "isn't working") was the
same underlying cause — the busy state transitioned to finished on
the same run-loop tick, so the 500 ms timer never fired before
cancel. With the task detached, transitions that actually take
>500 ms (e.g. the first OSM fetch + render) leave the busy state
visible long enough for the timer to flip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The `prefetchTiles` per-tile-loaded callback was opaque — consumers
saw an opaque "something changed" signal but couldn't tell how far
along a fetch was or whether any tiles had failed. That made it
impossible to build a sensible loading indicator on top.

Replace the callback with a progress one:

  await drawer.prefetchTiles(for: tiled) { progress in
    // TileFetchProgress: total, loaded, failed, fraction, isComplete
  }

`prefetchTiles` is now non-throwing — per-tile transport and decode
errors are caught and counted into `progress.failed` instead of
bubbling up and tearing down the whole fetch, so a flaky tile
server gives a partial-coverage render plus a count rather than a
silent total miss. Cancellation (parent task) is still honoured.

`GeoMap` (both UIKit + AppKit) gains an `onTileProgress:
((TileFetchProgress) -> Void)?` init parameter; `GeoMapView` exposes
a matching property and forwards every progress snapshot to it from
the main actor. The initial snapshot fires at the start of each
fetch with `loaded == 0` (or `loaded == n` for tiles already cached
from a prior projection), then once per tile completion, then a
final `isComplete` snapshot.

Cassini wires it up:

- `Model.tileProgress: TileFetchProgress?` is set from the callback
  and cleared when `baseMapMode` changes.
- New `TileProgressOverlay` view in the bottom-right corner: a
  circular `ProgressView` while loading, then a yellow
  `exclamationmark.triangle.fill` if any tiles failed, alongside
  the existing attribution label.
- On macOS the warning icon is hover-aware: hovering routes the
  failed count to `MapStatusBar`, which now prefers the
  "N tiles failed to load" message over the coord-hover and
  locked-coord states for the duration of the hover.
- iOS gets the indicator + warning icon without the hover handler.

Tests all pass; the bit-identical Danseiji IV sample render is
unaffected (the sample doesn't use a tiled base map).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The "keep the prior frame on screen during busy renders" change
keyed on `_previousDrawer`, which is only set when the drawer is
cycled (projection / size / zoomTo / insets / quality / frame /
display change). It is *not* set when only `contents` changes — and
contents-only is the path the consuming app hits whenever it
toggles a layer's visibility, flips base-map mode, or toggles the
OSM tile source. Result: those changes left `_previousDrawer` nil
while `projectProgress` was still in `.busy(_, .some(prev))`, and
`draw(_:)` returned early — blank view, no progress, looked like
nothing was working at all.

Treat the two cases distinctly:
  - drawer cycled → use `_previousDrawer` (different projection
    space, can't use new drawer with old projected content).
  - contents change only → use `_drawer` (still the same drawer,
    same projection — rendering the previously-projected content
    against it is correct, identical to the last good frame).

Same fix on both UIKit and AppKit sides. The original partial-tile-
cache bug stays fixed because the projection-change case still
falls through to `_previousDrawer`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The tiled renderer was hitting `tileCache.get(key)` per output
pixel — that's `NSLock.lock()` + a `[TileCacheKey: TileImage]`
dictionary lookup + a hash of `TileCacheKey` (which carries
`sourceID: AnyHashable` whose hashing boxes through dynamic
dispatch) for every one of ~320K pixels at draft quality. With
all tiles already in the shared cache the dominant cost wasn't
the actual projection math or the byte reads — it was 320K lock
acquisitions and AnyHashable hashes.

Single-image base maps were fast because their sampler captures a
flat `UnsafeBufferPointer<UInt8>` once before the loop and indexes
directly. Mirror that:

  - Snapshot the entire `n × n` tile grid (where `n = 1 << zoom`)
    into a flat `[TileImage?]` array before kicking off the
    `concurrentPerform` row sweep. One `TileCache.get` per tile,
    not per pixel. For a typical zoom-4 OSM render at 1600pt this
    is 256 lock acquisitions instead of ~320K.
  - Drop `tileCache`, `sourceID`, and `zoom` from
    `TiledRasterContext`. The per-pixel path becomes a single
    bounds-checked array load (`tileGrid[ty * n + tx]`) plus the
    existing sampler.

The pre-resolution adds a small fixed cost (~256 lookups for the
common case, ~1K at z=5, ~4K at z=6) but the per-pixel work drops
by roughly an order of magnitude. Cached-tile OSM renders should
now scale with the projection math rather than with `NSLock`
throughput.

Correctness: array index is the same `(tx, ty)` that the old code
used to build the cache key, so the same tiles get picked. Tiles
not yet fetched are `nil` entries — sampled-as-transparent, same
as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The flashing-grey-overlay symptom traced back to a re-render loop:

1. A tile fails to fetch.
2. `prefetchTiles` calls `onProgress` with the new failed count.
3. Cassini's `onTileProgress` closure sets `model.tileProgress`
   (`@Published`).
4. SwiftUI invalidates and re-evaluates the view body.
5. `GeoMap.updateNSView` re-runs and assigns
   `view.projection = projection` etc. — same value as before, but
   no `oldValue` check, so `didSet` runs the full
   `cycleDrawer + invalidateProjectedContents + setNeedsDisplay`
   pipeline.
6. The new projection task starts a 500 ms stale-render timer.
7. The cancelled task's tile retry fires after the network
   timeout (~1 s).
8. The retry's per-tile callback fires with the same failure →
   goto step 3.

So as long as any tile was failing, the view stayed in a
self-perpetuating retry loop with the grey overlay flashing
every ~1 s.

Add `oldValue` equality short-circuits to every property on
`GeoMapView` whose `didSet` invalidates:

- AppKit: `contents`, `projection`, `zoomTo`, `insets`, `frame`
  all lacked checks. Added.
- UIKit: `projection` was the missing one. Added.

`Projection` is a protocol type and not `Equatable`, so the
projection check uses `type(of:) == type(of:)` plus `reference`
equality — sufficient for the de-dupe case (Cassini's projection
picker and reference sliders both go through this path, and a
phi-one slider would too if surfaced). Other consumers with
projections carrying additional state will get a false-positive
"unchanged" only if they keep the type + reference the same,
which still matches the property's intent.

`Projections.Equirectangular` has a `phiOne` field beyond
`reference`, but Cassini doesn't surface a slider for it; an
explicit `phiOne` change would currently miss the redraw. If we
add a phi-one slider, the projection comparison would need to
become `Equatable`-conformant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`tilesNeeded` used to stride the output canvas at 16 points and
gather the tile each sample landed on. That's fine for cylindrical
projections (Mercator, Equirectangular), where a 16-pt canvas step
maps to a smooth, roughly constant step in source-canvas space. It
breaks for projections where the canvas-to-source mapping is
*highly* non-linear — Danseiji IV with the reference pinned at the
pole was the user-visible case: near the canvas centre (the pole)
each 16-pt step spans a huge longitude range, so the stride sampler
skipped entire wedges of source tiles. The prefetch never asked for
those tiles, the renderer's per-pixel sweep hit them, the snapshot
returned nil, and the user saw a "stuck" partial render with
wedge-shaped gaps that switching from Draft to Display made worse
(more pixels rendered → more distinct tiles hit → more gaps).

Two intermediate fixes (denser canvas-stride and source-grid
sampling with 6×6 then 17×17 samples per tile) still missed isolated
tiles. The actually-correct algorithm is the renderer's own
per-pixel sweep at the drawer's `pixelDensity`. It guarantees no
sampling artefact — any tile the renderer can touch is in the set.

Implementation:

- `tilesNeeded` now iterates `(width * pixelDensity, height *
  pixelDensity)` canvas pixels via `concurrentPerform`. Each row
  writes its own set; we union them at the end.
- Density is taken from the drawer (was a 2.0 constant), so Draft
  doesn't pay Display's iteration cost. A quality switch correctly
  re-prefetches the additional tiles Display needs that Draft
  didn't.
- Cost: ~50 ms parallel for a 1500×1200-pt canvas at Display
  density; sub-10 ms at Draft density. The result is cache-key-
  stable across the drawer's lifetime, so prefetch + pre-warm + per-
  tile-arrival re-renders pay it once.

Adds `TilePrefetchCoverageTests` with five mock-driven tests
covering the prefetch + render pipeline (fast / slow / failing
tile buckets, partial cache, tile-arrival cache invalidation, end-
to-end), plus a regression test for the user's scenario — Danseiji
IV pole-centered, Draft-then-Display quality switch — that checks
no *interior* gap pixels appear. Boundary-edge transparency at the
projection's mapBounds is expected behaviour, not a bug, so the
test ignores those.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@nighthawk nighthawk added the enhancement New feature or request label May 11, 2026
@nighthawk nighthawk self-assigned this May 11, 2026
nighthawk and others added 3 commits May 11, 2026 16:00
`Sources/GeoDrawer/apple/` is supposed to hold only code that's
Apple-only by nature. Several pure-Swift pieces had crept in: the
`Sampling` enum, the `BaseMap`/`BaseMapImage` value+class shells,
the `TileCache`/`TileCacheKey` pair, and the entire `tilesNeeded`
/ `prefetchTiles` / `resolvedZoom` machinery. Linux's CI caught the
inconsistency the hard way — `TiledBaseMap.swift` (top-level)
referenced `BaseMap.Sampling` which lived inside the CG-gated
`apple/GeoDrawer+BaseMap.swift`, so Linux builds failed with
"cannot find type 'BaseMap' in scope".

Reorg:

- New `Sources/GeoDrawer/Sampling.swift` — `GeoDrawer.Sampling`
  (`nearest`/`bilinear`). Was `BaseMap.Sampling`; both
  single-image and tiled base maps use it now.
- New `Sources/GeoDrawer/BaseMap.swift` — `BaseMap` struct +
  `BaseMapImage` class (with a pure-Swift `init?(width:height:pixels:)`
  for callers like swift-png on Linux). The `Hashable` conformance
  lives here too.
- `apple/BaseMap+CoreGraphics.swift` (renamed from
  `apple/GeoDrawer+BaseMap.swift`) keeps only the CGImage decoder
  on `BaseMapImage` and the `CGImage`/`UIImage`/`NSImage`
  initialisers on `BaseMap`.
- New `Sources/GeoDrawer/TileCache.swift` — `TileCache` class +
  `TileCacheKey`. Pure Swift, NSLock-serialised.
- New `Sources/GeoDrawer/GeoDrawer+TilePrefetch.swift` —
  `resolvedZoom`, `tilesNeeded`, `prefetchTiles`, and the
  `TileFetchOutcome` private struct. The previous version called
  `baseMapCache.invalidateTiled` directly; now there's an
  `invalidateRenderedTiledRaster(matching:)` shim that's a no-op
  on Linux and clears the apple-side raster cache when CG is
  present.
- `apple/GeoDrawer+TiledBaseMap.swift` keeps only the CG-bound
  renderer (`renderedTiledBaseMap`, `renderTiledBaseMap`,
  `TiledRasterCacheKey`, `TiledRasterContext`).
- `GeoDrawer.swift`: `tileCache` and `pixelDensity` properties
  moved out of the `#if canImport(CoreGraphics)` block — both are
  used by the pure-Swift prefetch path. `baseMapCache` stays
  gated (it caches `CGImage`).

`TiledBaseMap.sampling` and `BaseMap.sampling` now both use
`GeoDrawer.Sampling`. No behaviour change on Apple; Linux now
compiles. All 86 tests still pass; both Cassini targets build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two Linux-only compile errors from the previous reorg:

1. `URLSession`, `URLRequest` aren't in `Foundation` on Linux — they
   live in `FoundationNetworking`. Add a conditional import in
   `URLTemplateTileSource.swift` (`#if canImport(FoundationNetworking)`).

2. `Array.withUnsafeBufferPointer { $0.baseAddress }` returns an
   `Optional<UnsafePointer>` on Linux's swift-corelibs-foundation
   (Apple Foundation's overload returns non-optional via implicit
   unwrapping). Bind the optional with `if let base` before calling
   `memcpy`; the `count > 0` precondition guarantees it's non-nil
   in practice.

Apple builds + all 86 tests unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The async `URLSession.data(for:)` overload landed on
`swift-corelibs-foundation` with Swift 6.0; Linux Swift 5.x
(including 5.10) doesn't have it. Apple platforms and Linux 6.0+
both keep the modern path.

For the legacy build only, wrap
`dataTask(with:completionHandler:)` in a
`withCheckedThrowingContinuation` — same semantics, just gluing the
closure-based API onto async/await. Gated on
`!canImport(FoundationNetworking) || swift(>=6.0)`.

Also mark `URLTemplateTileSource` as `@unchecked Sendable` because
`URLSession` doesn't conform on Linux yet — the suppression is
sound (`URLSession` itself is documented thread-safe).

Co-Authored-By: Claude Opus 4.7 <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.

Allow drawing image-based base maps

2 participants