diff --git a/Apps/Sandcastle/gallery/iTwin Demo.html b/Apps/Sandcastle/gallery/iTwin Demo.html new file mode 100644 index 00000000000..19e0156a005 --- /dev/null +++ b/Apps/Sandcastle/gallery/iTwin Demo.html @@ -0,0 +1,312 @@ + + + + + + + + + iTwin iModel demo + + + + + +
+

Loading...

+
+
+
+
+
+ + + diff --git a/Apps/Sandcastle/gallery/iTwin Demo.jpg b/Apps/Sandcastle/gallery/iTwin Demo.jpg new file mode 100644 index 00000000000..98316311825 Binary files /dev/null and b/Apps/Sandcastle/gallery/iTwin Demo.jpg differ diff --git a/CHANGES.md b/CHANGES.md index 98047d22162..cfb0155f895 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ ##### Additions :tada: +- Added an integration with the [iTwin Platform](https://developer.bentley.com/) to load iModels as 3D Tiles. Use `ITwinPlatform.defaultAccessToken` to set the access token. Use `ITwinData.createTilesetFromIModelId(iModelId)` to load the iModel as a `Cesium3DTileset`. [#12289](https://github.com/CesiumGS/cesium/pull/12289) - Added `getSample` to `SampledProperty` to get the time of samples. [#12253](https://github.com/CesiumGS/cesium/pull/12253) - Added `Entity.trackingReferenceFrame` property to allow tracking entities in various reference frames. [#12194](https://github.com/CesiumGS/cesium/pull/12194), [#12314](https://github.com/CesiumGS/cesium/pull/12314) - `TrackingReferenceFrame.AUTODETECT` (default): uses either VVLH or ENU dependeding on entity's dynamic. Use `TrackingReferenceFrame.ENU` if your camera orientation flips abruptly from time to time. diff --git a/packages/engine/Source/Core/ITwinPlatform.js b/packages/engine/Source/Core/ITwinPlatform.js new file mode 100644 index 00000000000..ca6f8e63063 --- /dev/null +++ b/packages/engine/Source/Core/ITwinPlatform.js @@ -0,0 +1,158 @@ +import Check from "./Check.js"; +import defined from "./defined.js"; +import DeveloperError from "./DeveloperError.js"; +import Resource from "./Resource.js"; +import RuntimeError from "./RuntimeError.js"; + +/** + * Default settings for accessing the iTwin platform. + * + * @experimental This feature is not final and is subject to change without Cesium's standard deprecation policy. + * + * @see ITwinData + * @namespace ITwinPlatform + */ +const ITwinPlatform = {}; + +/** + * Status states for a mesh-export export. + * Valid values are: NotStarted, InProgress, Complete, Invalid + * @enum {string} + */ +ITwinPlatform.ExportStatus = Object.freeze({ + NotStarted: "NotStarted", + InProgress: "InProgress", + Complete: "Complete", + Invalid: "Invalid", +}); + +/** + * Types of mesh-export exports. CesiumJS only supports loading 3DTILES type exports. + * Valid values are: IMODEL, CESIUM, 3DTILES + * @enum {string} + */ +ITwinPlatform.ExportType = Object.freeze({ + IMODEL: "IMODEL", + CESIUM: "CESIUM", + "3DTILES": "3DTILES", +}); + +/** + * Gets or sets the default iTwin access token. This token should have the itwin-platform scope. + * + * @experimental This feature is not final and is subject to change without Cesium's standard deprecation policy. + * + * @type {string|undefined} + */ +ITwinPlatform.defaultAccessToken = undefined; + +/** + * Gets or sets the default iTwin API endpoint. + * + * @experimental This feature is not final and is subject to change without Cesium's standard deprecation policy. + * + * @type {string|Resource} + * @default "https://api.bentley.com" + */ +ITwinPlatform.apiEndpoint = new Resource({ + url: "https://api.bentley.com", +}); + +/** + * @typedef {Object} ExportRequest + * @private + * @property {string} iModelId + * @property {string} changesetId + * @property {ITwinPlatform.ExportType} exportType Type of the export. CesiumJS only supports the 3DTILES type + */ + +/** + * @typedef {Object} Link + * @private + * @property {string} href + */ + +/** + * @typedef {Object} ExportRepresentation + * The export objects from get-exports when using return=representation + * @private + * @property {string} id Export id + * @property {string} displayName Name of the iModel + * @property {ITwinPlatform.ExportStatus} status Status of this export + * @property {string} lastModified + * @property {ExportRequest} request Object containing info about the export itself + * @property {{mesh: Link}} _links Object containing relevant links. For Exports this includes the access url for the mesh itself + */ + +/** + * @typedef {Object} GetExportsResponse + * @private + * @property {ExportRepresentation[]} exports The list of exports for the current page + * @property {{self: Link, next: Link | undefined, prev: Link | undefined}} _links Pagination links + */ + +/** + * Get the list of exports for the specified iModel at it's most current version. + * This will only return the top 5 exports with {@link ITwinPlatform.ExportType} of 3DTILES. + * + * @private + * + * @param {string} iModelId iModel id + * @returns {Promise} + * + * @throws {RuntimeError} If the iTwin API request is not successful + */ +ITwinPlatform.getExports = async function (iModelId) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.string("iModelId", iModelId); + if (!defined(ITwinPlatform.defaultAccessToken)) { + throw new DeveloperError("Must set ITwinPlatform.defaultAccessToken first"); + } + //>>includeEnd('debug') + + const resource = new Resource({ + url: `${ITwinPlatform.apiEndpoint}mesh-export`, + headers: { + Authorization: `Bearer ${ITwinPlatform.defaultAccessToken}`, + Accept: "application/vnd.bentley.itwin-platform.v1+json", + Prefer: "return=representation", + }, + queryParameters: { + iModelId: iModelId, + exportType: ITwinPlatform.ExportType["3DTILES"], + // With the export auto-generation it will auto-delete the 6th export so + // there should never be more than 5 results. Just request them all and parse + // for ones that are COMPLETE + $top: "5", + client: "CesiumJS", + }, + }); + /* global CESIUM_VERSION */ + if (typeof CESIUM_VERSION !== "undefined") { + resource.appendQueryParameters({ clientVersion: CESIUM_VERSION }); + } + + try { + const response = await resource.fetchJson(); + return response; + } catch (error) { + const result = JSON.parse(error.response); + if (error.statusCode === 401) { + throw new RuntimeError( + `Unauthorized, bad token, wrong scopes or headers bad. ${result.error.details[0].code}`, + ); + } else if (error.statusCode === 403) { + console.error(result.error.code, result.error.message); + throw new RuntimeError("Not allowed, forbidden"); + } else if (error.statusCode === 422) { + throw new RuntimeError( + `Unprocessable Entity:${result.error.code} ${result.error.message}`, + ); + } else if (error.statusCode === 429) { + throw new RuntimeError("Too many requests"); + } + throw new RuntimeError(`Unknown request failure ${error.statusCode}`); + } +}; + +export default ITwinPlatform; diff --git a/packages/engine/Source/Scene/ITwinData.js b/packages/engine/Source/Scene/ITwinData.js new file mode 100644 index 00000000000..1e970ef1a3e --- /dev/null +++ b/packages/engine/Source/Scene/ITwinData.js @@ -0,0 +1,74 @@ +import Cesium3DTileset from "./Cesium3DTileset.js"; +import defined from "../Core/defined.js"; +import Resource from "../Core/Resource.js"; +import ITwinPlatform from "../Core/ITwinPlatform.js"; +import RuntimeError from "../Core/RuntimeError.js"; + +/** + * Methods for loading iTwin platform data into CesiumJS + * + * @experimental This feature is not final and is subject to change without Cesium's standard deprecation policy. + * + * @see ITwinPlatform + * @namespace ITwinData + */ +const ITwinData = {}; + +/** + * Create a {@link Cesium3DTileset} for the given iModel id using iTwin's Mesh Export API. + * + * If there is not a completed export available for the given iModel id, the returned promise will resolve to undefined. + * We recommend waiting 10-20 seconds and trying to load the tileset again. + * If all exports are Invalid this will throw an error. + * + * @example + * const tileset = await Cesium.ITwinData.createTilesetFromIModelId(iModelId); + * if (Cesium.defined(tileset)) { + * viewer.scene.primitives.add(tileset); + * } + * + * @experimental This feature is not final and is subject to change without Cesium's standard deprecation policy. + * + * @param {string} iModelId The id of the iModel to load + * @param {Cesium3DTileset.ConstructorOptions} [options] Object containing options to pass to the internally created {@link Cesium3DTileset}. + * @returns {Promise} A promise that will resolve to the created 3D tileset or undefined if there is no completed export for the given iModel id + * + * @throws {RuntimeError} If all exports for the given iModel are Invalid + * @throws {RuntimeError} If the iTwin API request is not successful + */ +ITwinData.createTilesetFromIModelId = async function (iModelId, options) { + const { exports } = await ITwinPlatform.getExports(iModelId); + + if ( + exports.length > 0 && + exports.every((exportObj) => { + return exportObj.status === ITwinPlatform.ExportStatus.Invalid; + }) + ) { + throw new RuntimeError( + `All exports for this iModel are Invalid: ${iModelId}`, + ); + } + + const completeExport = exports.find((exportObj) => { + return exportObj.status === ITwinPlatform.ExportStatus.Complete; + }); + + if (!defined(completeExport)) { + return; + } + + // Convert the link to the tileset url while preserving the search paramaters + // This link is only valid 1 hour + const baseUrl = new URL(completeExport._links.mesh.href); + baseUrl.pathname = `${baseUrl.pathname}/tileset.json`; + const tilesetUrl = baseUrl.toString(); + + const resource = new Resource({ + url: tilesetUrl, + }); + + return Cesium3DTileset.fromUrl(resource, options); +}; + +export default ITwinData; diff --git a/packages/engine/Specs/Core/ITwinPlatformSpec.js b/packages/engine/Specs/Core/ITwinPlatformSpec.js new file mode 100644 index 00000000000..eefb376a6b2 --- /dev/null +++ b/packages/engine/Specs/Core/ITwinPlatformSpec.js @@ -0,0 +1,122 @@ +import DeveloperError from "../../Source/Core/DeveloperError.js"; +import ITwinPlatform from "../../Source/Core/ITwinPlatform.js"; +import RequestErrorEvent from "../../Source/Core/RequestErrorEvent.js"; +import Resource from "../../Source/Core/Resource.js"; +import RuntimeError from "../../Source/Core/RuntimeError.js"; + +describe("ITwinPlatform", () => { + let previousAccessToken; + beforeEach(() => { + previousAccessToken = ITwinPlatform.defaultAccessToken; + ITwinPlatform.defaultAccessToken = "default-access-token"; + }); + + afterEach(() => { + ITwinPlatform.defaultAccessToken = previousAccessToken; + }); + + describe("getExports", () => { + let requestSpy; + beforeEach(() => { + requestSpy = spyOn(Resource.prototype, "fetchJson"); + }); + + it("rejects with no iModelId", async () => { + // @ts-expect-error + await expectAsync(ITwinPlatform.getExports()).toBeRejectedWithError( + DeveloperError, + /iModelId/, + ); + }); + + it("rejects with no default access token set", async () => { + ITwinPlatform.defaultAccessToken = undefined; + await expectAsync( + ITwinPlatform.getExports("imodel-id-1"), + ).toBeRejectedWithError( + DeveloperError, + /Must set ITwinPlatform.defaultAccessToken/, + ); + }); + + it("rejects for API 401 errors", async () => { + requestSpy.and.rejectWith( + new RequestErrorEvent( + 401, + JSON.stringify({ + error: { message: "failed", details: [{ code: "InvalidToken" }] }, + }), + ), + ); + await expectAsync( + ITwinPlatform.getExports("imodel-id-1"), + ).toBeRejectedWithError(RuntimeError, /Unauthorized/); + }); + + it("rejects for API 403 errors", async () => { + requestSpy.and.rejectWith( + new RequestErrorEvent( + 403, + JSON.stringify({ + error: { message: "failed", code: "Forbidden" }, + }), + ), + ); + await expectAsync( + ITwinPlatform.getExports("imodel-id-1"), + ).toBeRejectedWithError(RuntimeError, /forbidden/); + }); + + it("rejects for API 422 errors", async () => { + requestSpy.and.rejectWith( + new RequestErrorEvent( + 422, + JSON.stringify({ + error: { message: "failed", code: "BadEntity" }, + }), + ), + ); + await expectAsync( + ITwinPlatform.getExports("imodel-id-1"), + ).toBeRejectedWithError(RuntimeError, /Unprocessable/); + }); + + it("rejects for API 429 errors", async () => { + requestSpy.and.rejectWith( + new RequestErrorEvent( + 429, + JSON.stringify({ + error: { message: "" }, + }), + ), + ); + await expectAsync( + ITwinPlatform.getExports("imodel-id-1"), + ).toBeRejectedWithError(RuntimeError, /Too many/); + }); + + it("uses the default access token for the API request", async () => { + let resource; + requestSpy.and.callFake(function () { + resource = this; + return JSON.stringify({ exports: [] }); + }); + await ITwinPlatform.getExports("imodel-id-1"); + expect(resource).toBeDefined(); + expect(resource.headers["Authorization"]).toEqual( + "Bearer default-access-token", + ); + }); + + it("uses the imodel id in the API request", async () => { + let resource; + requestSpy.and.callFake(function () { + resource = this; + return JSON.stringify({ exports: [] }); + }); + await ITwinPlatform.getExports("imodel-id-1"); + expect(resource).toBeDefined(); + expect(resource.url).toContain("imodel-id-1"); + }); + }); +}); diff --git a/packages/engine/Specs/Scene/ITwinDataSpec.js b/packages/engine/Specs/Scene/ITwinDataSpec.js new file mode 100644 index 00000000000..f407483eec0 --- /dev/null +++ b/packages/engine/Specs/Scene/ITwinDataSpec.js @@ -0,0 +1,119 @@ +import { + ITwinPlatform, + RuntimeError, + Cesium3DTileset, + ITwinData, +} from "../../index.js"; + +function createMockExport( + id, + status, + exportType = ITwinPlatform.ExportType["3DTILES"], +) { + return { + id: `${id}`, + displayName: `export ${id}`, + status: status, + lastModified: "2024-11-04T12:00Z", + request: { + iModelId: "imodel-id-1", + changesetId: "changeset-id", + exportType, + }, + _links: { + mesh: { + // The API returns some important query params for auth that we + // need to make sure are preserved when the path is modified + href: `https://example.com/link/to/mesh/${id}?query=param`, + }, + }, + }; +} + +describe("ITwinData", () => { + let previousAccessToken; + beforeEach(() => { + previousAccessToken = ITwinPlatform.defaultAccessToken; + ITwinPlatform.defaultAccessToken = "default-access-token"; + }); + + afterEach(() => { + ITwinPlatform.defaultAccessToken = previousAccessToken; + }); + + describe("createTilesetFromIModelId", () => { + it("rejects when all exports are invalid", async () => { + spyOn(ITwinPlatform, "getExports").and.resolveTo({ + exports: [ + createMockExport(1, ITwinPlatform.ExportStatus.Invalid), + createMockExport(2, ITwinPlatform.ExportStatus.Invalid), + createMockExport(3, ITwinPlatform.ExportStatus.Invalid), + createMockExport(4, ITwinPlatform.ExportStatus.Invalid), + createMockExport(5, ITwinPlatform.ExportStatus.Invalid), + ], + }); + await expectAsync( + ITwinData.createTilesetFromIModelId("imodel-id-1"), + ).toBeRejectedWithError(RuntimeError, /All exports for this iModel/); + }); + + it("returns undefined when no exports returned", async () => { + spyOn(ITwinPlatform, "getExports").and.resolveTo({ + exports: [], + }); + const tileset = await ITwinData.createTilesetFromIModelId("imodel-id-1"); + expect(tileset).toBeUndefined(); + }); + + it("returns undefined when no exports are complete", async () => { + spyOn(ITwinPlatform, "getExports").and.resolveTo({ + exports: [ + createMockExport(1, ITwinPlatform.ExportStatus.InProgress), + createMockExport(2, ITwinPlatform.ExportStatus.NotStarted), + ], + }); + const tileset = await ITwinData.createTilesetFromIModelId("imodel-id-1"); + expect(tileset).toBeUndefined(); + }); + + it("returns undefined when no exports are complete", async () => { + spyOn(ITwinPlatform, "getExports").and.resolveTo({ + exports: [ + createMockExport(1, ITwinPlatform.ExportStatus.InProgress), + createMockExport(2, ITwinPlatform.ExportStatus.NotStarted), + ], + }); + const tileset = await ITwinData.createTilesetFromIModelId("imodel-id-1"); + expect(tileset).toBeUndefined(); + }); + + it("creates a tileset for the first complete export", async () => { + spyOn(ITwinPlatform, "getExports").and.resolveTo({ + exports: [ + createMockExport(1, ITwinPlatform.ExportStatus.Invalid), + createMockExport(2, ITwinPlatform.ExportStatus.Complete), + ], + }); + const tilesetSpy = spyOn(Cesium3DTileset, "fromUrl"); + await ITwinData.createTilesetFromIModelId("imodel-id-1"); + expect(tilesetSpy).toHaveBeenCalledTimes(1); + // Check that the resource url created is for the second export because + // the first is invalid + expect(tilesetSpy.calls.mostRecent().args[0].toString()).toEqual( + "https://example.com/link/to/mesh/2/tileset.json?query=param", + ); + expect(tilesetSpy.calls.mostRecent().args[1]).toBeUndefined(); + }); + + it("passes tileset options through to the tileset constructor", async () => { + spyOn(ITwinPlatform, "getExports").and.resolveTo({ + exports: [createMockExport(1, ITwinPlatform.ExportStatus.Complete)], + }); + const tilesetSpy = spyOn(Cesium3DTileset, "fromUrl"); + const tilesetOptions = { show: false }; + await ITwinData.createTilesetFromIModelId("imodel-id-1", tilesetOptions); + expect(tilesetSpy).toHaveBeenCalledTimes(1); + expect(tilesetSpy.calls.mostRecent().args[1]).toEqual(tilesetOptions); + }); + }); +});