diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index 8eb1f998..60775d04 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -16,6 +16,7 @@ import { PathLayer } from "@deck.gl/layers"; import type { RasterLayerProps, RasterModule, + TileMetadata, } from "@developmentseed/deck.gl-raster"; import { RasterLayer, @@ -407,7 +408,12 @@ export class COGLayer< inverseFrom3857: ReprojectionFns["inverseReproject"], ): Layer | LayersList | null { const { maxError, debug, debugOpacity } = this.props; - const { tile } = props; + + // Cast to include TileMetadata from raster-tileset's `getTileMetadata` + // method. + // TODO: implement generic handling of tile metadata upstream in TileLayer + const tile = props.tile as Tile2DHeader> & + TileMetadata; if (!props.data) { return null; @@ -495,35 +501,34 @@ export class COGLayer< } if (debug) { - // Get projected bounds from tile data - // getTileMetadata returns data that includes projectedBounds - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const projectedBounds: { - topLeft: [number, number]; - topRight: [number, number]; - bottomLeft: [number, number]; - bottomRight: [number, number]; - } = (tile as any)?.projectedBounds; - - if (!projectedBounds || !tms) { + const { projectedCorners } = tile; + + if (!projectedCorners || !tms) { return []; } - // Project bounds from image CRS to WGS84 - const { topLeft, topRight, bottomLeft, bottomRight } = projectedBounds; - + // Create a closed path in WGS84 projection around the tile bounds + // + // The tile has a `bbox` field which is already the bounding box in WGS84, + // but that uses `transformBounds` and densifies edges. So the corners of + // the bounding boxes don't line up with each other. + // + // In this case in the debug mode, it looks better if we ignore the actual + // non-linearities of the edges and just draw a box connecting the + // reprojected corners. In any case, the _image itself_ will be densified + // on the edges as a feature of the mesh generation. + const { topLeft, topRight, bottomRight, bottomLeft } = projectedCorners; const topLeftWgs84 = forwardTo4326(topLeft[0], topLeft[1]); const topRightWgs84 = forwardTo4326(topRight[0], topRight[1]); const bottomRightWgs84 = forwardTo4326(bottomRight[0], bottomRight[1]); const bottomLeftWgs84 = forwardTo4326(bottomLeft[0], bottomLeft[1]); - // Create a closed path around the tile bounds const path = [ topLeftWgs84, topRightWgs84, bottomRightWgs84, bottomLeftWgs84, - topLeftWgs84, // Close the path + topLeftWgs84, ]; layers.push( diff --git a/packages/deck.gl-raster/package.json b/packages/deck.gl-raster/package.json index 17d68e15..09eda4bf 100644 --- a/packages/deck.gl-raster/package.json +++ b/packages/deck.gl-raster/package.json @@ -62,6 +62,7 @@ "dependencies": { "@developmentseed/affine": "workspace:^", "@developmentseed/morecantile": "workspace:^", + "@developmentseed/proj": "workspace:^", "@developmentseed/raster-reproject": "workspace:^", "@math.gl/core": "^4.1.0", "@math.gl/culling": "^4.1.0", diff --git a/packages/deck.gl-raster/src/index.ts b/packages/deck.gl-raster/src/index.ts index f8127f4d..ec71436a 100644 --- a/packages/deck.gl-raster/src/index.ts +++ b/packages/deck.gl-raster/src/index.ts @@ -1,6 +1,7 @@ export type { RasterModule } from "./gpu-modules/types.js"; export type { RasterLayerProps } from "./raster-layer.js"; export { RasterLayer } from "./raster-layer.js"; +export type { TileMetadata } from "./raster-tileset/index.js"; export { TileMatrixSetTileset } from "./raster-tileset/index.js"; import { __TEST_EXPORTS as traversalTestExports } from "./raster-tileset/raster-tile-traversal.js"; diff --git a/packages/deck.gl-raster/src/raster-tileset/index.ts b/packages/deck.gl-raster/src/raster-tileset/index.ts index f96ab6bd..4ae6612d 100644 --- a/packages/deck.gl-raster/src/raster-tileset/index.ts +++ b/packages/deck.gl-raster/src/raster-tileset/index.ts @@ -1 +1,2 @@ +export type { TileMetadata } from "./raster-tileset-2d.js"; export { TileMatrixSetTileset } from "./raster-tileset-2d.js"; diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 91ae842f..48aa9ffe 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -6,23 +6,77 @@ */ import type { Viewport } from "@deck.gl/core"; -import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; +import type { + GeoBoundingBox, + _Tileset2DProps as Tileset2DProps, +} from "@deck.gl/geo-layers"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import * as affine from "@developmentseed/affine"; -import type { BoundingBox, TileMatrixSet } from "@developmentseed/morecantile"; +import type { + BoundingBox, + TileMatrix, + TileMatrixSet, +} from "@developmentseed/morecantile"; import { tileTransform } from "@developmentseed/morecantile"; +import { transformBounds } from "@developmentseed/proj"; import type { Matrix4 } from "@math.gl/core"; - import { getTileIndices } from "./raster-tile-traversal"; import type { Bounds, CornerBounds, Point, + ProjectedBoundingBox, ProjectionFunction, TileIndex, ZRange, } from "./types"; +/** Type returned by `getTileMetadata` */ +export type TileMetadata = { + /** + * **Axis-aligned** bounding box of the tile in **WGS84 coordinates**. + */ + bbox: GeoBoundingBox; + + /** + * **Axis-aligned** bounding box of the tile in **projected coordinates**. + */ + projectedBbox: ProjectedBoundingBox; + + /** + * "Rotated" bounding box of the tile in **projected coordinates**, + * represented as four corners. + * + * This preserves rotation/skew information that would be lost in the + * axis-aligned bbox. + */ + projectedCorners: { + topLeft: Point; + topRight: Point; + bottomLeft: Point; + bottomRight: Point; + }; + + /** + * Tile width in pixels. + * + * Note this may differ between levels in some TileMatrixSets. + */ + tileWidth: number; + + /** + * Tile height in pixels. + * + * Note this may differ between levels in some TileMatrixSets. + */ + tileHeight: number; + + /** + * A reference to the underlying TileMatrix. + */ + tileMatrix: TileMatrix; +}; + /** * A generic tileset implementation organized according to the OGC * [TileMatrixSet](https://docs.ogc.org/is/17-083r4/17-083r4.html) @@ -109,11 +163,25 @@ export class TileMatrixSetTileset extends Tileset2D { const currentOverview = this.tms.tileMatrices[index.z]!; const parentOverview = this.tms.tileMatrices[index.z - 1]!; - const decimation = currentOverview.cellSize / parentOverview.cellSize; + // Decimation is the number of child tiles that fit across one parent tile. + // Must use tile footprint (cellSize × tileWidth/Height), not cellSize alone, + // because tileWidth can change between levels (e.g. the last Sentinel-2 + // overview doubles tileWidth while halving cellSize, giving a 1:1 spatial + // mapping where decimation = 1). + const parentFootprintX = parentOverview.cellSize * parentOverview.tileWidth; + const parentFootprintY = + parentOverview.cellSize * parentOverview.tileHeight; + const currentFootprintX = + currentOverview.cellSize * currentOverview.tileWidth; + const currentFootprintY = + currentOverview.cellSize * currentOverview.tileHeight; + + const decimationX = parentFootprintX / currentFootprintX; + const decimationY = parentFootprintY / currentFootprintY; return { - x: Math.floor(index.x / decimation), - y: Math.floor(index.y / decimation), + x: Math.floor(index.x / decimationX), + y: Math.floor(index.y / decimationY), z: index.z - 1, }; } @@ -122,7 +190,7 @@ export class TileMatrixSetTileset extends Tileset2D { return index.z; } - override getTileMetadata(index: TileIndex): Record { + override getTileMetadata(index: TileIndex): TileMetadata { const { x, y, z } = index; const { tileMatrices } = this.tms; const tileMatrix = tileMatrices[z]!; @@ -138,7 +206,7 @@ export class TileMatrixSetTileset extends Tileset2D { // Return the projected bounds as four corners // This preserves rotation/skew information - const projectedBounds = { + const projectedCorners = { topLeft, topRight, bottomLeft, @@ -146,16 +214,36 @@ export class TileMatrixSetTileset extends Tileset2D { }; // Also compute axis-aligned bounding box for compatibility - const bounds: Bounds = [ + const projectedBounds: Bounds = [ Math.min(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), Math.min(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), Math.max(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), Math.max(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), ]; + // deck.gl's Tile2DHeader uses `bbox` (GeoBoundingBox) for screen-space + // culling in filterSubLayer → isTileVisible. Without this, all tiles + // would pass (or fail) the cull-rect test and the refinementStrategy + // (best-available) would not show parent tiles correctly. + const [west, south, east, north] = transformBounds( + this.projectTo4326, + ...projectedBounds, + ); + return { - bounds, - projectedBounds, + bbox: { + west, + south, + east, + north, + }, + projectedBbox: { + left: projectedBounds[0], + bottom: projectedBounds[1], + right: projectedBounds[2], + top: projectedBounds[3], + }, + projectedCorners, tileWidth, tileHeight, tileMatrix, diff --git a/packages/deck.gl-raster/src/raster-tileset/types.ts b/packages/deck.gl-raster/src/raster-tileset/types.ts index 597d4704..802289f9 100644 --- a/packages/deck.gl-raster/src/raster-tileset/types.ts +++ b/packages/deck.gl-raster/src/raster-tileset/types.ts @@ -8,14 +8,15 @@ export type GeoBoundingBox = { east: number; south: number; }; -export type NonGeoBoundingBox = { + +export type ProjectedBoundingBox = { left: number; top: number; right: number; bottom: number; }; -export type TileBoundingBox = NonGeoBoundingBox | GeoBoundingBox; +export type TileBoundingBox = ProjectedBoundingBox | GeoBoundingBox; export type TileLoadProps = { index: TileIndex; diff --git a/packages/deck.gl-raster/tests/tileset-refinement.test.ts b/packages/deck.gl-raster/tests/tileset-refinement.test.ts new file mode 100644 index 00000000..c62a3896 --- /dev/null +++ b/packages/deck.gl-raster/tests/tileset-refinement.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for TileMatrixSetTileset refinement strategy. + * + * Verifies that when zooming in, parent tiles already in cache remain visible + * while child tiles are still loading (best-available / no-flash behavior). + */ + +import type { Viewport } from "@deck.gl/core"; +import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; +import type { TileMatrixSet } from "@developmentseed/morecantile"; +import { describe, expect, it } from "vitest"; +import { TileMatrixSetTileset } from "../src/raster-tileset/raster-tileset-2d.js"; +import type { TileIndex } from "../src/raster-tileset/types.js"; + +// --------------------------------------------------------------------------- +// Minimal TileMatrixSet fixture (2 zoom levels, EPSG:4326 image space) +// --------------------------------------------------------------------------- +// +// z=0: 1×1 tile covering the whole image +// z=1: 2×2 tiles each covering a quadrant +// +// cellSize ratio is 2 (standard power-of-2 pyramid). + +const MOCK_TMS: TileMatrixSet = { + id: "test", + crs: { uri: "http://www.opengis.net/def/crs/EPSG/0/4326" }, + boundingBox: { + lowerLeft: [0, 0], + upperRight: [1, 1], + }, + tileMatrices: [ + { + id: "0", + scaleDenominator: 1000, + cellSize: 0.02, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [0, 1], + tileWidth: 64, + tileHeight: 64, + matrixWidth: 1, + matrixHeight: 1, + }, + { + id: "1", + scaleDenominator: 500, + cellSize: 0.01, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [0, 1], + tileWidth: 64, + tileHeight: 64, + matrixWidth: 2, + matrixHeight: 2, + }, + ], +}; + +// Identity projection (image CRS == WGS84 for this fixture) +const identity = (x: number, y: number): [number, number] => [x, y]; + +// --------------------------------------------------------------------------- +// Test-friendly subclass: override getTileIndices to control which tiles are +// "visible" at each simulated zoom level without needing a real Viewport. +// --------------------------------------------------------------------------- + +class ControlledTileset extends TileMatrixSetTileset { + private _forcedIndices: TileIndex[] = []; + + setForcedIndices(indices: TileIndex[]) { + this._forcedIndices = indices; + } + + override getTileIndices( + _opts: Parameters[0], + ): TileIndex[] { + return this._forcedIndices; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Minimal mock viewport whose `equals()` always returns false so that + * `Tileset2D.update()` always re-evaluates the tile selection. + */ +function makeViewport(): Viewport { + return { + equals: () => false, + resolution: undefined, + } as unknown as Viewport; +} + +/** + * Build a ControlledTileset with a never-resolving getTileData so that + * tiles stay in the "loading" state for the duration of the test. + */ +function makeTileset(opts?: Partial): ControlledTileset { + return new ControlledTileset( + { + getTileData: () => new Promise(() => {}), // never resolves + ...opts, + }, + MOCK_TMS, + { projectTo4326: identity, projectTo3857: identity }, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("TileMatrixSetTileset – best-available refinement", () => { + it("marks the z=0 parent visible while z=1 children are loading", async () => { + const tileset = makeTileset(); + + // --- Step 1: simulate viewport at z=0 (one tile covers the whole image) + tileset.setForcedIndices([{ x: 0, y: 0, z: 0 }]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // The z=0 tile is now selected and loading. + const parentTile = tileset.tiles.find( + (t) => t.index.x === 0 && t.index.y === 0 && t.index.z === 0, + ); + + if (!parentTile) { + expect.fail("parent tile should be in cache"); + } + + // Simulate the tile loading successfully by injecting content. + parentTile.content = { width: 64, height: 64 }; + // @ts-expect-error _isLoaded is private + parentTile._isLoaded = true; + + // Run another update so the tileset sees the loaded state. + tileset.setForcedIndices([{ x: 0, y: 0, z: 0 }]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + expect(parentTile!.isLoaded).toBe(true); + expect(parentTile!.isVisible).toBe(true); + + // --- Step 2: zoom in — z=1 tiles are now selected, still loading + tileset.setForcedIndices([ + { x: 0, y: 0, z: 1 }, + { x: 1, y: 0, z: 1 }, + { x: 0, y: 1, z: 1 }, + { x: 1, y: 1, z: 1 }, + ]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // All four z=1 tiles should be in cache and loading (never-resolve getData) + const childTiles = tileset.tiles.filter((t) => t.index.z === 1); + expect(childTiles).toHaveLength(4); + for (const child of childTiles) { + expect(child.isLoaded, `child ${child.id} should still be loading`).toBe( + false, + ); + } + + // The z=0 parent should still be visible as a placeholder. + expect( + parentTile!.isVisible, + "parent tile should remain visible while children are loading", + ).toBe(true); + + // The loading child tiles should NOT be visible (no content yet). + for (const child of childTiles) { + expect( + child.isVisible, + `loading child ${child.id} should not be visible`, + ).toBe(false); + } + }); + + it("hides the parent once all children have loaded", async () => { + const tileset = makeTileset(); + + // Load z=0 parent + tileset.setForcedIndices([{ x: 0, y: 0, z: 0 }]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + const parentTile = tileset.tiles.find((t) => t.index.z === 0)!; + parentTile.content = { width: 64, height: 64 }; + // @ts-expect-error _isLoaded is private + parentTile._isLoaded = true; + + // Zoom in + tileset.setForcedIndices([ + { x: 0, y: 0, z: 1 }, + { x: 1, y: 0, z: 1 }, + { x: 0, y: 1, z: 1 }, + { x: 1, y: 1, z: 1 }, + ]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // Load all children + const childTiles = tileset.tiles.filter((t) => t.index.z === 1); + for (const child of childTiles) { + child.content = { width: 64, height: 64 }; + // @ts-expect-error _isLoaded is private + child._isLoaded = true; + } + + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // Now children are loaded and selected, so the parent should not be visible. + expect( + parentTile.isVisible, + "parent should not be visible once children are loaded", + ).toBe(false); + + for (const child of childTiles) { + expect( + child.isVisible, + `loaded child ${child.id} should be visible`, + ).toBe(true); + } + }); + + it("getParentIndex correctly maps z=1 children to z=0 parent (2:1 ratio)", () => { + const tileset = makeTileset(); + + // MOCK_TMS: z=0 cellSize=0.02 tileWidth=64, z=1 cellSize=0.01 tileWidth=64 + // Footprint ratio = (0.02*64) / (0.01*64) = 2 → each parent covers 2×2 children. + const parent00 = tileset.getParentIndex({ x: 0, y: 0, z: 1 }); + const parent10 = tileset.getParentIndex({ x: 1, y: 0, z: 1 }); + const parent01 = tileset.getParentIndex({ x: 0, y: 1, z: 1 }); + const parent11 = tileset.getParentIndex({ x: 1, y: 1, z: 1 }); + + expect(parent00).toEqual({ x: 0, y: 0, z: 0 }); + expect(parent10).toEqual({ x: 0, y: 0, z: 0 }); + expect(parent01).toEqual({ x: 0, y: 0, z: 0 }); + expect(parent11).toEqual({ x: 0, y: 0, z: 0 }); + }); + + it("getParentIndex correctly handles 1:1 spatial mapping when tileWidth doubles", () => { + // Sentinel-2-like TMS: last overview doubles tileWidth while halving cellSize, + // so parent and child tiles cover the exact same spatial footprint. + // decimation should be 1 (each child maps to the parent at the same x,y). + const sentinel2TMS: TileMatrixSet = { + id: "s2", + crs: { uri: "http://www.opengis.net/def/crs/EPSG/0/32618" }, + boundingBox: { + lowerLeft: [499980, 4490220], + upperRight: [609780, 4600020], + }, + tileMatrices: [ + // z=3: cellSize=20, tileWidth=512 → footprint = 10240m + { + id: "3", + scaleDenominator: 71428.57, + cellSize: 20, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [499980, 4600020], + tileWidth: 512, + tileHeight: 512, + matrixWidth: 11, + matrixHeight: 11, + }, + // z=4: cellSize=10, tileWidth=1024 → footprint = 10240m (same as z=3!) + { + id: "4", + scaleDenominator: 35714.29, + cellSize: 10, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [499980, 4600020], + tileWidth: 1024, + tileHeight: 1024, + matrixWidth: 11, + matrixHeight: 11, + }, + ], + }; + + const tileset = new ControlledTileset( + { getTileData: () => new Promise(() => {}) }, + sentinel2TMS as any, + { projectTo4326: identity, projectTo3857: identity }, + ); + + // Every z=4 tile should map 1:1 to the z=3 tile at the same x,y. + expect(tileset.getParentIndex({ x: 0, y: 0, z: 1 })).toEqual({ + x: 0, + y: 0, + z: 0, + }); + expect(tileset.getParentIndex({ x: 5, y: 5, z: 1 })).toEqual({ + x: 5, + y: 5, + z: 0, + }); + expect(tileset.getParentIndex({ x: 10, y: 10, z: 1 })).toEqual({ + x: 10, + y: 10, + z: 0, + }); + }); + + it("getTileMetadata includes a bbox in GeoBoundingBox format", () => { + const tileset = makeTileset(); + + const meta = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + + expect(meta.bbox).toBeDefined(); + const { bbox } = meta; + expect(typeof bbox.west).toBe("number"); + expect(typeof bbox.south).toBe("number"); + expect(typeof bbox.east).toBe("number"); + expect(typeof bbox.north).toBe("number"); + expect(bbox.west).toBeLessThan(bbox.east); + expect(bbox.south).toBeLessThan(bbox.north); + }); +}); diff --git a/packages/deck.gl-raster/tsconfig.build.json b/packages/deck.gl-raster/tsconfig.build.json index 0e89e614..9dba05a1 100644 --- a/packages/deck.gl-raster/tsconfig.build.json +++ b/packages/deck.gl-raster/tsconfig.build.json @@ -9,6 +9,7 @@ "exclude": ["node_modules", "dist", "tests"], "references": [ { "path": "../morecantile/tsconfig.build.json" }, + { "path": "../proj/tsconfig.build.json" }, { "path": "../raster-reproject/tsconfig.build.json" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 992654d0..e9842e03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: '@developmentseed/morecantile': specifier: workspace:^ version: link:../morecantile + '@developmentseed/proj': + specifier: workspace:^ + version: link:../proj '@developmentseed/raster-reproject': specifier: workspace:^ version: link:../raster-reproject