diff --git a/common/api/core-frontend.api.md b/common/api/core-frontend.api.md index 68abbe47687..6f1335de464 100644 --- a/common/api/core-frontend.api.md +++ b/common/api/core-frontend.api.md @@ -2382,6 +2382,9 @@ export function createOrbitGtTileTreeReference(props: OrbitGtTileTree.ReferenceP // @internal (undocumented) export function createPrimaryTileTreeReference(view: ViewState, model: GeometricModelState): PrimaryTreeReference; +// @internal +export function createReaderPropsWithBaseUrl(streamBuffer: ByteStream | Uint8Array, yAxisUp: boolean, baseUrl?: string): GltfReaderProps | undefined; + // @internal (undocumented) export function createRealityTileTreeReference(props: RealityModelTileTree.ReferenceProps): RealityModelTileTree.Reference; @@ -9097,6 +9100,8 @@ export namespace RealityDataSource { export function createOrbitGtBlobPropsFromKey(rdSourceKey: RealityDataSourceKey): OrbitGtBlobProps | undefined; // @alpha export function fromKey(key: RealityDataSourceKey, iTwinId: GuidString | undefined): Promise; + // @internal + export function getTilesetUrlFromTilesetUrlImpl(rdSource: RealityDataSource): string | undefined; } // @alpha @@ -9525,6 +9530,8 @@ export class RealityTileRegion { export class RealityTileTree extends TileTree { // @internal constructor(params: RealityTileTreeParams); + // @internal (undocumented) + readonly baseUrl?: string; // @beta get batchTableProperties(): BatchTableProperties | undefined; // @internal (undocumented) @@ -9591,6 +9598,8 @@ export class RealityTileTree extends TileTree { // @internal (undocumented) export interface RealityTileTreeParams extends TileTreeParams { + // (undocumented) + readonly baseUrl?: string; // (undocumented) readonly gcsConverterAvailable: boolean; // (undocumented) diff --git a/common/api/summary/core-frontend.exports.csv b/common/api/summary/core-frontend.exports.csv index dd78e44c21e..926e505b212 100644 --- a/common/api/summary/core-frontend.exports.csv +++ b/common/api/summary/core-frontend.exports.csv @@ -140,6 +140,7 @@ internal;function;createMaskTreeReference internal;function;createModelMapLayerTileTreeReference internal;function;createOrbitGtTileTreeReference internal;function;createPrimaryTileTreeReference +internal;function;createReaderPropsWithBaseUrl internal;function;createRealityTileTreeReference beta;interface;CreateRenderInstancesParamsBuilderArgs public;interface;CreateRenderMaterialArgs @@ -594,6 +595,7 @@ alpha;function;createKeyFromBlobUrl alpha;function;createKeyFromOrbitGtBlobProps alpha;function;createOrbitGtBlobPropsFromKey alpha;function;fromKey +internal;function;getTilesetUrlFromTilesetUrlImpl alpha;interface;RealityDataSourceProvider alpha;class;RealityDataSourceProviderRegistry internal;interface;RealityMeshGraphicParams diff --git a/common/changes/@itwin/core-frontend/andremig-texture-baseurl_2024-12-05-15-48.json b/common/changes/@itwin/core-frontend/andremig-texture-baseurl_2024-12-05-15-48.json new file mode 100644 index 00000000000..ac11a63efe7 --- /dev/null +++ b/common/changes/@itwin/core-frontend/andremig-texture-baseurl_2024-12-05-15-48.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/core-frontend", + "comment": "", + "type": "none" + } + ], + "packageName": "@itwin/core-frontend" +} \ No newline at end of file diff --git a/core/frontend/src/RealityDataSource.ts b/core/frontend/src/RealityDataSource.ts index a0e4893d84c..0943c117459 100644 --- a/core/frontend/src/RealityDataSource.ts +++ b/core/frontend/src/RealityDataSource.ts @@ -208,6 +208,13 @@ export namespace RealityDataSource { return provider.createRealityDataSource(key, iTwinId); } + + /** Returns the source's tileset url if the source is an instance of RealityDataSourceTilesetUrlImpl. + * @internal + */ + export function getTilesetUrlFromTilesetUrlImpl(rdSource: RealityDataSource): string | undefined { + return (rdSource instanceof RealityDataSourceTilesetUrlImpl) ? rdSource.tilesetUrl : undefined; + }; } /** A named supplier of [RealityDataSource]]s. diff --git a/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts b/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts index d73062b97cc..149f281ec70 100644 --- a/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts +++ b/core/frontend/src/RealityDataSourceTilesetUrlImpl.ts @@ -66,6 +66,9 @@ export class RealityDataSourceTilesetUrlImpl implements RealityDataSource { return undefined; } + public get tilesetUrl(): string | undefined { + return this._tilesetUrl; + } // This is to set the root url from the provided root document path. // If the root document is stored on PW Context Share then the root document property of the Reality Data is provided, // otherwise the full path to root document is given. diff --git a/core/frontend/src/test/tile/RealityTileLoader.test.ts b/core/frontend/src/test/tile/RealityTileLoader.test.ts new file mode 100644 index 00000000000..f1d324d2343 --- /dev/null +++ b/core/frontend/src/test/tile/RealityTileLoader.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- +* 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 { createReaderPropsWithBaseUrl } from "../../tile/RealityTileLoader"; +import { GltfV2ChunkTypes, GltfVersions, TileFormat } from "@itwin/core-common"; + +const minimalBin = new Uint8Array([12, 34, 0xfe, 0xdc]); +const minimalJson = { asset: { version: "02.00" }, meshes: [] }; + +function jsonToBytes(json: object, alignment = 4): Uint8Array { + let str = JSON.stringify(json); + while (str.length % alignment !== 0) + str += " "; + + const bytes = new TextEncoder().encode(str); + expect(bytes.length).toEqual(str.length); // pure ASCII + return bytes; +} + +function setHeader(data: Uint8Array | DataView, length: number, format = TileFormat.Gltf, version = GltfVersions.Version2): void { + if (data instanceof Uint8Array) + data = new DataView(data.buffer); + + data.setUint32(0, format, true); + data.setUint32(4, version, true); + data.setUint32(8, length, true); +} + +interface Chunk { + len?: number; + type: number; + data: Uint8Array; +} + +interface Header { + len?: number; + format: number; + version: number; +} +function glbFromChunks(chunks: Chunk[], header?: Header): Uint8Array { + let numBytes = 12; + for (const chunk of chunks) + numBytes += 8 + (chunk.len ?? chunk.data.length); + + const glb = new Uint8Array(numBytes); + const view = new DataView(glb.buffer); + + header = header ?? { format: TileFormat.Gltf, version: GltfVersions.Version2 }; + setHeader(view, header.len ?? numBytes, header.format, header.version); + + let chunkStart = 12; + for (const chunk of chunks) { + view.setUint32(chunkStart + 0, chunk.len ?? chunk.data.length, true); + view.setUint32(chunkStart + 4, chunk.type, true); + glb.set(chunk.data, chunkStart + 8); + chunkStart += chunk.data.length + 8; + } + + return glb; +} + +function makeGlb(json: object | undefined, binary?: Uint8Array, header?: Header): Uint8Array { + const chunks = []; + if (json) + chunks.push({ type: GltfV2ChunkTypes.JSON, data: jsonToBytes(json) }); + + if (binary) + chunks.push({ type: GltfV2ChunkTypes.Binary, data: binary }); + + return glbFromChunks(chunks, header); +} + +describe("createReaderPropsWithBaseUrl", () => { + const glb = makeGlb(minimalJson, minimalBin); + it("should add a valid base url to the reader props", {}, () => { + let props = createReaderPropsWithBaseUrl(glb, false, "http://localhost:8080/tileset.json"); + expect(props?.baseUrl?.toString()).to.equal("http://localhost:8080/tileset.json"); + + props = createReaderPropsWithBaseUrl(glb, false, "https://some-blob-storage.com/tileset.json"); + expect(props?.baseUrl?.toString()).to.equal("https://some-blob-storage.com/tileset.json"); + + props = createReaderPropsWithBaseUrl(glb, false, "https://some-blob-storage.com/tileset.json?with-some-query-params"); + expect(props?.baseUrl?.toString()).to.equal("https://some-blob-storage.com/tileset.json?with-some-query-params"); + }); + + it("should not add an invalid base url to the reader props", {}, () => { + let props = createReaderPropsWithBaseUrl(glb, false, ""); + expect(props?.baseUrl).to.be.undefined; + + props = createReaderPropsWithBaseUrl(glb, false, "some-invalid-url"); + expect(props?.baseUrl).to.be.undefined; + }); +}); + diff --git a/core/frontend/src/tile/RealityModelTileTree.ts b/core/frontend/src/tile/RealityModelTileTree.ts index 89fcc29e0d3..87056319098 100644 --- a/core/frontend/src/tile/RealityModelTileTree.ts +++ b/core/frontend/src/tile/RealityModelTileTree.ts @@ -269,12 +269,13 @@ class RealityModelTileTreeParams implements RealityTileTreeParams { public is3d = true; public loader: RealityModelTileLoader; public rootTile: RealityTileParams; + public baseUrl?: string; public get location() { return this.loader.tree.location; } public get yAxisUp() { return this.loader.tree.yAxisUp; } public get priority() { return this.loader.priority; } - public constructor(tileTreeId: string, iModel: IModelConnection, modelId: Id64String, loader: RealityModelTileLoader, public readonly gcsConverterAvailable: boolean, public readonly rootToEcef: Transform | undefined) { + public constructor(tileTreeId: string, iModel: IModelConnection, modelId: Id64String, loader: RealityModelTileLoader, public readonly gcsConverterAvailable: boolean, public readonly rootToEcef: Transform | undefined, baseUrl?: string) { this.loader = loader; this.id = tileTreeId; this.modelId = modelId; @@ -287,6 +288,7 @@ class RealityModelTileTreeParams implements RealityTileTreeParams { additiveRefinement: undefined !== refine ? "ADD" === refine : undefined, usesGeometricError: loader.tree.rdSource.usesGeometricError, }); + this.baseUrl = baseUrl; } } @@ -711,7 +713,9 @@ export namespace RealityModelTileTree { const props = await getTileTreeProps(rdSource, tilesetToDb, iModel); const loader = new RealityModelTileLoader(props, new BatchedTileIdMap(iModel), opts); const gcsConverterAvailable = await getGcsConverterAvailable(iModel); - const params = new RealityModelTileTreeParams(tileTreeId, iModel, modelId, loader, gcsConverterAvailable, props.tilesetToEcef); + //The full tileset url is needed so that it includes the url's search parameters if any are present + const baseUrl = RealityDataSource.getTilesetUrlFromTilesetUrlImpl(rdSource); + const params = new RealityModelTileTreeParams(tileTreeId, iModel, modelId, loader, gcsConverterAvailable, props.tilesetToEcef, baseUrl); return new RealityModelTileTree(params); } return undefined; diff --git a/core/frontend/src/tile/RealityTileLoader.ts b/core/frontend/src/tile/RealityTileLoader.ts index 890fecb85a5..e658794ffe8 100644 --- a/core/frontend/src/tile/RealityTileLoader.ts +++ b/core/frontend/src/tile/RealityTileLoader.ts @@ -16,7 +16,7 @@ import { ScreenViewport, Viewport } from "../Viewport"; import { GltfWrapMode } from "../common/gltf/GltfSchema"; import { B3dmReader, BatchedTileIdMap, createDefaultViewFlagOverrides, GltfGraphicsReader, GltfReader, GltfReaderProps, I3dmReader, ImdlReader, readPointCloudTileContent, - RealityTile, RealityTileContent, Tile, TileContent, TileDrawArgs, TileLoadPriority, TileRequest, TileRequestChannel, TileUser, + RealityTile, RealityTileContent, RealityTileTree, Tile, TileContent, TileDrawArgs, TileLoadPriority, TileRequest, TileRequestChannel, TileUser, } from "./internal"; const defaultViewFlagOverrides = createDefaultViewFlagOverrides({}); @@ -143,7 +143,9 @@ export abstract class RealityTileLoader { reader = I3dmReader.create(streamBuffer, iModel, modelId, is3d, tile.contentRange, system, yAxisUp, tile.isLeaf, isCanceled, undefined, this.wantDeduplicatedVertices); break; case TileFormat.Gltf: - const props = GltfReaderProps.create(streamBuffer.nextBytes(streamBuffer.arrayBuffer.byteLength), yAxisUp); + const tree = tile.tree as RealityTileTree; + const baseUrl = tree.baseUrl; + const props = createReaderPropsWithBaseUrl(streamBuffer, yAxisUp, baseUrl); if (props) { reader = new GltfGraphicsReader(props, { iModel, @@ -155,7 +157,6 @@ export abstract class RealityTileLoader { idMap: this.getBatchIdMap(), }); } - break; case TileFormat.Cmpt: const header = new CompositeTileHeader(streamBuffer); @@ -242,3 +243,14 @@ export abstract class RealityTileLoader { return minDistance; } } + +/** Exposed strictly for testing purposes. +* @internal +*/ +export function createReaderPropsWithBaseUrl(streamBuffer: ByteStream, yAxisUp: boolean, baseUrl?: string): GltfReaderProps | undefined { + let url: URL | undefined; + try { + url = new URL(baseUrl); + } catch (_) { } + + return GltfReaderProps.create(streamBuffer.nextBytes(streamBuffer.arrayBuffer.byteLength), yAxisUp, url); diff --git a/core/frontend/src/tile/RealityTileTree.ts b/core/frontend/src/tile/RealityTileTree.ts index 177f223d61b..d036f28537d 100644 --- a/core/frontend/src/tile/RealityTileTree.ts +++ b/core/frontend/src/tile/RealityTileTree.ts @@ -164,6 +164,7 @@ export interface RealityTileTreeParams extends TileTreeParams { readonly rootTile: RealityTileParams; readonly rootToEcef?: Transform; readonly gcsConverterAvailable: boolean; + readonly baseUrl?: string; } /** Base class for a [[TileTree]] representing a reality model (e.g., a point cloud or photogrammetry mesh) or 3d terrain with map imagery. @@ -189,6 +190,8 @@ export class RealityTileTree extends TileTree { protected _rootToEcef?: Transform; /** @internal */ protected _ecefToDb?: Transform; + /** @internal */ + public readonly baseUrl?: string; /** @internal */ public constructor(params: RealityTileTreeParams) { @@ -207,6 +210,7 @@ export class RealityTileTree extends TileTree { this._ecefToDb = dbToEcef.inverse(); } } + this.baseUrl = params.baseUrl; } /** The mapping of per-feature JSON properties from this tile tree's batch table, if one is defined.