Skip to content

Commit 5fc140a

Browse files
sparse wmts datasource fixes (#9024)
Co-authored-by: Ben Polinsky <ben-polinsky@users.noreply.github.com> Co-authored-by: Daniel Zhong <32878167+danielzhong@users.noreply.github.com>
1 parent 67324c5 commit 5fc140a

7 files changed

Lines changed: 314 additions & 12 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@itwin/core-frontend",
5+
"comment": "Add support for WMTS sparse tilesets",
6+
"type": "none"
7+
}
8+
],
9+
"packageName": "@itwin/core-frontend"
10+
}

core/frontend/src/internal/tile/map/ImageryProviders/WmtsMapLayerImageryProvider.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,13 @@ export class WmtsMapLayerImageryProvider extends MapLayerImageryProvider {
187187
assert(false); // Must always hava a matrix set.
188188
return;
189189
}
190-
const limits = matrixSetAndLimits.limits?.[quadId.level + 1]?.limits;
190+
const childLevel = quadId.level + 1;
191+
const childMatrixId = matrixSetAndLimits.tileMatrixSet.tileMatrix.length > childLevel
192+
? matrixSetAndLimits.tileMatrixSet.tileMatrix[childLevel].identifier
193+
: undefined;
194+
const limits = childMatrixId !== undefined
195+
? matrixSetAndLimits.limits?.find((l) => l.tileMatrix === childMatrixId)?.limits
196+
: undefined;
191197
if (!limits) {
192198
resolveChildren(childIds);
193199
return;

core/frontend/src/internal/tile/map/WmtsCapabilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ export namespace WmtsCapability {
326326

327327
const tileMatrixLimitsRoot = elem.getElementsByTagName("TileMatrixSetLimits");
328328
if (tileMatrixLimitsRoot.length > 0) {
329-
const tileMatrixLimits = tileMatrixLimitsRoot[0].getElementsByTagName("TileMatrixSetLimits");
329+
const tileMatrixLimits = tileMatrixLimitsRoot[0].getElementsByTagName("TileMatrixLimits");
330330
for (const tmsl of tileMatrixLimits) {
331331
this.tileMatrixSetLimits.push(new TileMatrixSetLimits(tmsl));
332332
}

core/frontend/src/test/tile/map/ImageryTileTree.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@
33
* See LICENSE.md in the project root for license terms and full copyright notice.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { Range3d } from "@itwin/core-geometry";
67
import { ImageMapLayerProps, ImageMapLayerSettings } from "@itwin/core-common";
78
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
89
import { MockRender } from "../../../internal/render/MockRender";
910
import { createBlankConnection } from "../../createBlankConnection";
10-
import { ImageryMapLayerTreeReference } from "../../../tile/map/ImageryTileTree";
11+
import { ImageryMapLayerTreeReference, ImageryMapTile, ImageryMapTileTree } from "../../../tile/map/ImageryTileTree";
1112
import { IModelConnection } from "../../../IModelConnection";
1213
import { ImageryMapLayerFormat } from "../../../tile/map/MapLayerImageryFormats";
1314
import { MapLayerImageryProvider } from "../../../tile/map/MapLayerImageryProvider";
1415
import { IModelApp } from "../../../IModelApp";
16+
import { QuadId, TileTreeLoadStatus } from "../../../tile/internal";
17+
import { TileDrawArgs } from "../../../tile/TileDrawArgs";
1518

1619
class CustomProvider extends MapLayerImageryProvider {
1720
public override async constructUrl(_row: number, _column: number, _zoomLevel: number) { return this._settings.url;}
@@ -96,4 +99,81 @@ describe("ImageryTileTree", () => {
9699
expect(tileTreeLhs!.modelId).not.toEqual(tileTreeRhs!.modelId);
97100
}
98101
});
102+
103+
/** Helper: create a tree and return the root tile + tree ref */
104+
async function createTreeAndRoot() {
105+
const props: ImageMapLayerProps = { formatId: "Custom1", url: "https://dummy.com", name: "TestLayer", subLayers: [{ name: "sub0", visible: true }] };
106+
const settings = ImageMapLayerSettings.fromJSON(props);
107+
const treeRef = new ImageryMapLayerTreeReference({ layerSettings: settings, layerIndex: 0, iModel: imodel });
108+
const tree = await treeRef.treeOwner.loadTree() as ImageryMapTileTree;
109+
expect(tree).toBeDefined();
110+
const root = tree.rootTile as ImageryMapTile;
111+
return { tree, root };
112+
}
113+
114+
/** Helper: create a child ImageryMapTile under a parent */
115+
function createChild(parent: ImageryMapTile, tree: ImageryMapTileTree, level: number, column: number, row: number, isLeaf = false): ImageryMapTile {
116+
const quadId = new QuadId(level, column, row);
117+
const rectangle = tree.tilingScheme.tileXYToRectangle(column, row, level);
118+
const range = Range3d.createXYZXYZ(rectangle.low.x, rectangle.low.y, 0, rectangle.high.x, rectangle.high.y, 0);
119+
return new ImageryMapTile({ parent, isLeaf, contentId: quadId.contentId, range, maximumSize: 256 }, tree, quadId, rectangle);
120+
}
121+
122+
describe("setContent", () => {
123+
it("should mark self as leaf when content has no texture, not the parent", async () => {
124+
const { tree, root } = await createTreeAndRoot();
125+
const child = createChild(root, tree, 1, 0, 0);
126+
127+
// Before setContent: neither should be a leaf
128+
expect(child.isLeaf).toBe(false);
129+
const rootWasLeaf = root.isLeaf;
130+
131+
// Set content with no texture (simulates empty tile from server)
132+
child.setContent({});
133+
134+
// Child should be marked as leaf (no data below it)
135+
expect(child.isLeaf).toBe(true);
136+
// Parent should NOT be affected — siblings should remain traversable
137+
expect(root.isLeaf).toBe(rootWasLeaf);
138+
});
139+
140+
it("should not mark self as leaf when content has a texture", async () => {
141+
const { tree, root } = await createTreeAndRoot();
142+
const child = createChild(root, tree, 1, 0, 0);
143+
144+
// Create a mock texture
145+
const mockTexture = { bytesUsed: 100 } as any;
146+
child.setContent({ imageryTexture: mockTexture });
147+
148+
// Child should NOT be a leaf — texture exists, so deeper levels may too
149+
expect(child.isLeaf).toBe(false);
150+
});
151+
});
152+
153+
describe("selectCartoDrapeTiles", () => {
154+
it("should not treat a not-found tile as a terminal leaf", async () => {
155+
const { tree, root } = await createTreeAndRoot();
156+
157+
// Simulate a sparse tile pyramid: level-1 child returns 404 (not found)
158+
const child = createChild(root, tree, 1, 0, 0);
159+
child.setNotFound();
160+
161+
expect(child.isNotFound).toBe(true);
162+
expect(child.isLeaf).toBe(false);
163+
164+
const loadChildrenSpy = vi.spyOn(child as any, "loadChildren").mockReturnValue(TileTreeLoadStatus.Loading);
165+
166+
const drapeTiles: ImageryMapTile[] = [];
167+
const highResTiles: ImageryMapTile[] = [];
168+
const mockArgs = { markChildrenLoading: vi.fn() } as unknown as TileDrawArgs;
169+
const status = child.selectCartoDrapeTiles(drapeTiles, highResTiles, child.rectangle, 0.0000001, mockArgs);
170+
171+
// Should attempt to load children rather than stopping at this tile
172+
expect(loadChildrenSpy).toHaveBeenCalled();
173+
expect(status).toBe(TileTreeLoadStatus.Loading);
174+
// eslint-disable-next-line @typescript-eslint/unbound-method
175+
expect(mockArgs.markChildrenLoading).toHaveBeenCalled();
176+
expect(drapeTiles).toHaveLength(0);
177+
});
178+
});
99179
});

core/frontend/src/test/tile/map/WmtsCapabilities.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,169 @@ describe("WmtsCapabilities1", () => {
245245
expect(googleTms?.length).toEqual(2);
246246
});
247247

248+
it("should parse TileMatrixSetLimits from great-artesian-basin", () => {
249+
// Minimal WMTS capabilities XML with TileMatrixSetLimits to test parsing.
250+
// Based on the great-artesian-basin.xml fixture structure.
251+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
252+
<Capabilities xmlns="http://www.opengis.net/wmts/1.0" xmlns:ows="http://www.opengis.net/ows/1.1" version="1.0.0">
253+
<Contents>
254+
<Layer>
255+
<ows:Title>TestLayer</ows:Title>
256+
<ows:Identifier>test-layer</ows:Identifier>
257+
<Style isDefault="true"><ows:Identifier>default</ows:Identifier></Style>
258+
<Format>image/png</Format>
259+
<TileMatrixSetLink>
260+
<TileMatrixSet>EPSG:3857</TileMatrixSet>
261+
<TileMatrixSetLimits>
262+
<TileMatrixLimits>
263+
<TileMatrix>EPSG:3857:0</TileMatrix>
264+
<MinTileRow>0</MinTileRow>
265+
<MaxTileRow>0</MaxTileRow>
266+
<MinTileCol>0</MinTileCol>
267+
<MaxTileCol>0</MaxTileCol>
268+
</TileMatrixLimits>
269+
<TileMatrixLimits>
270+
<TileMatrix>EPSG:3857:5</TileMatrix>
271+
<MinTileRow>16</MinTileRow>
272+
<MaxTileRow>19</MaxTileRow>
273+
<MinTileCol>27</MinTileCol>
274+
<MaxTileCol>30</MaxTileCol>
275+
</TileMatrixLimits>
276+
</TileMatrixSetLimits>
277+
</TileMatrixSetLink>
278+
</Layer>
279+
<TileMatrixSet>
280+
<ows:Identifier>EPSG:3857</ows:Identifier>
281+
<ows:SupportedCRS>urn:ogc:def:crs:EPSG::3857</ows:SupportedCRS>
282+
<TileMatrix><ows:Identifier>EPSG:3857:0</ows:Identifier><ScaleDenominator>559082264</ScaleDenominator><TopLeftCorner>-20037508 20037508</TopLeftCorner><TileWidth>256</TileWidth><TileHeight>256</TileHeight><MatrixWidth>1</MatrixWidth><MatrixHeight>1</MatrixHeight></TileMatrix>
283+
</TileMatrixSet>
284+
</Contents>
285+
</Capabilities>`;
286+
287+
const capabilities = WmtsCapabilities.createFromXml(xml);
288+
expect(capabilities).toBeDefined();
289+
290+
const layer = capabilities?.contents?.layers[0];
291+
expect(layer).toBeDefined();
292+
expect(layer!.identifier).toEqual("test-layer");
293+
294+
const epsg3857Link = layer?.tileMatrixSetLinks.find((link) => link.tileMatrixSet === "EPSG:3857");
295+
expect(epsg3857Link).toBeDefined();
296+
297+
// BUG: TileMatrixSetLimits are never parsed because inner getElementsByTagName uses
298+
// "TileMatrixSetLimits" (the container tag) instead of "TileMatrixLimits" (the child tag).
299+
// This test SHOULD pass once the parsing bug is fixed.
300+
expect(epsg3857Link!.tileMatrixSetLimits.length).toEqual(2);
301+
302+
// Verify the first limit entry (level 0)
303+
const firstLimit = epsg3857Link!.tileMatrixSetLimits[0];
304+
expect(firstLimit.tileMatrix).toEqual("EPSG:3857:0");
305+
expect(firstLimit.limits).toBeDefined();
306+
expect(firstLimit.limits!.low.x).toEqual(0); // MinTileCol
307+
expect(firstLimit.limits!.low.y).toEqual(0); // MinTileRow
308+
expect(firstLimit.limits!.high.x).toEqual(0); // MaxTileCol
309+
expect(firstLimit.limits!.high.y).toEqual(0); // MaxTileRow
310+
311+
// Verify level 5 limit entry
312+
const level5Limit = epsg3857Link!.tileMatrixSetLimits[1];
313+
expect(level5Limit.tileMatrix).toEqual("EPSG:3857:5");
314+
expect(level5Limit.limits).toBeDefined();
315+
expect(level5Limit.limits!.low.x).toEqual(27); // MinTileCol
316+
expect(level5Limit.limits!.low.y).toEqual(16); // MinTileRow
317+
expect(level5Limit.limits!.high.x).toEqual(30); // MaxTileCol
318+
expect(level5Limit.limits!.high.y).toEqual(19); // MaxTileRow
319+
});
320+
321+
it("should parse Propeller Aero NDOT capabilities", () => {
322+
// Minimal XML representative of Propeller Aero WMTS: localized dataset,
323+
// no TileMatrixSetLimits, resource URL template, small WGS84 bounding box.
324+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
325+
<Capabilities xmlns="http://www.opengis.net/wmts/1.0" xmlns:ows="http://www.opengis.net/ows/1.1" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0.0">
326+
<ows:ServiceIdentification>
327+
<ows:Title>PropellerAero WMTS Service</ows:Title>
328+
<ows:ServiceType>OGC WMTS</ows:ServiceType>
329+
<ows:ServiceTypeVersion>1.0.0</ows:ServiceTypeVersion>
330+
</ows:ServiceIdentification>
331+
<Contents>
332+
<Layer>
333+
<ows:Title>Oct 30, 2025</ows:Title>
334+
<ows:Identifier>dsce0d0b33</ows:Identifier>
335+
<ows:WGS84BoundingBox crs="urn:ogc:def:crs:OGC:2:84">
336+
<ows:LowerCorner>-96.18430645001274 40.81005980948186</ows:LowerCorner>
337+
<ows:UpperCorner>-96.17298696901085 40.81541280531352</ows:UpperCorner>
338+
</ows:WGS84BoundingBox>
339+
<Style isDefault="true">
340+
<ows:Title>default</ows:Title>
341+
<ows:Identifier>default</ows:Identifier>
342+
</Style>
343+
<Format>image/png</Format>
344+
<TileMatrixSetLink>
345+
<TileMatrixSet>PropellerAeroTileMatrix</TileMatrixSet>
346+
</TileMatrixSetLink>
347+
<ResourceURL format="image/png" resourceType="tile" template="https://cdn.example.com/tiles/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png"/>
348+
</Layer>
349+
<TileMatrixSet>
350+
<ows:Title>PropellerAeroTileMatrix</ows:Title>
351+
<ows:Identifier>PropellerAeroTileMatrix</ows:Identifier>
352+
<ows:SupportedCRS>urn:ogc:def:crs:EPSG::3857</ows:SupportedCRS>
353+
<WellKnownScaleSet>urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible</WellKnownScaleSet>
354+
<TileMatrix><ows:Identifier>0</ows:Identifier><ScaleDenominator>559082264.0287178</ScaleDenominator><TopLeftCorner>-20037508.3427892 20037508.3427892</TopLeftCorner><TileWidth>256</TileWidth><TileHeight>256</TileHeight><MatrixWidth>1</MatrixWidth><MatrixHeight>1</MatrixHeight></TileMatrix>
355+
<TileMatrix><ows:Identifier>1</ows:Identifier><ScaleDenominator>279541132.0143589</ScaleDenominator><TopLeftCorner>-20037508.3427892 20037508.3427892</TopLeftCorner><TileWidth>256</TileWidth><TileHeight>256</TileHeight><MatrixWidth>2</MatrixWidth><MatrixHeight>2</MatrixHeight></TileMatrix>
356+
<TileMatrix><ows:Identifier>20</ows:Identifier><ScaleDenominator>533.182395962446</ScaleDenominator><TopLeftCorner>-20037508.3427892 20037508.3427892</TopLeftCorner><TileWidth>256</TileWidth><TileHeight>256</TileHeight><MatrixWidth>1048576</MatrixWidth><MatrixHeight>1048576</MatrixHeight></TileMatrix>
357+
</TileMatrixSet>
358+
</Contents>
359+
</Capabilities>`;
360+
361+
const capabilities = WmtsCapabilities.createFromXml(xml);
362+
expect(capabilities).toBeDefined();
363+
expect(capabilities?.version).toEqual("1.0.0");
364+
365+
// Service identification
366+
expect(capabilities?.serviceIdentification?.title).toEqual("PropellerAero WMTS Service");
367+
expect(capabilities?.serviceIdentification?.serviceType).toEqual("OGC WMTS");
368+
369+
// Layer
370+
expect(capabilities?.contents?.layers.length).toEqual(1);
371+
const layer = capabilities!.contents!.layers[0];
372+
expect(layer.identifier).toEqual("dsce0d0b33");
373+
374+
// The layer covers a small area near Lincoln, NE
375+
expect(layer.wsg84BoundingBox).toBeDefined();
376+
expect(layer.wsg84BoundingBox!.west).toBeDefined();
377+
const area = layer.wsg84BoundingBox!.globalLocationArea;
378+
// Longitude ~ -96.18 to -96.17 (small site in Nebraska)
379+
expect(area.southwest.longitudeDegrees).toBeCloseTo(-96.184, 2);
380+
expect(area.northeast.longitudeDegrees).toBeCloseTo(-96.173, 2);
381+
expect(area.southwest.latitudeDegrees).toBeCloseTo(40.810, 2);
382+
expect(area.northeast.latitudeDegrees).toBeCloseTo(40.815, 2);
383+
384+
// Tile matrix set
385+
expect(capabilities?.contents?.tileMatrixSets.length).toEqual(1);
386+
const tms = capabilities!.contents!.tileMatrixSets[0];
387+
expect(tms.identifier).toEqual("PropellerAeroTileMatrix");
388+
expect(tms.supportedCrs).toContain("EPSG::3857");
389+
expect(tms.wellKnownScaleSet).toContain("GoogleMapsCompatible");
390+
391+
// Verify it's recognized as Google Maps compatible
392+
const googleTms = capabilities?.contents?.getGoogleMapsCompatibleTileMatrixSet();
393+
expect(googleTms?.length).toEqual(1);
394+
expect(googleTms![0].identifier).toEqual("PropellerAeroTileMatrix");
395+
396+
// No TileMatrixSetLimits — this Propeller endpoint doesn't publish them,
397+
// which is part of why sparse tile requests fail.
398+
const tmLink = layer.tileMatrixSetLinks[0];
399+
expect(tmLink.tileMatrixSet).toEqual("PropellerAeroTileMatrix");
400+
expect(tmLink.tileMatrixSetLimits.length).toEqual(0);
401+
402+
// Resource URL should be present
403+
expect(layer.resourceUrls.length).toBeGreaterThan(0);
404+
const pngUrl = layer.resourceUrls.find((u) => u.format.includes("png"));
405+
expect(pngUrl).toBeDefined();
406+
expect(pngUrl!.template).toContain("{TileMatrix}");
407+
expect(pngUrl!.template).toContain("{TileCol}");
408+
expect(pngUrl!.template).toContain("{TileRow}");
409+
});
410+
248411

249412

250413
it("should request proper URL", async () => {

core/frontend/src/test/tile/map/WmtsMapLayerImageryProvider.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55

66
import { EmptyLocalization, ImageMapLayerSettings, ServerError } from "@itwin/core-common";
77
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8-
import { WmtsCapabilities, WmtsMapLayerImageryProvider } from "../../../tile/internal";
8+
import { QuadId, WmtsCapabilities, WmtsCapability, WmtsMapLayerImageryProvider } from "../../../tile/internal";
99
import { IModelApp } from "../../../IModelApp";
1010
import { RequestBasicCredentials } from "../../../request/Request";
1111
import { fakeTextFetch } from "./MapLayerTestUtilities";
12+
import { Range2d } from "@itwin/core-geometry";
1213

1314
const wmtsSampleSource = { formatId: "WMTS", url: "https://localhost/wmts", name: "Test WMTS" };
1415
describe("WmtsMapLayerImageryProvider", () => {
@@ -127,6 +128,48 @@ describe("WmtsMapLayerImageryProvider", () => {
127128
expect(url).toEqual(`${refUrl}&${param1.toString()}&${param2.toString()}`);
128129
});
129130

131+
it("_generateChildIds should filter children by TileMatrixSetLimits", async () => {
132+
// Set up a mock that returns limits restricting which tiles are valid.
133+
// At level 1, only tile (col=1, row=1) is valid (matching great-artesian-basin level 1 limits).
134+
const tileMatrixSet: Partial<WmtsCapability.TileMatrixSet> = {
135+
tileMatrix: [
136+
{ identifier: "0" } as WmtsCapability.TileMatrix,
137+
{ identifier: "1" } as WmtsCapability.TileMatrix,
138+
],
139+
identifier: "TestTMS",
140+
};
141+
142+
// Level 1 limits: only col 1, row 1 is valid
143+
const limitsLevel1: Partial<WmtsCapability.TileMatrixSetLimits> = {
144+
tileMatrix: "1",
145+
limits: Range2d.createXYXY(1, 1, 1, 1), // MinTileCol=1, MinTileRow=1, MaxTileCol=1, MaxTileRow=1
146+
};
147+
148+
vi.spyOn(WmtsMapLayerImageryProvider.prototype, "getDisplayedTileMatrixSetAndLimits" as any).mockImplementation(() => {
149+
return {
150+
tileMatrixSet,
151+
limits: [limitsLevel1 as WmtsCapability.TileMatrixSetLimits],
152+
};
153+
});
154+
155+
const settings = ImageMapLayerSettings.fromJSON({ formatId: "WMTS", name: "test", url: "https://fake/wmts" });
156+
const provider = new WmtsMapLayerImageryProvider(settings);
157+
158+
// Parent is at level 0, col 0, row 0. Children at level 1 are:
159+
// (col=0,row=0), (col=1,row=0), (col=0,row=1), (col=1,row=1)
160+
// Only (col=1,row=1) should be returned since limits restrict to col=1,row=1.
161+
const parentQuadId = new QuadId(0, 0, 0);
162+
let resolvedChildIds: QuadId[] = [];
163+
(provider as any)._generateChildIds(parentQuadId, (childIds: QuadId[]) => {
164+
resolvedChildIds = childIds;
165+
});
166+
167+
// Limits are matched by tileMatrix identifier, so only tiles within col=1,row=1 are returned.
168+
expect(resolvedChildIds.length).toEqual(1);
169+
expect(resolvedChildIds[0].column).toEqual(1);
170+
expect(resolvedChildIds[0].row).toEqual(1);
171+
});
172+
130173
it("construct tile url using resource url", async () => {
131174
const response = await fetch(`/assets/wmts_capabilities/wmts_resource_url.xml`);
132175
const text = await response.text();

0 commit comments

Comments
 (0)