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 @@ + + +
+ + + + + +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 {Promiseundefined
.
+ * 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 {Promiseundefined
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);
+ });
+ });
+});