Feature/bitmap drawing support#42
Open
nighthawk wants to merge 27 commits into
Open
Conversation
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>
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Closes #7