Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Affine } from "@developmentseed/affine";
import * as affine from "@developmentseed/affine";
import { overlappingTileSpan } from "./tile-span.js";
import type { RasterTilesetLevel } from "./tileset-interface.js";
import type { Bounds, Corners, ProjectionFunction } from "./types.js";

Expand Down Expand Up @@ -135,14 +136,17 @@ export class AffineTilesetLevel implements RasterTilesetLevel {
const maxColIdx = this.matrixWidth - 1;
const maxRowIdx = this.matrixHeight - 1;

const [firstCol, lastCol] = overlappingTileSpan(pixMinX / tw, pixMaxX / tw);
const [firstRow, lastRow] = overlappingTileSpan(pixMinY / th, pixMaxY / th);

// Asymmetric clamping: only clamp minCol/minRow up from below and
// maxCol/maxRow down from above. If the bbox lies entirely outside the
// array, this produces an empty range (min > max) so the consumer's
// `for (i = min; i <= max; i++)` loop does nothing.
const minCol = Math.max(0, Math.floor(pixMinX / tw));
const maxCol = Math.min(maxColIdx, Math.floor(pixMaxX / tw));
const minRow = Math.max(0, Math.floor(pixMinY / th));
const maxRow = Math.min(maxRowIdx, Math.floor(pixMaxY / th));
const minCol = Math.max(0, firstCol);
const maxCol = Math.min(maxColIdx, lastCol);
const minRow = Math.max(0, firstRow);
const maxRow = Math.min(maxRowIdx, lastRow);

return { minCol, maxCol, minRow, maxRow };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,28 @@ export function getTileIndices(
root.getSelected(selectedNodes);
}

return selectedNodes;
return uniqueTiles(selectedNodes);
}

/**
* Drop repeated tile indices, keeping the first node for each `z/x/y`.
*
* Each parent creates its own child nodes, so a tile that is a child of two
* parents can be selected once through each. That happens when pyramid levels
* are not aligned (e.g. COG overviews with odd sizes), where a child tile can
* straddle two parents, and in TileMatrixSets whose rounded cellSizes push a
* parent's edge past a child's edge.
*/
function uniqueTiles(nodes: RasterTileNode[]): RasterTileNode[] {
const seen = new Set<string>();
return nodes.filter((node) => {
const key = `${node.z}/${node.x}/${node.y}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}

/**
Expand Down
18 changes: 12 additions & 6 deletions packages/deck.gl-raster/src/raster-tileset/tile-matrix-set.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as affine from "@developmentseed/affine";
import type { TileMatrix, TileMatrixSet } from "@developmentseed/morecantile";
import { tileTransform, xy_bounds } from "@developmentseed/morecantile";
import { overlappingTileSpan } from "./tile-span.js";
import type {
RasterTilesetDescriptor,
RasterTilesetLevel,
Expand Down Expand Up @@ -70,7 +71,9 @@ class TileMatrixAdaptor implements RasterTilesetLevel {
* 1. Treating each TileMatrix as an independent, axis-aligned grid in CRS space
* 2. Mapping the parent tile's CRS bounding box into the child grid
* 3. Returning the inclusive range of child tile indices whose spatial extent
* intersects the parent tile
* overlaps the parent tile. A child that only shares an edge with the
* parent is excluded, so in an aligned pyramid such as WebMercatorQuad
* each parent has exactly its 2×2 children.
*
* The returned indices are clamped to the valid extents of the child matrix
* (`[0, matrixWidth)` and `[0, matrixHeight)`).
Expand Down Expand Up @@ -111,11 +114,14 @@ class TileMatrixAdaptor implements RasterTilesetLevel {
const originY = pointOfOrigin[1];

// Convert CRS bounds → tile indices
let minCol = Math.floor((projectedMinX - originX) / childTileWidthCRS);
let maxCol = Math.floor((projectedMaxX - originX) / childTileWidthCRS);

let minRow = Math.floor((originY - projectedMaxY) / childTileHeightCRS);
let maxRow = Math.floor((originY - projectedMinY) / childTileHeightCRS);
let [minCol, maxCol] = overlappingTileSpan(
(projectedMinX - originX) / childTileWidthCRS,
(projectedMaxX - originX) / childTileWidthCRS,
);
let [minRow, maxRow] = overlappingTileSpan(
(originY - projectedMaxY) / childTileHeightCRS,
(originY - projectedMinY) / childTileHeightCRS,
);

// Clamp to matrix bounds
minCol = Math.max(0, Math.min(matrixWidth - 1, minCol));
Expand Down
39 changes: 39 additions & 0 deletions packages/deck.gl-raster/src/raster-tileset/tile-span.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* How close, as a fraction of a tile, a bound must be to a tile edge to count
* as lying on it.
*
* In an aligned pyramid (WebMercatorQuad, or COG overviews that exactly halve)
* each parent tile edge falls on a child tile edge, but the CRS arithmetic
* that computes it can land a few 1e-9 of a tile to either side. 1e-6 of a
* tile is well above that error and well below a pixel.
*
* This does not absorb cellSizes that a TileMatrixSet rounds to a few
* significant digits (the OGC CDB1GlobalGrid example lists 2^-14 as
* 6.10351562e-05). Such levels drift apart in proportion to the column index,
* so far enough from the origin a parent again overlaps its neighbor's
* children.
*/
const TILE_EDGE_TOLERANCE = 1e-6;

/**
* Find the tiles that a span overlaps along one axis of a tile grid.
*
* `start` and `end` are positions in tile units from the grid origin, so tile
* `i` spans `i` to `i + 1`. Tiles that the span only touches at an edge are
* excluded: a parent whose edges sit on child tile edges overlaps exactly the
* children it contains, not the neighbors that share those edges.
*
* @param start Start of the span, in tiles from the grid origin.
* @param end End of the span, in tiles from the grid origin. Must not be
* less than `start`.
* @returns The inclusive, unclamped index range `[first, last]`. A span
* narrower than the tolerance still returns one tile.
*/
export function overlappingTileSpan(
start: number,
end: number,
): [first: number, last: number] {
const first = Math.floor(start + TILE_EDGE_TOLERANCE);
const last = Math.ceil(end - TILE_EDGE_TOLERANCE) - 1;
return [first, Math.max(first, last)];
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export interface RasterTilesetLevel {
* The returned range is **inclusive** on both ends: a consumer should
* iterate `for (let col = minCol; col <= maxCol; col++)`.
*
* Tiles that only touch the bounding box along an edge, within a small
* floating-point tolerance, are not included. Otherwise, in an aligned
* pyramid, every parent tile would also claim its neighbors' children, and
* the traversal would visit and select those children once per parent.
*
* Used by the traversal algorithm to find child tiles from a parent tile's
* projected bounds.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as affine from "@developmentseed/affine";
import { describe, expect, it } from "vitest";
import { resolveSecondaryTiles } from "../../src/multi-raster-tileset/secondary-tile-resolver.js";
import { AffineTilesetLevel } from "../../src/raster-tileset/affine-tileset-level.js";
import { overlappingTileSpan } from "../../src/raster-tileset/tile-span.js";
import type { RasterTilesetLevel } from "../../src/raster-tileset/tileset-interface.js";
import type { Corners, Point } from "../../src/raster-tileset/types.js";

Expand Down Expand Up @@ -56,12 +59,16 @@ function gridLevel(opts: {
projectedMaxX: number,
projectedMaxY: number,
) => {
// Use ceil-1 for both min and max so that exact tile boundaries are treated
// as inclusive on the left tile (the boundary point belongs to the tile ending there).
let minCol = Math.ceil((projectedMinX - originX) / tileCrsWidth) - 1;
let maxCol = Math.ceil((projectedMaxX - originX) / tileCrsWidth) - 1;
let minRow = Math.ceil((originY - projectedMaxY) / tileCrsHeight) - 1;
let maxRow = Math.ceil((originY - projectedMinY) / tileCrsHeight) - 1;
// Tiles that only share an edge with the box are left out, as the
// `RasterTilesetLevel` contract requires.
let [minCol, maxCol] = overlappingTileSpan(
(projectedMinX - originX) / tileCrsWidth,
(projectedMaxX - originX) / tileCrsWidth,
);
let [minRow, maxRow] = overlappingTileSpan(
(originY - projectedMaxY) / tileCrsHeight,
(originY - projectedMinY) / tileCrsHeight,
);
minCol = Math.max(0, Math.min(matrixWidth - 1, minCol));
maxCol = Math.max(0, Math.min(matrixWidth - 1, maxCol));
minRow = Math.max(0, Math.min(matrixHeight - 1, minRow));
Expand Down Expand Up @@ -120,14 +127,59 @@ describe("resolveSecondaryTiles", () => {
});

it("handles primary tile spanning two secondary tiles", () => {
// Primary tile (2,0): covers [605120, 7997440] to [607680, 8000000]
// Crosses boundary between secondary (0,0) and (1,0)
const result = resolveSecondaryTiles(primaryLevel, 2, 0, secondaryLevel, 0);
expect(result.tileIndices.length).toBe(2);
// Stitched: [600000..610240], width=10240
// scaleX = 2560 / 10240 = 0.25, offsetX = (605120-600000)/10240 = 0.5
// Secondary grid shifted left by half a primary tile, so its column
// boundaries fall at 603840, 608960, ...
const shiftedSecondary = gridLevel({
originX: origin.x - 1280,
originY: origin.y,
cellSize: 20,
tileWidth: 256,
tileHeight: 256,
matrixWidth: 22,
matrixHeight: 22,
});
// Primary tile (1,0): covers [602560, 7997440] to [605120, 8000000]
// Crosses the boundary at 603840 between secondary (0,0) and (1,0)
const result = resolveSecondaryTiles(
primaryLevel,
1,
0,
shiftedSecondary,
0,
);
expect(result.tileIndices).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]);
// Stitched: [598720..608960], width=10240
// scaleX = 2560 / 10240 = 0.25, offsetX = (602560-598720)/10240 = 0.375
expect(result.uvTransform[2]).toBeCloseTo(0.25);
expect(result.uvTransform[0]).toBeCloseTo(0.5);
expect(result.uvTransform[0]).toBeCloseTo(0.375);
});

it("fetches one 20 m tile for a 10 m tile whose edges lie on its edges", () => {
// Sentinel-2-like 10 m and 20 m bands with 1024 px tiles. Primary tile
// (1,1) is the bottom-right quarter of secondary tile (0,0): its right and
// bottom edges are secondary tile edges.
const transform: affine.Affine = [10, 0, 600000, 0, -10, 5000040];
const primary = new AffineTilesetLevel({
affine: transform,
arrayWidth: 10980,
arrayHeight: 10980,
tileWidth: 1024,
tileHeight: 1024,
mpu: 1,
});
const secondary = new AffineTilesetLevel({
affine: affine.compose(transform, affine.scale(2)),
arrayWidth: 5490,
arrayHeight: 5490,
tileWidth: 1024,
tileHeight: 1024,
mpu: 1,
});
const result = resolveSecondaryTiles(primary, 1, 1, secondary, 0);
expect(result.tileIndices).toEqual([{ x: 0, y: 0 }]);
});

it("returns identity-like transform when grids align exactly", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { TileMatrixSet } from "@developmentseed/morecantile";
import { describe, expect, it } from "vitest";
import { createRootTiles } from "../../src/raster-tileset/raster-tile-traversal.js";
import { TileMatrixSetAdaptor } from "../../src/raster-tileset/tile-matrix-set.js";
import { overlappingTileSpan } from "../../src/raster-tileset/tile-span.js";
import type {
RasterTilesetDescriptor,
RasterTilesetLevel,
Expand Down Expand Up @@ -29,22 +32,20 @@ function makeFakeLevel(opts: {
throw new Error("not used");
},
crsBoundsToTileRange(minX, minY, maxX, maxY) {
const minCol = Math.max(
0,
Math.min(matrixWidth - 1, Math.floor((minX + 180) / tileDegrees)),
// Tiles that only share an edge with the box are left out, as the
// `RasterTilesetLevel` contract requires.
const [firstCol, lastCol] = overlappingTileSpan(
(minX + 180) / tileDegrees,
(maxX + 180) / tileDegrees,
);
const maxCol = Math.max(
0,
Math.min(matrixWidth - 1, Math.floor((maxX + 180) / tileDegrees)),
);
const minRow = Math.max(
0,
Math.min(matrixHeight - 1, Math.floor((90 - maxY) / tileDegrees)),
);
const maxRow = Math.max(
0,
Math.min(matrixHeight - 1, Math.floor((90 - minY) / tileDegrees)),
const [firstRow, lastRow] = overlappingTileSpan(
(90 - maxY) / tileDegrees,
(90 - minY) / tileDegrees,
);
const minCol = Math.max(0, Math.min(matrixWidth - 1, firstCol));
const maxCol = Math.max(0, Math.min(matrixWidth - 1, lastCol));
const minRow = Math.max(0, Math.min(matrixHeight - 1, firstRow));
const maxRow = Math.max(0, Math.min(matrixHeight - 1, lastRow));
return { minCol, maxCol, minRow, maxRow };
},
};
Expand Down Expand Up @@ -130,4 +131,46 @@ describe("createRootTiles", () => {
// 3 × 4 = 12 root tiles, all enumerated.
expect(roots).toHaveLength(12);
});

it("leaves out root tiles that only touch the viewport", () => {
// 360 × 180 one-degree root tiles, so the culled path runs.
const tms: TileMatrixSet = {
crs: "http://www.opengis.net/def/crs/OGC/1.3/CRS84",
boundingBox: { lowerLeft: [-180, -90], upperRight: [180, 90] },
tileMatrices: [
{
id: "0",
scaleDenominator: 397569609.9759771,
cellSize: 1 / 256,
cornerOfOrigin: "topLeft",
pointOfOrigin: [-180, 90],
tileWidth: 256,
tileHeight: 256,
matrixWidth: 360,
matrixHeight: 180,
},
],
};
const descriptor = new TileMatrixSetAdaptor(tms, {
projectTo3857: identity,
projectFrom3857: identity,
projectTo4326: identity,
projectFrom4326: identity,
});
// Every viewport edge lies on a root tile edge.
const roots = createRootTiles({
descriptor,
viewport: makeViewport([10, 20, 13, 22]),
datasetWgs84Bounds: [-180, -90, 180, 90],
});
// Columns 190..192 cover longitudes 10..13, rows 68..69 latitudes 22..20.
expect(roots.map(({ x, y }) => `${x}/${y}`)).toEqual([
"190/68",
"191/68",
"192/68",
"190/69",
"191/69",
"192/69",
]);
});
});
Loading
Loading