diff --git a/common/api/core-frontend.api.md b/common/api/core-frontend.api.md index 4ae54e616654..cb69ddb46d2d 100644 --- a/common/api/core-frontend.api.md +++ b/common/api/core-frontend.api.md @@ -3767,6 +3767,9 @@ export function getCompressedJpegFromCanvas(canvas: HTMLCanvasElement, maxBytes? // @internal (undocumented) export function getFrustumPlaneIntersectionDepthRange(frustum: Frustum, plane: Plane3dByOriginAndUnitNormal): Range1d; +// @beta +export function getGoogle3dTilesUrl(): string; + // @public export function getImageSourceFormatForMimeType(mimeType: string): ImageSourceFormat | undefined; @@ -4075,6 +4078,8 @@ export interface GltfReaderArgs { // @internal export interface GltfReaderResult extends TileContent { + // (undocumented) + copyright?: string; // (undocumented) range?: AxisAlignedBox3d; // (undocumented) @@ -4095,6 +4100,39 @@ export interface GLTimerResult { nanoseconds: number; } +// @beta +export class Google3dTilesProvider implements RealityDataSourceProvider { + constructor(options: Google3dTilesProviderOptions); + // (undocumented) + addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise; + // (undocumented) + createRealityDataSource(key: RealityDataSourceKey, iTwinId: GuidString | undefined): Promise; + // (undocumented) + decorate(_context: DecorateContext): void; + initialize(): Promise; + readonly useCachedDecorations = true; +} + +// @beta +export type Google3dTilesProviderOptions = { + apiKey: string; + getAuthToken?: never; + showCreditsOnScreen?: boolean; +} | { + apiKey?: never; + getAuthToken: () => Promise; + showCreditsOnScreen?: boolean; +}; + +// @internal +export class GoogleMapsDecorator implements Decorator { + constructor(showCreditsOnScreen?: boolean); + activate(mapType: GoogleMapsMapTypes): Promise; + decorate: (context: DecorateContext) => void; + // (undocumented) + readonly logo: LogoDecoration; +} + // @public export type GpuMemoryLimit = "none" | "default" | "aggressive" | "relaxed" | number; @@ -4876,7 +4914,7 @@ export class IModelApp { static queryRenderCompatibility(): WebGLRenderCompatibilityInfo; // @beta static get realityDataAccess(): RealityDataAccess | undefined; - // @alpha + // @beta static get realityDataSourceProviders(): RealityDataSourceProviderRegistry; // @internal static registerEntityState(classFullName: string, classType: typeof EntityState): void; @@ -5620,6 +5658,20 @@ export enum LockedStates { Y_BM = 2 } +// @internal +export class LogoDecoration implements CanvasDecoration { + // (undocumented) + activate(sprite: Sprite): Promise; + // (undocumented) + decorate(context: DecorateContext): void; + drawDecoration(ctx: CanvasRenderingContext2D): void; + get isLoaded(): boolean; + moveToLowerLeftCorner(context: DecorateContext): boolean; + set offset(offset: Point3d | undefined); + get offset(): Point3d | undefined; + readonly position: Point3d; +} + // @public export class LookAndMoveTool extends ViewManip { constructor(vp: ScreenViewport, oneShot?: boolean, isDraggingRequired?: boolean); @@ -8117,12 +8169,15 @@ export namespace RealityDataSource { export function fromKey(key: RealityDataSourceKey, iTwinId: GuidString | undefined): Promise; } -// @alpha +// @beta export interface RealityDataSourceProvider { + addAttributions?(cards: HTMLTableElement, vp: ScreenViewport): Promise; createRealityDataSource(key: RealityDataSourceKey, iTwinId: GuidString | undefined): Promise; + decorate?(_context: DecorateContext): void; + useCachedDecorations?: true | undefined; } -// @alpha +// @beta export class RealityDataSourceProviderRegistry { // @internal constructor(); @@ -8217,6 +8272,10 @@ export class RealityTile extends Tile { // @internal (undocumented) computeVisibilityFactor(args: TileDrawArgs): number; // @internal (undocumented) + get copyright(): string | undefined; + // @internal (undocumented) + protected _copyright?: string; + // @internal (undocumented) disposeContents(): void; // @internal (undocumented) protected forceSelectRealityTile(): boolean; diff --git a/common/api/summary/core-frontend.exports.csv b/common/api/summary/core-frontend.exports.csv index f68f6ccb0b5c..5f4b6e75e294 100644 --- a/common/api/summary/core-frontend.exports.csv +++ b/common/api/summary/core-frontend.exports.csv @@ -225,6 +225,7 @@ public;function;getCenteredViewRect public;function;getCesiumAssetUrl public;function;getCompressedJpegFromCanvas internal;function;getFrustumPlaneIntersectionDepthRange +beta;function;getGoogle3dTilesUrl public;function;getImageSourceFormatForMimeType public;function;getImageSourceMimeType public;interface;GetPixelDataWorldPointArgs @@ -241,6 +242,9 @@ internal;interface;GltfReaderArgs internal;interface;GltfReaderResult beta;interface;GltfTemplate internal;interface;GLTimerResult +beta;class;Google3dTilesProvider +beta;type;Google3dTilesProviderOptions +internal;class;GoogleMapsDecorator public;type;GpuMemoryLimit public;interface;GpuMemoryLimits public;class;GraphicalEditingScope @@ -346,6 +350,7 @@ public;enum;LocateFilterStatus public;class;LocateOptions public;class;LocateResponse internal;enum;LockedStates +internal;class;LogoDecoration public;class;LookAndMoveTool public;interface;LookAtArgs public;interface;LookAtOrthoArgs @@ -507,8 +512,8 @@ alpha;function;createKeyFromBlobUrl alpha;function;createKeyFromOrbitGtBlobProps alpha;function;createOrbitGtBlobPropsFromKey alpha;function;fromKey -alpha;interface;RealityDataSourceProvider -alpha;class;RealityDataSourceProviderRegistry +beta;interface;RealityDataSourceProvider +beta;class;RealityDataSourceProviderRegistry public;interface;RealityMeshParams public;namespace;RealityMeshParams internal;function;fromGltfMesh diff --git a/common/changes/@itwin/core-common/google-3d-tiles_2025-05-29-15-10.json b/common/changes/@itwin/core-common/google-3d-tiles_2025-05-29-15-10.json new file mode 100644 index 000000000000..d1ac065f5d72 --- /dev/null +++ b/common/changes/@itwin/core-common/google-3d-tiles_2025-05-29-15-10.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-common", + "comment": "", + "type": "none" + } + ], + "packageName": "@itwin/core-common" +} \ No newline at end of file diff --git a/common/changes/@itwin/core-frontend/google-3d-tiles_2025-05-21-15-03.json b/common/changes/@itwin/core-frontend/google-3d-tiles_2025-05-21-15-03.json new file mode 100644 index 000000000000..3910a7769eee --- /dev/null +++ b/common/changes/@itwin/core-frontend/google-3d-tiles_2025-05-21-15-03.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-frontend", + "comment": "Support Google Photorealistic 3D Tiles.", + "type": "none" + } + ], + "packageName": "@itwin/core-frontend" +} \ No newline at end of file diff --git a/common/changes/@itwin/map-layers-formats/google-3d-tiles_2025-05-21-21-05.json b/common/changes/@itwin/map-layers-formats/google-3d-tiles_2025-05-21-21-05.json new file mode 100644 index 000000000000..a6bcc7b85e0a --- /dev/null +++ b/common/changes/@itwin/map-layers-formats/google-3d-tiles_2025-05-21-21-05.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/map-layers-formats", + "comment": "", + "type": "none" + } + ], + "packageName": "@itwin/map-layers-formats" +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index aee1b6224383..e86d143681f0 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -864,6 +864,9 @@ importers: '@types/chai-as-promised': specifier: ^7 version: 7.1.0 + '@types/sinon': + specifier: ^17.0.2 + version: 17.0.2 '@vitest/browser': specifier: ^3.0.6 version: 3.0.6(@types/node@22.13.10)(playwright@1.47.1)(typescript@5.6.2)(vite@6.2.1(@types/node@22.13.10)(terser@5.39.0)(yaml@2.7.0))(vitest@3.0.6) @@ -885,6 +888,9 @@ importers: rimraf: specifier: ^6.0.1 version: 6.0.1 + sinon: + specifier: ^17.0.2 + version: 17.0.2 source-map-loader: specifier: ^5.0.0 version: 5.0.0(webpack@5.97.1) diff --git a/core/common/src/ContextRealityModel.ts b/core/common/src/ContextRealityModel.ts index d44e009e01d1..620cd69cf4d9 100644 --- a/core/common/src/ContextRealityModel.ts +++ b/core/common/src/ContextRealityModel.ts @@ -28,22 +28,22 @@ export interface OrbitGtBlobProps { */ export enum RealityDataProvider { /** - * This is the legacy mode where the access to the 3d tiles is harcoded in ContextRealityModelProps.tilesetUrl property. - * It was use to support RealityMesh3DTiles, Terrain3DTiles, Cesium3DTiles - * You should use other mode when possible + * This is the legacy mode where the access to the 3d tiles is hardcoded in ContextRealityModelProps.tilesetUrl property. + * It was used to support RealityMesh3DTiles, Terrain3DTiles, Cesium3DTiles + * You should use other modes when possible * @see [[RealityDataSource.createKeyFromUrl]] that will try to detect provider from an URL */ TilesetUrl = "TilesetUrl", /** - * This is the legacy mode where the access to the 3d tiles is harcoded in ContextRealityModelProps.OrbitGtBlob property. - * It was use to support OrbitPointCloud (OPC) from other server than ContextShare - * You should use other mode when possible + * This is the legacy mode where the access to the 3d tiles is hardcoded in ContextRealityModelProps.OrbitGtBlob property. + * It was used to support OrbitPointCloud (OPC) from other server than ContextShare + * You should use other modes when possible * @see [[RealityDataSource.createKeyFromOrbitGtBlobProps]] that will try to detect provider from an URL */ OrbitGtBlob = "OrbitGtBlob", /** - * Will provide access url from realityDataId and iTwinId on contextShare for 3dTile storage format or OPC storage format - * This provider support all type of 3dTile storage fomat and OrbitPointCloud: RealityMesh3DTiles, Terrain3DTiles, Cesium3DTiles, OPC + * Will provide access url from realityDataId and iTwinId on contextShare for 3dTile storage format or OPC storage format + * This provider supports all types of 3dTile storage format and OrbitPointCloud: RealityMesh3DTiles, Terrain3DTiles, Cesium3DTiles, OPC * @see [[RealityDataFormat]]. */ ContextShare = "ContextShare", diff --git a/core/frontend/package.json b/core/frontend/package.json index ed0ef5c18471..ebe950370bef 100644 --- a/core/frontend/package.json +++ b/core/frontend/package.json @@ -69,6 +69,7 @@ "@itwin/ecschema-rpcinterface-common": "workspace:*", "@itwin/eslint-plugin": "5.0.0-dev.1", "@types/chai-as-promised": "^7", + "@types/sinon": "^17.0.2", "@vitest/browser": "^3.0.6", "@vitest/coverage-v8": "^3.0.6", "cpx2": "^8.0.0", @@ -76,6 +77,7 @@ "glob": "^10.3.12", "playwright": "~1.47.1", "rimraf": "^6.0.1", + "sinon": "^17.0.2", "source-map-loader": "^5.0.0", "typescript": "~5.6.2", "typemoq": "^2.1.0", @@ -98,4 +100,4 @@ "fuse.js": "^3.3.0", "wms-capabilities": "0.4.0" } -} +} \ No newline at end of file diff --git a/core/frontend/src/IModelApp.ts b/core/frontend/src/IModelApp.ts index 9232cd1231bb..6a0f284c0053 100644 --- a/core/frontend/src/IModelApp.ts +++ b/core/frontend/src/IModelApp.ts @@ -234,7 +234,7 @@ export class IModelApp { /** The [[TerrainProviderRegistry]] for this session. */ public static get terrainProviderRegistry(): TerrainProviderRegistry { return this._terrainProviderRegistry; } /** The [[RealityDataSourceProviderRegistry]] for this session. - * @alpha + * @beta */ public static get realityDataSourceProviders(): RealityDataSourceProviderRegistry { return this._realityDataSourceProviders; } /** The [[RenderSystem]] for this session. */ diff --git a/core/frontend/src/RealityDataSource.ts b/core/frontend/src/RealityDataSource.ts index 82a692e5bc9c..d4aca1fec9c8 100644 --- a/core/frontend/src/RealityDataSource.ts +++ b/core/frontend/src/RealityDataSource.ts @@ -5,15 +5,19 @@ /** @packageDocumentation * @module Tiles */ -import { BentleyError, GuidString, Logger, LoggingMetaData, RealityDataStatus } from "@itwin/core-bentley"; +import { BentleyError, BentleyStatus, GuidString, Logger, LoggingMetaData, RealityDataStatus } from "@itwin/core-bentley"; import { Cartographic, EcefLocation, OrbitGtBlobProps, RealityData, RealityDataFormat, RealityDataProvider, RealityDataSourceKey } from "@itwin/core-common"; +import { Range3d } from "@itwin/core-geometry"; import { FrontendLoggerCategory } from "./common/FrontendLoggerCategory"; import { CesiumIonAssetProvider, ContextShareProvider, getCesiumAssetUrl } from "./tile/internal"; import { RealityDataSourceTilesetUrlImpl } from "./RealityDataSourceTilesetUrlImpl"; import { RealityDataSourceContextShareImpl } from "./RealityDataSourceContextShareImpl"; import { RealityDataSourceCesiumIonAssetImpl } from "./RealityDataSourceCesiumIonAssetImpl"; +import { RealityDataSourceGoogle3dTilesImpl } from "./internal/RealityDataSourceGoogle3dTilesImpl"; import { IModelApp } from "./IModelApp"; -import { Range3d } from "@itwin/core-geometry"; +import { getCopyrights, GoogleMapsDecorator } from "./internal/GoogleMapsDecorator"; +import { DecorateContext } from "./ViewContext"; +import { ScreenViewport } from "./Viewport"; const loggerCategory: string = FrontendLoggerCategory.RealityData; @@ -219,8 +223,9 @@ export namespace RealityDataSource { * The provider's name is stored in a [RealityDataSourceKey]($common). When the [[RealityDataSource]] is requested from the key, * the provider is looked up in [[IModelApp.realityDataSourceProviders]] by its name and, if found, its [[createRealityDataSource]] method * is invoked to produce the reality data source. - * @alpha + * @beta */ + export interface RealityDataSourceProvider { /** Produce a RealityDataSource for the specified `key`. * @param key Identifies the reality data source. @@ -228,12 +233,20 @@ export interface RealityDataSourceProvider { * @returns the requested reality data source, or `undefined` if it could not be produced. */ createRealityDataSource(key: RealityDataSourceKey, iTwinId: GuidString | undefined): Promise; + /** Optionally add any decorations specific to this reality data source provider. + * For example, the Google Photorealistic 3D Tiles reality data source provider will add the Google logo. + */ + decorate?(_context: DecorateContext): void; + /** Optionally add attribution logo cards to the viewport's logo div. */ + addAttributions?(cards: HTMLTableElement, vp: ScreenViewport): Promise; + /** Enables cached decorations for this provider. @see [[ViewportDecorator.useCachedDecorations]] */ + useCachedDecorations?: true | undefined; } /** A registry of [[RealityDataSourceProvider]]s identified by their unique names. The registry can be accessed via [[IModelApp.realityDataSourceProviders]]. * It includes a handful of built-in providers for sources like Cesium ION, ContextShare, OrbitGT, and arbitrary public-accessible URLs. - * Any number of additional providers can be registered. They should typically be registered just after [[IModelAp.startup]]. - * @alpha + * Any number of additional providers can be registered. They should typically be registered just after [[IModelApp.startup]]. + * @beta */ export class RealityDataSourceProviderRegistry { private readonly _providers = new Map(); @@ -265,3 +278,104 @@ export class RealityDataSourceProviderRegistry { return this._providers.get(name); } } + +/** + * Options for creating a Google Photorealistic 3D Tiles reality data source provider. + * The caller must provide either an API key or a function that returns an auth token. + * @beta + */ +export type Google3dTilesProviderOptions = { + /** Google Map Tiles API Key used to access Google 3D Tiles. */ + apiKey: string; + getAuthToken?: never; + /** If true, the data attributions/copyrights from the Google 3D Tiles will be displayed on screen. The Google Maps logo will always be displayed. Defaults to `true`. */ + showCreditsOnScreen?: boolean +} | { + apiKey?: never; + /** Function that returns an OAuth token for authenticating with Google 3D Tiles. This token is expected to not contain the "Bearer" prefix. */ + getAuthToken: () => Promise; + /** If true, the data attributions/copyrights from the Google 3D Tiles will be displayed on screen. The Google Maps logo will always be displayed. Defaults to `true`. */ + showCreditsOnScreen?: boolean; +}; + +/** + * Will provide Google Photorealistic 3D Tiles (in 3dTile format). + * A valid API key or getAuthToken fuction must be supplied when creating this provider. + * To use this provider, you must register it with [[IModelApp.realityDataSourceProviders]]. + * Example usage: + * ```ts + * [[include:GooglePhotorealistic3dTiles_providerApiKey]] + * ``` + * @see [Google Photorealistic 3D Tiles]($docs/learning/frontend/GooglePhotorealistic3dTiles.md) + * @beta + */ +export class Google3dTilesProvider implements RealityDataSourceProvider { + /** Google Map Tiles API Key used to access Google 3D Tiles. */ + private _apiKey?: string; + /** Function that returns an OAuth token for authenticating with Google 3D Tiles. This token is expected to not contain the "Bearer" prefix. */ + private _getAuthToken?: () => Promise; + /** Decorator for Google Maps logos. */ + private _decorator: GoogleMapsDecorator; + /** Enables cached decorations for this provider. @see [[ViewportDecorator.useCachedDecorations]] */ + public readonly useCachedDecorations = true; + + public async createRealityDataSource(key: RealityDataSourceKey, iTwinId: GuidString | undefined): Promise { + if (!this._apiKey && !this._getAuthToken) { + Logger.logError(loggerCategory, "Either an API key or getAuthToken function are required to create a Google3dTilesProvider."); + return undefined; + } + return RealityDataSourceGoogle3dTilesImpl.createFromKey(key, iTwinId, this._apiKey, this._getAuthToken); + } + + public constructor(options: Google3dTilesProviderOptions) { + this._apiKey = options.apiKey; + this._getAuthToken = options.getAuthToken; + this._decorator = new GoogleMapsDecorator(options.showCreditsOnScreen ?? true); + } + + /** + * Initialize the Google 3D Tiles reality data source provider by activating its decorator, which consists of loading the correct Google Maps logo. + * @returns `true` if the decorator was successfully activated, otherwise `false`. + */ + public async initialize(): Promise { + const isActivated = await this._decorator.activate("satellite"); + if (!isActivated) { + const msg = "Failed to activate decorator"; + Logger.logError(loggerCategory, msg); + throw new BentleyError(BentleyStatus.ERROR, msg); + } + return isActivated; + } + + public decorate(_context: DecorateContext): void { + this._decorator.decorate(_context); + } + + public async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + const copyrightMap = getCopyrights(vp); + + // Only add another logo card if the tiles have copyright + if (copyrightMap.size > 0) { + // Order by most occurances to least + // See https://developers.google.com/maps/documentation/tile/create-renderer#display-attributions + const sortedCopyrights = [...copyrightMap.entries()].sort((a, b) => b[1] - a[1]); + + let copyrightMsg = "Data provided by:
    "; + copyrightMsg += sortedCopyrights.map(([key]) => `
  • ${key}
  • `).join(""); + copyrightMsg += "
"; + + cards.appendChild(IModelApp.makeLogoCard({ + iconSrc: `${IModelApp.publicPath}images/google_on_white_hdpi.png`, + heading: "Google Photorealistic 3D Tiles", + notice: copyrightMsg + })); + } + } +} + +/** Returns the URL used for retrieving Google Photorealistic 3D Tiles. + * @beta + */ +export function getGoogle3dTilesUrl() { + return "https://tile.googleapis.com/v1/3dtiles/root.json"; +} diff --git a/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts b/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts index dfb9b6e3968a..d8e77320354b 100644 --- a/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts +++ b/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts @@ -74,7 +74,7 @@ export class RealityDataSourceTilesetUrlImpl implements RealityDataSource { // otherwise the full path to root document is given. // The base URL contains the base URL from which tile relative path are constructed. // The tile's path root will need to be reinserted for child tiles to return a 200 - // If the original url includes search paramaters, they are stored in _searchParams to be reinserted into child tile requests. + // If the original root tileset url includes search paramaters, they are stored in _searchParams to be reinserted into child tile requests. private setBaseUrl(url: string): void { const urlParts = url.split("/"); const newUrl = new URL(url); diff --git a/core/frontend/src/internal/GoogleMapsDecorator.ts b/core/frontend/src/internal/GoogleMapsDecorator.ts new file mode 100644 index 000000000000..633e6360ea0e --- /dev/null +++ b/core/frontend/src/internal/GoogleMapsDecorator.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { Point3d } from "@itwin/core-geometry"; +import { CanvasDecoration } from "../render/CanvasDecoration"; +import { DecorateContext } from "../ViewContext"; +import { IModelApp } from "../IModelApp"; +import { Decorator } from "../ViewManager"; +import { IconSprites, Sprite } from "../Sprites"; +import { RealityTile } from "../tile/internal"; +import { ScreenViewport } from "../Viewport"; + +/** Layer types that can be added to the map. + * @internal + */ +type GoogleMapsMapTypes = "roadmap" | "satellite" | "terrain"; + +/** A simple decorator that shows the logo at a given screen position. + * @internal + */ +export class LogoDecoration implements CanvasDecoration { + private _sprite?: Sprite; + + /** The current position of the logo in view coordinates. */ + public readonly position = new Point3d(); + + private _offset: Point3d | undefined; + + public set offset(offset: Point3d | undefined) { + this._offset = offset; + } + + /** The logo offset in view coordinates.*/ + public get offset() { + return this._offset; + } + + /** Move the logo to the lower left corner of the screen. */ + public moveToLowerLeftCorner(context: DecorateContext): boolean { + if (!this._sprite || !this._sprite.isLoaded) + return false; + + this.position.x = this._offset?.x ?? 0; + this.position.y = context.viewport.parentDiv.clientHeight - this._sprite.size.y; + if (this._offset?.y) + this.position.y -= this._offset.y; + return true; + } + + /* TODO: Add other move methods as needed */ + + /** Indicate if the logo is loaded and ready to be drawn. */ + public get isLoaded() { return this._sprite?.isLoaded ?? false; } + + public async activate(sprite: Sprite): Promise { + this._sprite = sprite; + return new Promise((resolve, _reject) => { + sprite.loadPromise.then(() => { + resolve(true); + }).catch(() => { + resolve (false); + }); + }); + } + + /** Draw this sprite onto the supplied canvas. + * @see [[CanvasDecoration.drawDecoration]] + */ + public drawDecoration(ctx: CanvasRenderingContext2D): void { + if (this.isLoaded) { + // Draw image with an origin at the top left corner + ctx.drawImage(this._sprite!.image!, 0, 0); + } + } + + public decorate(context: DecorateContext) { + context.addCanvasDecoration(this); + } +} + +/** A decorator that adds the Google Maps logo to the lower left corner of the screen. + * @internal + */ +export class GoogleMapsDecorator implements Decorator { + public readonly logo = new LogoDecoration(); + private _showCreditsOnScreen?: boolean; + + /** Create a new GoogleMapsDecorator. + * @param showCreditsOnScreen If true, the data attributions/copyrights from the Google Photorealistic 3D Tiles will be displayed on screen. The Google Maps logo will always be displayed. + */ + constructor(showCreditsOnScreen?: boolean) { + this._showCreditsOnScreen = showCreditsOnScreen; + } + + /** Activate the logo based on the given map type. */ + public async activate(mapType: GoogleMapsMapTypes): Promise { + // Pick the logo that is the most visible on the background map + const imageName = mapType === "roadmap" ? + "google_on_white" : + "google_on_non_white"; + + // We need to move the logo right after the 'i.js' button + this.logo.offset = new Point3d(45, 10); + + return this.logo.activate(IconSprites.getSpriteFromUrl(`${IModelApp.publicPath}images/${imageName}.png`)); + }; + + /** Decorate implementation */ + public decorate = (context: DecorateContext) => { + if (!this.logo.isLoaded) + return; + this.logo.moveToLowerLeftCorner(context); + this.logo.decorate(context); + + if (!this._showCreditsOnScreen) + return; + + // Get data attribution (copyright) text + const copyrightMap = getCopyrights(context.viewport); + // Order by most occurances to least + // See https://developers.google.com/maps/documentation/tile/create-renderer#display-attributions + const sortedCopyrights = [...copyrightMap.entries()].sort((a, b) => b[1] - a[1]); + const copyrightText = sortedCopyrights.map(([key]) => ` • ${key}`).join(""); + + // Create and add element, offset to leave space for i.js and Google logos + const elem = document.createElement("div"); + elem.innerHTML = copyrightText; + elem.style.color = "white"; + elem.style.fontSize = "11px"; + elem.style.textWrap = "wrap"; + elem.style.position = "absolute"; + elem.style.bottom = "10px"; + elem.style.left = "107px"; + + context.addHtmlDecoration(elem); + }; +} + +/** Get copyrights from tiles currently in the viewport. + * @internal + */ +export function getCopyrights(vp: ScreenViewport): Map { + const tiles = IModelApp.tileAdmin.getTilesForUser(vp)?.selected; + const copyrightMap = new Map(); + if (tiles) { + for (const tile of tiles as Set) { + if (tile.copyright) { + for (const copyright of tile.copyright.split(";")) { + const currentCount = copyrightMap.get(copyright); + copyrightMap.set(copyright, currentCount ? currentCount + 1 : 1); + } + } + } + } + return copyrightMap; +} \ No newline at end of file diff --git a/core/frontend/src/internal/RealityDataSourceGoogle3dTilesImpl.ts b/core/frontend/src/internal/RealityDataSourceGoogle3dTilesImpl.ts new file mode 100644 index 000000000000..8a2644942e0d --- /dev/null +++ b/core/frontend/src/internal/RealityDataSourceGoogle3dTilesImpl.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ +/** @packageDocumentation + * @module Tiles + */ +import { BentleyStatus, GuidString } from "@itwin/core-bentley"; +import { IModelError, RealityData, RealityDataFormat, RealityDataSourceKey, RealityDataSourceProps } from "@itwin/core-common"; + +import { request } from "../request/Request"; +import { PublisherProductInfo, RealityDataSource, SpatialLocationAndExtents } from "../RealityDataSource"; +import { ThreeDTileFormatInterpreter } from "../tile/internal"; + +/** This class provides access to the reality data provider services. + * It encapsulates access to a reality data from the Google Photorealistic 3D Tiles service. + * A valid Google 3D Tiles authentication key must be configured for this provider to work (provide the key in the [[RealityDataSourceGoogle3dTilesImpl.createFromKey]] method). +* @internal +*/ +export class RealityDataSourceGoogle3dTilesImpl implements RealityDataSource { + public readonly key: RealityDataSourceKey; + /** The URL that supplies the 3d tiles for displaying the Google 3D Tiles tileset. */ + private _tilesetUrl: string | undefined; + /** Base URL of the Google 3D Tiles tileset. Does not include trailing subdirectories. */ + private _baseUrl: string = "" + /** Search parameters that must be passed down to child tile requests. */ + private _searchParams?: URLSearchParams; + /** Google Map Tiles API Key used to access Google 3D Tiles. */ + private _apiKey?: string; + /** Function that returns an OAuth token for authenticating with GP3sDT. This token is expected to not contain the "Bearer" prefix. */ + private _getAuthToken?: () => Promise; + + /** This is necessary for Google 3D Tiles tilesets! This tells the iTwin.js tiling system to use the geometric error specified in the tileset rather than any of our own. */ + public readonly usesGeometricError = true; + public readonly maximumScreenSpaceError = 16; + + /** Construct a new reality data source. + * @param props JSON representation of the reality data source + */ + protected constructor(props: RealityDataSourceProps, apiKey: string | undefined, _getAuthToken?: () => Promise) { + this.key = props.sourceKey; + this._tilesetUrl = this.key.id; + this._apiKey = apiKey; + this._getAuthToken = _getAuthToken; + } + + /** + * Create an instance of this class from a source key and iTwin context. + */ + public static async createFromKey(sourceKey: RealityDataSourceKey, _iTwinId: GuidString | undefined, apiKey: string | undefined, _getAuthToken?: () => Promise): Promise { + return new RealityDataSourceGoogle3dTilesImpl({ sourceKey }, apiKey, _getAuthToken); + } + + public get isContextShare(): boolean { + return false; + } + /** + * Returns Reality Data if available + */ + public get realityData(): RealityData | undefined { + return undefined; + } + public get realityDataId(): string | undefined { + return undefined; + } + /** + * Returns Reality Data type if available + */ + public get realityDataType(): string | undefined { + return undefined; + } + + public getTilesetUrl(): string | undefined { + return this._tilesetUrl; + } + + /** Return the URL of the Google 3D Tiles tileset with its API key included. */ + private getTilesetUrlWithKey() { + const google3dTilesKey = this._apiKey; + if (this._getAuthToken) { + // If we have a getAuthToken function, no need to append API key to the URL + return this._tilesetUrl; + } else { + return `${this._tilesetUrl}?key=${google3dTilesKey}`; + } + } + + protected setBaseUrl(url: string): void { + const urlParts = url.split("/"); + const newUrl = new URL(url); + this._searchParams = newUrl.searchParams; + urlParts.pop(); + if (urlParts.length === 0) { + this._baseUrl = ""; + } else { + this._baseUrl = newUrl.origin; + } + } + + /** + * This method returns the URL to access the actual 3d tiles from the service provider. + * @returns string containing the URL to reality data. + */ + public async getServiceUrl(_iTwinId: GuidString | undefined): Promise { + return this._tilesetUrl; + } + + public async getRootDocument(_iTwinId: GuidString | undefined): Promise { + const url = this.getTilesetUrlWithKey(); + if (!url) + throw new IModelError(BentleyStatus.ERROR, "Unable to get service url"); + + this.setBaseUrl(url); + + let authToken; + if (this._getAuthToken) { + authToken = await this._getAuthToken(); + } + + return request(url, "json", authToken ? { + headers: { + authorization: `Bearer ${authToken}` + }} : undefined + ); + } + + /** Returns the tile URL relative to the base URL. + * If the tile path is a relative URL, the base URL is prepended to it. + * For both absolute and relative tile path URLs, the search parameters are checked. If the search params are empty, the base URL's search params are appended to the tile path. + */ + public getTileUrl(tilePath: string): string { + // this._baseUrl does not include the trailing subdirectories. + // This is not an issue because the tile path always starts with the appropriate subdirectories. + // We also do not need to worry about the tile path starting with a slash. + // This happens in these tiles at the second .json level, but the URL API will handle that for us. + const url = new URL(tilePath, this._baseUrl); + + // If tile is a reference to a tileset, iterate over tileset url's search params and store them in this._searchParams so we can pass them down to children + if (this.getTileContentType(url.toString()) === "tileset" && url.searchParams.size !== 0) { + for (const [key, value] of url.searchParams.entries()) { + this._searchParams?.append(key, value); + } + } + + if (this._searchParams === undefined || this._searchParams.size === 0) { + return url.toString(); + } + + // Append all stored search params to url's existing ones + const newUrl = new URL(url.toString()); + for (const [key, value] of this._searchParams.entries()) { + if (!url.searchParams.has(key)) { + // Only append the search param if it does not already exist in the url + newUrl.searchParams.append(key, value); + } + } + + return newUrl.toString(); + } + + /** + * Returns the tile content. The path to the tile is relative to the base url of present reality data whatever the type. + */ + public async getTileContent(name: string): Promise { + let authToken; + if (this._getAuthToken) { + authToken = await this._getAuthToken(); + } + + return request(this.getTileUrl(name), "arraybuffer", authToken ? { + headers: { + authorization: `Bearer ${authToken}` + }} : undefined + ); + } + + /** + * Returns the tile content in json format. The path to the tile is relative to the base url of present reality data whatever the type. + */ + public async getTileJson(name: string): Promise { + let authToken; + if (this._getAuthToken) { + authToken = await this._getAuthToken(); + } + + return request(this.getTileUrl(name), "json", authToken ? { + headers: { + authorization: `Bearer ${authToken}` + }} : undefined + ); + } + + public getTileContentType(url: string): "tile" | "tileset" { + return new URL(url, "https://localhost/").pathname.toLowerCase().endsWith("json") ? "tileset" : "tile"; + } + + /** + * Gets spatial location and extents of this reality data source + * @returns spatial location and extents + * @internal + */ + public async getSpatialLocationAndExtents(): Promise { + let spatialLocation: SpatialLocationAndExtents | undefined; + if (this.key.format === RealityDataFormat.ThreeDTile) { + const rootDocument = await this.getRootDocument(undefined); + spatialLocation = ThreeDTileFormatInterpreter.getSpatialLocationAndExtents(rootDocument); + } + return spatialLocation; + } + /** + * Gets information to identify the product and engine that create this reality data + * Will return undefined if cannot be resolved + * @returns information to identify the product and engine that create this reality data + * @alpha + */ + public async getPublisherProductInfo(): Promise { + let publisherInfo: PublisherProductInfo | undefined; + return publisherInfo; + } +} + diff --git a/core/frontend/src/internal/cross-package.ts b/core/frontend/src/internal/cross-package.ts index 756efa3ccde6..1d4ac16377ae 100644 --- a/core/frontend/src/internal/cross-package.ts +++ b/core/frontend/src/internal/cross-package.ts @@ -50,6 +50,7 @@ export { WmsUtilities, LayerTileTreeHandler, type MapLayerTreeSetting, LayerTileTreeReferenceHandler } from "../tile/internal"; +export { GoogleMapsDecorator, LogoDecoration } from "./GoogleMapsDecorator"; // Used by display-test-app which currently builds using both ESModules and CommonJS. // Remove once CommonJS is dropped. diff --git a/core/frontend/src/internal/tile/RealityModelTileTree.ts b/core/frontend/src/internal/tile/RealityModelTileTree.ts index 7d8fd9e2200b..be6eb83dd5a4 100644 --- a/core/frontend/src/internal/tile/RealityModelTileTree.ts +++ b/core/frontend/src/internal/tile/RealityModelTileTree.ts @@ -24,7 +24,7 @@ import { IModelConnection } from "../../IModelConnection"; import { PlanarClipMaskState } from "../../PlanarClipMaskState"; import { RealityDataSource } from "../../RealityDataSource"; import { RenderMemory } from "../../render/RenderMemory"; -import { SceneContext } from "../../ViewContext"; +import { DecorateContext, SceneContext } from "../../ViewContext"; import { ViewState } from "../../ViewState"; import { BatchedTileIdMap, CesiumIonAssetProvider, createClassifierTileTreeReference, createDefaultViewFlagOverrides, DisclosedTileTreeSet, GeometryTileTreeReference, @@ -33,6 +33,7 @@ import { } from "../../tile/internal"; import { SpatialClassifiersState } from "../../SpatialClassifiersState"; import { RealityDataSourceTilesetUrlImpl } from "../../RealityDataSourceTilesetUrlImpl"; +import { ScreenViewport } from "../../Viewport"; function getUrl(content: any) { return content ? (content.url ? content.url : content.uri) : undefined; @@ -260,7 +261,7 @@ export class RealityModelTileTreeProps { public get usesGeometricError(): boolean { return undefined !== this.maximumScreenSpaceError; } - + constructor(json: any, root: any, rdSource: RealityDataSource, tilesetToDbTransform: Transform, public readonly tilesetToEcef?: Transform) { this.tilesetJson = root; this.dataSource = rdSource; @@ -269,12 +270,12 @@ export class RealityModelTileTreeProps { if (json.asset.gltfUpAxis === undefined || json.asset.gltfUpAxis === "y" || json.asset.gltfUpAxis === "Y") { this.yAxisUp = true; } - + const maxSSE = json.asset.extras?.maximumScreenSpaceError; if (typeof maxSSE === "number") { this.maximumScreenSpaceError = json.asset.extras?.maximumScreenSpaceError; } else if (rdSource.usesGeometricError) { - this.maximumScreenSpaceError = rdSource.maximumScreenSpaceError ?? 1; + this.maximumScreenSpaceError = rdSource.maximumScreenSpaceError ?? 16; } } } @@ -816,6 +817,7 @@ export class RealityTreeReference extends RealityModelTileTree.Reference { protected _rdSourceKey: RealityDataSourceKey; private readonly _produceGeometry?: boolean; private readonly _modelId: Id64String; + public readonly useCachedDecorations?: true | undefined; public constructor(props: RealityModelTileTree.ReferenceProps) { super(props); @@ -837,6 +839,8 @@ export class RealityTreeReference extends RealityModelTileTree.Reference { } this._modelId = modelId ?? props.iModel.transientIds.getNext(); + const provider = IModelApp.realityDataSourceProviders.find(this._rdSourceKey.provider); + this.useCachedDecorations = provider?.useCachedDecorations; } public override get modelId() { return this._modelId; } @@ -993,9 +997,17 @@ export class RealityTreeReference extends RealityModelTileTree.Reference { } } - public override async addAttributions(cards: HTMLTableElement): Promise { - // eslint-disable-next-line @typescript-eslint/no-deprecated - return Promise.resolve(this.addLogoCards(cards)); + public override async addAttributions(cards: HTMLTableElement, vp: ScreenViewport): Promise { + const provider = IModelApp.realityDataSourceProviders.find(this._rdSourceKey.provider); + if (provider?.addAttributions) { + await provider.addAttributions(cards, vp); + } } -} + public override decorate(_context: DecorateContext): void { + const provider = IModelApp.realityDataSourceProviders.find(this._rdSourceKey.provider); + if (provider?.decorate) { + provider.decorate(_context); + } + } +} diff --git a/core/frontend/src/public/images/google_on_non_white.png b/core/frontend/src/public/images/google_on_non_white.png new file mode 100644 index 000000000000..44f200802a1c Binary files /dev/null and b/core/frontend/src/public/images/google_on_non_white.png differ diff --git a/core/frontend/src/public/images/google_on_non_white_hdpi.png b/core/frontend/src/public/images/google_on_non_white_hdpi.png new file mode 100644 index 000000000000..393d03005d5b Binary files /dev/null and b/core/frontend/src/public/images/google_on_non_white_hdpi.png differ diff --git a/core/frontend/src/public/images/google_on_white.png b/core/frontend/src/public/images/google_on_white.png new file mode 100644 index 000000000000..4d2c66933927 Binary files /dev/null and b/core/frontend/src/public/images/google_on_white.png differ diff --git a/core/frontend/src/public/images/google_on_white_hdpi.png b/core/frontend/src/public/images/google_on_white_hdpi.png new file mode 100644 index 000000000000..5424b56d5da6 Binary files /dev/null and b/core/frontend/src/public/images/google_on_white_hdpi.png differ diff --git a/core/frontend/src/test/Google3dTilesProvider.test.ts b/core/frontend/src/test/Google3dTilesProvider.test.ts new file mode 100644 index 000000000000..674672388cc2 --- /dev/null +++ b/core/frontend/src/test/Google3dTilesProvider.test.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import sinon from "sinon"; +import { Frustum } from "@itwin/core-common"; +import { Range3d } from "@itwin/core-geometry"; +import { IModelApp } from "../IModelApp"; +import { IModelConnection } from "../IModelConnection"; +import { LogoDecoration } from "../internal/GoogleMapsDecorator"; +import { IconSprites, Sprite } from "../Sprites"; +import { RealityTile, RealityTileTree, TileAdmin } from "../tile/internal"; +import { Google3dTilesProvider } from "../RealityDataSource"; +import { createBlankConnection } from "./createBlankConnection"; +import { ScreenViewport } from "../Viewport"; +import { DecorateContext } from "../ViewContext"; +import { Decorations } from "../render/Decorations"; + +class FakeRealityTile extends RealityTile { + constructor(contentId: string, copyright?: string) { + super( + {contentId, range:Range3d.createNull(), maximumSize: 256}, + {} as RealityTileTree + ); + this._copyright = copyright; + } +} + +describe("Google3dTilesProvider", () => { + const sandbox = sinon.createSandbox(); + let iModel: IModelConnection; + let getSpriteStub: sinon.SinonStub; + let addCanvasDecorationStub: sinon.SinonStub; + let addHTMLDecorationStub: sinon.SinonStub; + let context: DecorateContext; + + beforeAll(async () => { + await IModelApp.startup(); + iModel = createBlankConnection(); + }); + + beforeEach(() => { + sinon.stub(IModelApp, "publicPath").get(() => "public/"); + sandbox.stub(LogoDecoration.prototype, "activate").callsFake(async function _(_sprite: Sprite) { + return Promise.resolve(true); + }); + sinon.stub(LogoDecoration.prototype, "isLoaded").get(() => true); + sandbox.stub(TileAdmin.prototype as any, "getTilesForUser").callsFake(function _(_vp: unknown) { + const set = { + selected: new Set() + }; + set.selected.add(new FakeRealityTile("testId1", "Bentley Systems, Inc.")); + set.selected.add(new FakeRealityTile("testId2", "Google")); + set.selected.add(new FakeRealityTile("testId3", "Google")); + return set; + }); + + getSpriteStub = sandbox.stub(IconSprites, "getSpriteFromUrl").callsFake(function _(_url: string) { + return {} as Sprite; + }); + addCanvasDecorationStub = sinon.stub(DecorateContext.prototype, "addCanvasDecoration"); + addHTMLDecorationStub = sinon.stub(DecorateContext.prototype, "addHtmlDecoration"); + context = DecorateContext.create({ + viewport: { getFrustum: () => new Frustum(), decorationDiv: document.createElement("div") } as ScreenViewport, + output: new Decorations() + }); + }); + + afterAll(async () => { + await iModel.close(); + await IModelApp.shutdown(); + }); + + afterEach(async () => { + sandbox.restore(); + sinon.restore(); + getSpriteStub.restore(); + addCanvasDecorationStub.restore(); + addHTMLDecorationStub.restore(); + }); + + it("should add attributions", async () => { + const provider = new Google3dTilesProvider({ apiKey: "testApiKey" }); + expect(await provider.initialize()).to.be.true; + + const table = document.createElement("table"); + await provider.addAttributions(table, {} as ScreenViewport); + + expect(table.innerHTML).to.includes(``); + expect(table.innerHTML).to.includes(`

Google Photorealistic 3D Tiles

`); + expect(table.innerHTML).to.includes(`Data provided by:
  • Google
  • Bentley Systems, Inc.
`); + }); + + it("should decorate Google logo and attributions on screen", async () => { + const provider = new Google3dTilesProvider({ apiKey: "testApiKey" }); + expect(await provider.initialize()).to.be.true; + provider.decorate(context); + + expect(addCanvasDecorationStub.called).to.be.true; + expect(addHTMLDecorationStub.called).to.be.true; + const htmlDecorationStr = `
• Google • Bentley Systems, Inc.
`; + expect(addHTMLDecorationStub.firstCall.args[0].outerHTML).to.eq(htmlDecorationStr); + expect(getSpriteStub.firstCall.args[0]).to.eq("public/images/google_on_non_white.png"); + }); + + it("should not decorate attributions on screen when showCreditsOnscreen is false", async () => { + const provider = new Google3dTilesProvider({ apiKey: "testApiKey", showCreditsOnScreen: false }); + expect(await provider.initialize()).to.be.true; + provider.decorate(context); + + expect(addHTMLDecorationStub.called).to.be.false; + }); +}); \ No newline at end of file diff --git a/core/frontend/src/test/RealityDataSource.test.ts b/core/frontend/src/test/RealityDataSource.test.ts index f7f6be07d28b..32730809532e 100644 --- a/core/frontend/src/test/RealityDataSource.test.ts +++ b/core/frontend/src/test/RealityDataSource.test.ts @@ -6,7 +6,7 @@ import { OrbitGtBlobProps, RealityDataFormat, RealityDataProvider, RealityDataSourceKey } from "@itwin/core-common"; import { describe, expect, it } from "vitest"; import { CesiumIonAssetProvider, getCesiumAssetUrl } from "../tile/internal"; -import { RealityDataSource } from "../RealityDataSource"; +import { getGoogle3dTilesUrl, Google3dTilesProvider, RealityDataSource } from "../RealityDataSource"; describe("RealityDataSource", () => { it("should handle creation from empty url", () => { @@ -286,4 +286,13 @@ describe("RealityDataSource", () => { const rdSourceKeyStr = RealityDataSourceKey.convertToString(rdSourceKey); expect(rdSourceKeyStr).toEqual("ContextShare:OPC:994fc408-401f-4ee1-91f0-3d7bfba50136:5b4ebd22-d94b-456b-8bd8-d59563de9acd"); }); + it("should handle creation from Google3dTilesProvider", async () => { + const provider = new Google3dTilesProvider({ apiKey: "testApiKey" }); + const rdSourceKey = RealityDataSource.createKeyFromUrl(getGoogle3dTilesUrl()); + const rdSource = await provider.createRealityDataSource(rdSourceKey, undefined); + expect(rdSource).to.be.toBeDefined; + expect(rdSourceKey.provider).toEqual("TilesetUrl"); + expect(rdSourceKey.format).toEqual(RealityDataFormat.ThreeDTile); + expect(rdSourceKey.id).toEqual(getGoogle3dTilesUrl()); + }); }); diff --git a/core/frontend/src/test/RealityDataSourceGoogle3dTilesImpl.test.ts b/core/frontend/src/test/RealityDataSourceGoogle3dTilesImpl.test.ts new file mode 100644 index 000000000000..f9d323a32dd8 --- /dev/null +++ b/core/frontend/src/test/RealityDataSourceGoogle3dTilesImpl.test.ts @@ -0,0 +1,214 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from "vitest"; +import { RealityDataSourceKey, RealityDataSourceProps } from "@itwin/core-common"; +import { RealityDataSourceGoogle3dTilesImpl } from "../internal/RealityDataSourceGoogle3dTilesImpl"; +import { getGoogle3dTilesUrl, Google3dTilesProvider } from "../RealityDataSource"; + +describe("RealityDataSourceGoogle3dTilesImpl", async () => { + const provider = new Google3dTilesProvider({ apiKey: "testApiKey" }); + const rdSourceKey = { + provider: "Test Google 3D Tiles provider", + format: "ThreeDTile", + id: getGoogle3dTilesUrl() + } + const rdSource = await provider.createRealityDataSource(rdSourceKey, undefined); + + it("handle content type of relative urls", async () => { + expect(rdSource).toBeDefined(); + expect(rdSource?.getTileContentType("tileset.json")).toEqual("tileset"); + expect(rdSource?.getTileContentType("tile.glb")).toEqual("tile"); + expect(rdSource?.getTileContentType("./tileset.json")).toEqual("tileset"); + expect(rdSource?.getTileContentType("./tile.glb")).toEqual("tile"); + expect(rdSource?.getTileContentType("../tilesets/tileset.json")).toEqual("tileset"); + expect(rdSource?.getTileContentType("../models/tile.glb")).toEqual("tile"); + expect(rdSource?.getTileContentType("tilesets/tileset.json")).toEqual("tileset"); + expect(rdSource?.getTileContentType("models/tile.glb")).toEqual("tile"); + + expect(rdSource?.getTileContentType("tileset.json?a=b&c=d")).toEqual("tileset"); + expect(rdSource?.getTileContentType("tile.glb?a=b&c=d")).toEqual("tile"); + expect(rdSource?.getTileContentType("./tileset.json?a=b&c=d")).toEqual("tileset"); + expect(rdSource?.getTileContentType("./tile.glb?a=b&c=d")).toEqual("tile"); + expect(rdSource?.getTileContentType("../tilesets/tileset.json?a=b&c=d")).toEqual("tileset"); + expect(rdSource?.getTileContentType("../models/tile.glb?a=b&c=d")).toEqual("tile"); + expect(rdSource?.getTileContentType("tilesets/tileset.json?a=b&c=d")).toEqual("tileset"); + expect(rdSource?.getTileContentType("models/tile.glb?a=b&c=d")).toEqual("tile"); + + expect(rdSource?.getTileContentType("tileset.json?a=b&c=d#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("tile.glb?a=b&c=d#fragment")).toEqual("tile"); + expect(rdSource?.getTileContentType("./tileset.json?a=b&c=d#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("./tile.glb?a=b&c=d#fragment")).toEqual("tile"); + expect(rdSource?.getTileContentType("../tilesets/tileset.json?a=b&c=d#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("../models/tile.glb?a=b&c=d#fragment")).toEqual("tile"); + expect(rdSource?.getTileContentType("tilesets/tileset.json?a=b&c=d#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("models/tile.glb?a=b&c=d#fragment")).toEqual("tile"); + + expect(rdSource?.getTileContentType("tileset.json#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("tile.glb#fragment")).toEqual("tile"); + expect(rdSource?.getTileContentType("./tileset.json#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("./tile.glb#fragment")).toEqual("tile"); + expect(rdSource?.getTileContentType("../tilesets/tileset.json#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("../models/tile.glb#fragment")).toEqual("tile"); + expect(rdSource?.getTileContentType("tilesets/tileset.json#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("models/tile.glb#fragment")).toEqual("tile"); + }); + it("handle content type of absolute urls", async () => { + expect(rdSource).toBeDefined(); + expect(rdSource?.getTileContentType("https://localhost/tilesets/tileset.json")).toEqual("tileset"); + expect(rdSource?.getTileContentType("https://localhost/models/tile.glb")).toEqual("tile"); + expect(rdSource?.getTileContentType("https://localhost/tilesets/tileset.json?a=b&c=d")).toEqual("tileset"); + expect(rdSource?.getTileContentType("https://localhost/models/tile.glb?a=b&c=d")).toEqual("tile"); + expect(rdSource?.getTileContentType("https://localhost/tilesets/tileset.json?a=b&c=d#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("https://localhost/models/tile.glb?a=b&c=d#fragment")).toEqual("tile"); + expect(rdSource?.getTileContentType("https://localhost/tilesets/tileset.json#fragment")).toEqual("tileset"); + expect(rdSource?.getTileContentType("https://localhost/models/tile.glb#fragment")).toEqual("tile"); + }); + it("handle content type of other cases", async () => { + expect(rdSource).toBeDefined(); + expect(rdSource?.getTileContentType("")).to.not.equal("tileset"); + expect(rdSource?.getTileContentType("tileset.json/")).to.not.equal("tileset"); + expect(rdSource?.getTileContentType("tileset.json2")).toEqual("tile"); + expect(rdSource?.getTileContentType("tileset.json/tileset.js")).toEqual("tile"); + expect(rdSource?.getTileContentType("TILESET.JSON")).toEqual("tileset"); + expect(rdSource?.getTileContentType("..\\tilesets\\tileset.json")).toEqual("tileset"); + expect(rdSource?.getTileContentType("/path/../tilesets/tileset.json")).toEqual("tileset"); + expect(rdSource?.getTileContentType("/path/../models/json.glb")).toEqual("tile"); + expect(rdSource?.getTileContentType("tile.glb?referer=tileset.json")).toEqual("tile"); + expect(rdSource?.getTileContentType("file:///c:/path/to/tileset.json")).toEqual("tileset"); + }); + + describe("getTileUrl", () => { + const sourceKey = { + provider: "Test Google 3D Tiles provider", + format: "ThreeDTile", + } + class TestGoogle3dTilesImpl extends RealityDataSourceGoogle3dTilesImpl { + public constructor(props: RealityDataSourceProps) { + super(props, undefined); + } + + public override setBaseUrl(url: string): void { + super.setBaseUrl(url); + } + + public static override async createFromKey(key: RealityDataSourceKey): Promise { + const source = await RealityDataSourceGoogle3dTilesImpl.createFromKey(key, undefined, undefined) as TestGoogle3dTilesImpl; + source.setBaseUrl(key.id); + return source; + } + } + + it("should get correct URL", async () => { + const url = "https://tile.googleapis.com/some/sub/dirs/root.json"; + const source = await TestGoogle3dTilesImpl.createFromKey({ ...sourceKey, id: url }); + expect(source).toBeDefined(); + + if (!source) + return; + + const returnedUrl = source.getTileUrl("tileset.json"); + expect(returnedUrl).toEqual("https://tile.googleapis.com/tileset.json"); + + const returnedUrl2 = source.getTileUrl("tile.glb"); + expect(returnedUrl2).toEqual("https://tile.googleapis.com/tile.glb"); + }); + + it("should handle tile path starting with slash", async () => { + const url = "https://tile.googleapis.com/some/sub/dirs/root.json"; + const source = await TestGoogle3dTilesImpl.createFromKey({ ...sourceKey, id: url }); + expect(source).toBeDefined(); + + if (!source) + return; + + const returnedUrl = source.getTileUrl("/tileset.json"); + expect(returnedUrl).toEqual("https://tile.googleapis.com/tileset.json"); + + const returnedUrl2 = source.getTileUrl("/tile.glb"); + expect(returnedUrl2).toEqual("https://tile.googleapis.com/tile.glb"); + }); + + it("should handle paths with leading subdirectories", async () => { + const url = "https://tile.googleapis.com/some/sub/dirs/root.json?key=key&sessionId=id"; + const source = await TestGoogle3dTilesImpl.createFromKey({ ...sourceKey, id: url }); + expect(source).toBeDefined(); + + if (!source) + return; + + const returnedUrl = source.getTileUrl("some/leading/path/tileset.json"); + expect(returnedUrl).toEqual("https://tile.googleapis.com/some/leading/path/tileset.json?key=key&sessionId=id"); + + const returnedUrl2 = source.getTileUrl("some/leading/path/tile.glb"); + expect(returnedUrl2).toEqual("https://tile.googleapis.com/some/leading/path/tile.glb?key=key&sessionId=id"); + }); + + it("should pass down root search params", async () => { + const url = "https://tile.googleapis.com/some/sub/dirs/root.json?key=key&sessionId=id"; + const source = await TestGoogle3dTilesImpl.createFromKey({ ...sourceKey, id: url }); + expect(source).toBeDefined(); + + if (!source) + return; + + const returnedUrl = source.getTileUrl("tileset.json"); + expect(returnedUrl).toEqual("https://tile.googleapis.com/tileset.json?key=key&sessionId=id"); + + const returnedUrl2 = source.getTileUrl("tile.glb"); + expect(returnedUrl2).toEqual("https://tile.googleapis.com/tile.glb?key=key&sessionId=id"); + }); + + it("should pass down root search params while preserving tile's search params", async () => { + const url = "https://tile.googleapis.com/some/sub/dirs/root.json?key=key"; + const source = await TestGoogle3dTilesImpl.createFromKey({ ...sourceKey, id: url }); + expect(source).toBeDefined(); + + if (!source) + return; + + const returnedUrl = source.getTileUrl("tile.glb?sessionId=123"); + expect(returnedUrl).toEqual("https://tile.googleapis.com/tile.glb?sessionId=123&key=key"); + + const returnedUrl2 = source.getTileUrl("tile.glb?sessionId=456"); + expect(returnedUrl2).toEqual("https://tile.googleapis.com/tile.glb?sessionId=456&key=key"); + }); + + it("should pass down root search params while preserving tileset's search params", async () => { + const url = "https://tile.googleapis.com/some/sub/dirs/root.json?key=key"; + const source = await TestGoogle3dTilesImpl.createFromKey({ ...sourceKey, id: url }); + expect(source).toBeDefined(); + + if (!source) + return; + + const returnedUrl = source.getTileUrl("tileset.json?sessionId=123"); + expect(returnedUrl).toEqual("https://tile.googleapis.com/tileset.json?sessionId=123&key=key"); + + const returnedUrl2 = source.getTileUrl("tileset.json?sessionId=456"); + expect(returnedUrl2).toEqual("https://tile.googleapis.com/tileset.json?sessionId=456&key=key"); + }); + + it("should pass down both root search params and tileset search params", async () => { + const url = "https://tile.googleapis.com/some/sub/dirs/root.json?key=key"; + const source = await TestGoogle3dTilesImpl.createFromKey({ ...sourceKey, id: url }); + expect(source).toBeDefined(); + + if (!source) + return; + + const returnedUrl = source.getTileUrl("tileset.json?sessionId=123"); + expect(returnedUrl).toEqual("https://tile.googleapis.com/tileset.json?sessionId=123&key=key"); + + const returnedUrl2 = source.getTileUrl("tileset.json?sessionId=456"); + expect(returnedUrl2).toEqual("https://tile.googleapis.com/tileset.json?sessionId=456&key=key"); + + const returnedUrl3 = source.getTileUrl("tile.glb"); + // Because all tileset search params are stored in RealityDataSourceGoogle3dTilesImpl._searchParams, not just root, the tile will recieve both session ids. + // In the future we might need a way to pass down "subtree" search params only to the tiles that need them. + expect(returnedUrl3).toEqual("https://tile.googleapis.com/tile.glb?key=key&sessionId=123&sessionId=456"); + }); + }); +}); \ No newline at end of file diff --git a/core/frontend/src/test/tile/RealityModelTileTreeProps.test.ts b/core/frontend/src/test/tile/RealityModelTileTreeProps.test.ts index bcd8cb591a66..122de4f903a3 100644 --- a/core/frontend/src/test/tile/RealityModelTileTreeProps.test.ts +++ b/core/frontend/src/test/tile/RealityModelTileTreeProps.test.ts @@ -78,7 +78,7 @@ describe("RealityTileTreeProps", () => { it("uses max SSE from RealityDataSource", () => { expectMaxSSE(123, true, 123); - expectMaxSSE(1, true, undefined); + expectMaxSSE(16, true, undefined); }); it("uses max SSE from tileset", () => { diff --git a/core/frontend/src/tile/GltfReader.ts b/core/frontend/src/tile/GltfReader.ts index 398cf3aa40f9..6ff480582718 100644 --- a/core/frontend/src/tile/GltfReader.ts +++ b/core/frontend/src/tile/GltfReader.ts @@ -144,6 +144,7 @@ class GltfBufferView { export interface GltfReaderResult extends TileContent { readStatus: TileReadStatus; range?: AxisAlignedBox3d; + copyright?: string; } type GltfTemplateResult = Omit & { template?: GraphicTemplate }; @@ -593,6 +594,7 @@ export abstract class GltfReader { isLeaf, contentRange, range, + copyright: this._glTF.asset?.copyright, containsPointCloud: this._containsPointCloud, template: createGraphicTemplate({ nodes: templateNodes, diff --git a/core/frontend/src/tile/RealityTile.ts b/core/frontend/src/tile/RealityTile.ts index b46b302e81b2..e48b314a82b1 100644 --- a/core/frontend/src/tile/RealityTile.ts +++ b/core/frontend/src/tile/RealityTile.ts @@ -43,6 +43,7 @@ export interface RealityTileGeometry { /** @internal */ export interface RealityTileContent extends TileContent { geometry?: RealityTileGeometry; + copyright?: string; } const scratchLoadedChildren = new Array(); @@ -72,6 +73,8 @@ export class RealityTile extends Tile { protected _reprojectionTransform?: Transform; private _reprojectedGraphic?: RenderGraphic; private readonly _geometricError?: number; + /** @internal */ + protected _copyright?: string; /** @internal */ public constructor(props: RealityTileParams, tree: RealityTileTree) { @@ -101,6 +104,7 @@ export class RealityTile extends Tile { public override setContent(content: RealityTileContent): void { super.setContent(content); this._geometry = content.geometry; + this._copyright = content.copyright; } /** @internal */ @@ -130,6 +134,8 @@ export class RealityTile extends Tile { * This property is only available when using [[TileGeometryCollector]]. */ public get geometry(): RealityTileGeometry | undefined { return this._geometry; } + /** @internal */ + public get copyright(): string | undefined { return this._copyright; } /** @internal */ public override get isDisplayable(): boolean { diff --git a/docs/changehistory/NextVersion.md b/docs/changehistory/NextVersion.md index af22e5e0f195..7ae96208b5f2 100644 --- a/docs/changehistory/NextVersion.md +++ b/docs/changehistory/NextVersion.md @@ -7,6 +7,7 @@ publish: false Table of contents: - [Electron 36 support](#electron-36-support) +- [Google Photorealistic 3D Tiles support](#google-photorealistic-3d-tiles-support) - [API deprecations](#api-deprecations) - [@itwin/presentation-common](#itwinpresentation-common) - [@itwin/presentation-backend](#itwinpresentation-backend) @@ -16,6 +17,14 @@ Table of contents: In addition to [already supported Electron versions](../learning/SupportedPlatforms.md#electron), iTwin.js now supports [Electron 36](https://www.electronjs.org/blog/electron-36-0). +## Google Photorealistic 3D Tiles support + +iTwin.js now supports displaying Google Photorealistic 3D Tiles via the new `Google3dTilesProvider`. See [this article](../learning/frontend/GooglePhotorealistic3dTiles.md) for more details. + +![Google Photorealistic 3D Tiles - Exton](../learning/frontend/google-photorealistic-3d-tiles-1.jpg "Google Photorealistic 3D Tiles - Exton") + +![Google Photorealistic 3D Tiles - Philadelphia](../learning/frontend/google-photorealistic-3d-tiles-2.jpg "Google Photorealistic 3D Tiles - Philadelphia") + ## API deprecations ### @itwin/presentation-common diff --git a/docs/learning/frontend/GooglePhotorealistic3DTiles.md b/docs/learning/frontend/GooglePhotorealistic3DTiles.md new file mode 100644 index 000000000000..f5dbebb5df8e --- /dev/null +++ b/docs/learning/frontend/GooglePhotorealistic3DTiles.md @@ -0,0 +1,21 @@ +# Google Photorealistic 3D Tiles in iTwin.js + +iTwin.js supports displaying Google Photorealistic 3D Tiles via the class [Google3dTilesProvider]($frontend). This provider handles authentication, tile loading, and attribution display. + +Here is an example of how to use the new provider by supplying an API key: + +```ts +[[include:GooglePhotorealistic3dTiles_providerApiKey]] +``` + +Instead of an API key, you can also supply a `getAuthToken` function: + +```ts +[[include:GooglePhotorealistic3dTiles_providerGetAuthToken]] +``` + +You can also use the [Google3dTilesProviderOptions]($frontend) `showCreditsOnScreen` flag to control the display of the data attributions on-screen. It is set to `true` by default. + +![Google Photorealistic 3D Tiles - Exton](./google-photorealistic-3d-tiles-1.jpg "Google Photorealistic 3D Tiles - Exton") + +![Google Photorealistic 3D Tiles - Philadelphia](./google-photorealistic-3d-tiles-2.jpg "Google Photorealistic 3D Tiles - Philadelphia") diff --git a/docs/learning/frontend/google-photorealistic-3d-tiles-1.jpg b/docs/learning/frontend/google-photorealistic-3d-tiles-1.jpg new file mode 100644 index 000000000000..be19b14b83c3 Binary files /dev/null and b/docs/learning/frontend/google-photorealistic-3d-tiles-1.jpg differ diff --git a/docs/learning/frontend/google-photorealistic-3d-tiles-2.jpg b/docs/learning/frontend/google-photorealistic-3d-tiles-2.jpg new file mode 100644 index 000000000000..d6477362e03f Binary files /dev/null and b/docs/learning/frontend/google-photorealistic-3d-tiles-2.jpg differ diff --git a/example-code/snippets/src/frontend/GooglePhotorealistic3DTiles.ts b/example-code/snippets/src/frontend/GooglePhotorealistic3DTiles.ts new file mode 100644 index 000000000000..b71deab3bd97 --- /dev/null +++ b/example-code/snippets/src/frontend/GooglePhotorealistic3DTiles.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- +* Copyright (c) Bentley Systems, Incorporated. All rights reserved. +* See LICENSE.md in the project root for license terms and full copyright notice. +*--------------------------------------------------------------------------------------------*/ + +import { getGoogle3dTilesUrl, Google3dTilesProvider, IModelApp, Viewport } from "@itwin/core-frontend"; + +// __PUBLISH_EXTRACT_START__ GooglePhotorealistic3dTiles_providerApiKey +export async function setUpGoogle3dTilesProviderWithApiKey(viewport: Viewport, apiKey: string) { + // Specify your API key in the provider options + const provider = new Google3dTilesProvider({ apiKey }); + // The provider must be initialized before attaching, to load imagery for its decorator + await provider.initialize(); + + // Register the provider with a name that will also be used to attach the reality model + IModelApp.realityDataSourceProviders.register("google3dTiles", provider); + + // This function just provides the Google 3D Tiles URL, or you can get it another way via a service, etc. + const url = getGoogle3dTilesUrl(); + + viewport.displayStyle.attachRealityModel({ + tilesetUrl: url, + name: "google3dTiles", + rdSourceKey: { + // provider property must be the same name you registered your provider under + provider: "google3dTiles", + format: "ThreeDTile", + id: url, + }, + }); +} +// __PUBLISH_EXTRACT_END__; + +// __PUBLISH_EXTRACT_START__ GooglePhotorealistic3dTiles_providerGetAuthToken +export async function setUpGoogle3dTilesProviderWithGetAuthToken() { + const fetchToken = async () => { + const apiUrl = "https://my-api.com/"; + const response = await fetch(apiUrl); + const data = await response.json(); + return data.accessToken; + } + // Specify the getAuthToken function to authenticate via authorization header in the Google 3D Tiles request, instead of API key + const provider = new Google3dTilesProvider({ getAuthToken: fetchToken }); + await provider.initialize(); + + // Next you can register the provider and attach the reality model, etc. +} +// __PUBLISH_EXTRACT_END__; \ No newline at end of file diff --git a/extensions/map-layers-formats/src/GoogleMaps/GoogleMapDecorator.ts b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapDecorator.ts deleted file mode 100644 index b8654f24da64..000000000000 --- a/extensions/map-layers-formats/src/GoogleMaps/GoogleMapDecorator.ts +++ /dev/null @@ -1,99 +0,0 @@ -/*--------------------------------------------------------------------------------------------- -* Copyright (c) Bentley Systems, Incorporated. All rights reserved. -* See LICENSE.md in the project root for license terms and full copyright notice. -*--------------------------------------------------------------------------------------------*/ -import { CanvasDecoration, DecorateContext, Decorator, IconSprites, IModelApp, Sprite } from "@itwin/core-frontend"; -import { Point3d } from "@itwin/core-geometry"; -import { GoogleMapsMapTypes } from "./GoogleMapsSession.js"; - - -/** A simple decorator that show logo at the a given screen position. - * @internal - */ -export class LogoDecoration implements CanvasDecoration { - private _sprite?: Sprite; - - /** The current position of the logo in view coordinates. */ - public readonly position = new Point3d(); - - private _offset: Point3d|undefined; - - public set offset(offset: Point3d|undefined) { - this._offset = offset; - } - - /** The logo offset in view coordinates.*/ - public get offset() { - return this._offset; - } - - /** Move the logo to the lower left corner of the screen. */ - public moveToLowerLeftCorner(context: DecorateContext) : boolean{ - if (!this._sprite || !this._sprite.isLoaded) - return false; - - this.position.x = this._offset?.x ?? 0; - this.position.y = context.viewport.parentDiv.clientHeight - this._sprite.size.y; - if (this._offset?.y) - this.position.y -= this._offset.y; - return true; - } - - /* TODO: Add other move methods as needed */ - - /** Indicate if the logo is loaded and ready to be drawn. */ - public get isLoaded() { return this._sprite?.isLoaded ?? false; } - - public async activate(sprite: Sprite): Promise { - this._sprite = sprite; - return new Promise((resolve, _reject) => { - sprite.loadPromise.then(() => { - resolve(true); - }).catch(() => { - resolve (false); - }); - }); - } - - /** Draw this sprite onto the supplied canvas. - * @see [[CanvasDecoration.drawDecoration]] - */ - public drawDecoration(ctx: CanvasRenderingContext2D): void { - if (this.isLoaded) { - // Draw image with an origin at the top left corner - ctx.drawImage(this._sprite!.image!, 0, 0); - } - } - - public decorate(context: DecorateContext) { - context.addCanvasDecoration(this); - } -} - -/** A decorator that adds the Google Maps logo to the lower left corner of the screen. - * @internal -*/ -export class GoogleMapsDecorator implements Decorator { - public readonly logo = new LogoDecoration(); - - /** Activate the logo based on the given map type. */ - public async activate(mapType: GoogleMapsMapTypes): Promise { - // Pick the logo that is the most visible on the background map - const imageName = mapType === "roadmap" ? - "google_on_white" : - "google_on_non_white"; - - // We need to move the logo right after the 'i.js' button - this.logo.offset = new Point3d(45, 10); - - return this.logo.activate(IconSprites.getSpriteFromUrl(`${IModelApp.publicPath}images/${imageName}.png`)); - }; - - /** Decorate implementation */ - public decorate = (context: DecorateContext) => { - if (!this.logo.isLoaded) - return; - this.logo.moveToLowerLeftCorner(context); - this.logo.decorate(context); - }; -} diff --git a/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryProvider.ts b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryProvider.ts index c738febfc894..01f6d025dd9f 100644 --- a/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryProvider.ts +++ b/extensions/map-layers-formats/src/GoogleMaps/GoogleMapsImageryProvider.ts @@ -8,9 +8,8 @@ import { BentleyError, BentleyStatus, Logger } from "@itwin/core-bentley"; import { ImageMapLayerSettings, ImageSource } from "@itwin/core-common"; -import { DecorateContext, IModelApp, MapCartoRectangle, MapLayerImageryProvider, MapTile, QuadIdProps, ScreenViewport, Tile } from "@itwin/core-frontend"; -import { GoogleMapsDecorator } from "./GoogleMapDecorator.js"; -import { GoogleMapsCreateSessionOptions, GoogleMapsLayerTypes, GoogleMapsMapTypes, GoogleMapsScaleFactors, GoogleMapsSession, GoogleMapsSessionManager, ViewportInfo } from "./GoogleMapsSession.js"; +import { DecorateContext, GoogleMapsDecorator, IModelApp, MapCartoRectangle, MapLayerImageryProvider, MapTile, QuadIdProps, ScreenViewport, Tile } from "@itwin/core-frontend"; +import { GoogleMapsCreateSessionOptions, GoogleMapsLayerTypes, GoogleMapsMapTypes, GoogleMapsScaleFactors, GoogleMapsSession, GoogleMapsSessionManager, ViewportInfo } from "./GoogleMapsSession.js"; import { NativeGoogleMapsSessionManager } from "../internal/NativeGoogleMapsSession.js"; import { GoogleMapsUtils } from "../internal/GoogleMapsUtils.js"; diff --git a/extensions/map-layers-formats/src/test/GoogleMaps/GoogleMaps.test.ts b/extensions/map-layers-formats/src/test/GoogleMaps/GoogleMaps.test.ts index d0205de564ac..9dcdaa5ea29a 100644 --- a/extensions/map-layers-formats/src/test/GoogleMaps/GoogleMaps.test.ts +++ b/extensions/map-layers-formats/src/test/GoogleMaps/GoogleMaps.test.ts @@ -3,9 +3,8 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { DecorateContext, Decorations, IconSprites, IModelApp, MapCartoRectangle, MapTile, MapTileTree, QuadId, ScreenViewport, Sprite } from "@itwin/core-frontend"; +import { DecorateContext, Decorations, IconSprites, IModelApp, LogoDecoration, MapCartoRectangle, MapTile, MapTileTree, QuadId, ScreenViewport, Sprite } from "@itwin/core-frontend"; import sinon from "sinon"; -import { LogoDecoration } from "../../GoogleMaps/GoogleMapDecorator.js"; import { Frustum, ImageMapLayerSettings } from "@itwin/core-common"; import { TilePatch } from "@itwin/core-frontend/lib/cjs/tile/internal.js"; import { Range3d } from "@itwin/core-geometry"; diff --git a/test-apps/display-test-app/README.md b/test-apps/display-test-app/README.md index 0f49ba127dd3..3a3155713629 100644 --- a/test-apps/display-test-app/README.md +++ b/test-apps/display-test-app/README.md @@ -196,6 +196,8 @@ You can use these environment variables to alter the default behavior of various * If defined, sets a Google Maps key within the `MapLayerOptions` as a "key" type. * IMJS_CESIUM_ION_KEY * If defined, the API key supplying access to Cesium ION assets. +* IMJS_GOOGLE_3D_TILES_KEY + * If defined, the API key supplying access to Google Photorealistic 3D Tiles. * IMJS_IMODEL_ID * If defined, the GuidString of the iModel to fetch from the iModel Hub and open. * IMJS_URL_PREFIX diff --git a/test-apps/display-test-app/src/frontend/ViewAttributes.ts b/test-apps/display-test-app/src/frontend/ViewAttributes.ts index a25a6b385e96..2d60eb683dcc 100644 --- a/test-apps/display-test-app/src/frontend/ViewAttributes.ts +++ b/test-apps/display-test-app/src/frontend/ViewAttributes.ts @@ -10,7 +10,7 @@ import { BackgroundMapProps, BackgroundMapProviderName, BackgroundMapProviderProps, BackgroundMapType, BaseMapLayerSettings, CesiumTerrainAssetId, ColorDef, DisplayStyle3dSettingsProps, GlobeMode, HiddenLine, LinePixels, MonochromeMode, RenderMode, TerrainProps, ThematicDisplayMode, ThematicGradientColorScheme, ThematicGradientMode, } from "@itwin/core-common"; -import { DisplayStyle2dState, DisplayStyle3dState, DisplayStyleState, IModelApp, Viewport, ViewState, ViewState3d } from "@itwin/core-frontend"; +import { DisplayStyle2dState, DisplayStyle3dState, DisplayStyleState, getGoogle3dTilesUrl, Google3dTilesProvider, IModelApp, Viewport, ViewState, ViewState3d } from "@itwin/core-frontend"; import { AmbientOcclusionEditor } from "./AmbientOcclusion"; import { EnvironmentEditor } from "./EnvironmentEditor"; import { Settings } from "./FeatureOverrides"; @@ -485,6 +485,11 @@ export class ViewAttributes { this._updates.push((view) => thematic.update(view)); } + private getDisplayingGoogle3dTiles() { + const google3dTilesModel = this._vp.view.displayStyle.settings.contextRealityModels.models.find((model) => { return model.name === "google3dTiles"; }); + return google3dTilesModel !== undefined; + } + private getBackgroundMap(view: ViewState) { return view.displayStyle.settings.backgroundMap; } private addBackgroundMapOrTerrain(): void { const isMapSupported = (view: ViewState) => view.is3d() && view.iModel.isGeoLocated; @@ -498,16 +503,66 @@ export class ViewAttributes { backgroundSettingsDiv.style.display = display; }; + let checkboxInterfaceGoogle3dTiles: CheckBox | undefined; + let checkboxInterfaceBGMap: CheckBox | undefined; + + const toggleGoogle3dTiles = (enabled: boolean) => { + if (undefined === checkboxInterfaceGoogle3dTiles) + return; + const checkboxGoogle3dTiles = checkboxInterfaceGoogle3dTiles.checkbox; + const checkboxLabelGoogle3dTiles = checkboxInterfaceGoogle3dTiles.label; + if (!enabled) { + checkboxGoogle3dTiles.disabled = true; + checkboxLabelGoogle3dTiles.style.opacity = "0.5"; + } else { + checkboxGoogle3dTiles.disabled = false; + checkboxLabelGoogle3dTiles.style.opacity = "1.0"; + } + checkboxLabelGoogle3dTiles.style.fontWeight = checkboxGoogle3dTiles.checked ? "bold" : "500"; + }; + + const toggleBGMapUI = (enabled: boolean) => { + if (undefined === checkboxInterfaceBGMap) + return; + const checkboxBGMap = checkboxInterfaceBGMap.checkbox; + const checkboxLabelBGMap = checkboxInterfaceBGMap.label; + if (!enabled) { + checkboxBGMap.disabled = true; + checkboxLabelBGMap.style.opacity = "0.5"; + } else { + checkboxBGMap.disabled = false; + checkboxLabelBGMap.style.opacity = "1.0"; + } + checkboxLabelBGMap.style.fontWeight = checkboxBGMap.checked ? "bold" : "500"; + }; + + const enableGoogle3dTiles = (enabled: boolean) => { + toggleBGMapUI(!enabled); + if (undefined === checkboxInterfaceGoogle3dTiles) + return; + const checkboxGoogle3dTiles = checkboxInterfaceGoogle3dTiles.checkbox; + const checkboxLabelGoogle3dTiles = checkboxInterfaceGoogle3dTiles.label; + checkboxLabelGoogle3dTiles.style.fontWeight = checkboxGoogle3dTiles.checked ? "bold" : "500"; + this.sync(); + }; + const checkboxInterfaceGoogle3dTiles0 = checkboxInterfaceGoogle3dTiles = this.addCheckbox("Google Photorealistic 3D Tiles", enableGoogle3dTiles, div); + if (this.getDisplayingGoogle3dTiles()) + checkboxInterfaceGoogle3dTiles.checkbox.checked = true; + toggleGoogle3dTiles(this.getDisplayingGoogle3dTiles() || !this._vp.view.viewFlags.backgroundMap); + const enableMap = (enabled: boolean) => { this._vp.viewFlags = this._vp.viewFlags.with("backgroundMap", enabled); backgroundSettingsDiv.style.display = enabled ? "block" : "none"; showOrHideSettings(enabled); + toggleGoogle3dTiles(!enabled); this.sync(); }; - const checkboxInterface = this.addCheckbox("Background Map", enableMap, div); + const checkboxInterface = checkboxInterfaceBGMap = this.addCheckbox("Background Map", enableMap, div); const checkbox = checkboxInterface.checkbox; const checkboxLabel = checkboxInterface.label; + toggleBGMapUI(this._vp.view.viewFlags.backgroundMap || !this.getDisplayingGoogle3dTiles()); + const imageryProviders = createComboBox({ parent: backgroundSettingsDiv, name: "Imagery: ", @@ -558,12 +613,38 @@ export class ViewAttributes { backgroundSettingsDiv.appendChild(mapSettings); backgroundSettingsDiv.appendChild(terrainSettings); - this._updates.push((view) => { + this._updates.push(async (view) => { const visible = isMapSupported(view); div.style.display = visible ? "block" : "none"; if (!visible) return; + if (checkboxInterfaceGoogle3dTiles0.checkbox.checked) { + // Only create and initialize the provider once + const provider = new Google3dTilesProvider({ apiKey: process.env.IMJS_GOOGLE_3D_TILES_KEY!, showCreditsOnScreen: true }); + await provider.initialize(); + + if (!this.getDisplayingGoogle3dTiles()) { + const url = getGoogle3dTilesUrl(); + IModelApp.realityDataSourceProviders.register("google3dTiles", provider); + view.displayStyle.attachRealityModel({ + tilesetUrl: url, + name: "google3dTiles", + rdSourceKey: { + provider: "google3dTiles", + format: "ThreeDTile", + id: url, + }, + }); + this.sync(); + } + } else { + if (this.getDisplayingGoogle3dTiles()) { + view.displayStyle.detachRealityModelByNameAndUrl("google3dTiles", getGoogle3dTilesUrl()); + this.sync(); + } + } + checkbox.checked = view.viewFlags.backgroundMap; checkboxLabel.style.fontWeight = checkbox.checked ? "bold" : "500"; showOrHideSettings(checkbox.checked);