Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions Documentation~/iTwinForUnity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# iTwin Mesh Exporter for Unity

A Unity Editor extension that enables you to browse your Bentley iTwin projects, select iModels and changesets, and export 3D mesh data directly from the iTwin Platform using the [Mesh‑Export API](https://developer.bentley.com/apis/mesh-export/). The exported mesh can be loaded into your Unity scene using [Cesium for Unity](https://cesium.com/platform/cesium-for-unity/).

## Features

- **OAuth2 PKCE Authentication**: Secure login with your Bentley account using the official OAuth2 PKCE flow.
- **Project & Model Browser**: Browse your iTwin projects and iModels, with search and pagination.
- **Changeset Selection**: Choose a specific changeset/version of your iModel to export.
- **One-Click Mesh Export**: Start and monitor mesh export jobs from the Unity Editor.
- **Automatic Tileset URL Generation**: Get a ready-to-use `tileset.json` URL for Cesium.
- **Cesium for Unity Integration**: Assign the exported mesh URL to a `Cesium3DTileset` in your scene, or create a new tileset directly from the tool.
- **Tilesets Manager Window**: Browse, search, and manage all iModels tilesets in your scene.
- **Tileset Metadata Inspector**: Inspect iTwin/iModel metadata for each tileset directly in the Unity Inspector.

---

## Configuration

### Register an iTwin App

1. Sign in at the [iTwin Platform Developer Portal](https://developer.bentley.com/).
2. Under **My Apps**, click **Register New App** → **Desktop / Mobile**.
3. Add a **Redirect URI** matching your editor listener (default: `http://localhost:58789/`).
4. Grant the `itwin-platform` scope.

### Configure Redirect URI

- Default listener URI: `http://localhost:58789/`
- To customize, use the **Advanced Settings** section in the Mesh Export tool's Authentication panel and update the same URI in your app's Redirect URIs list.

---

## Usage

### Mesh Export Tool

![Mesh Export Tool Demo](docs/demo-mesh-export.gif)

Open the tool via **Bentley → Mesh Export** in the Unity Editor.

#### 1. Authentication

- Enter your **Client ID** (from your registered iTwin app) and click **Save Client ID**.
- Click **Login to Bentley** to start the OAuth2 PKCE flow.
- Sign in via the browser and grant permissions. Upon success, return to Unity.
- The tool displays your login status and token expiry.

#### 2. Select Data

- **Fetch iTwins**: Click to load your iTwin projects.
- **Browse iModels**: Select a project to view its iModels.
- **Choose Changeset**: Pick a changeset/version for export (or use the latest).

#### 3. Export Mesh

- Click **Start Export Workflow** to begin the mesh export process.
- The tool starts an export job and polls for completion.
- When finished, it generates a **Tileset URL** (`tileset.json`) for Cesium.

#### 4. Cesium Integration

- Click in `Create Cesium Tileset` button to load the exported mesh into your scene.
- Optionally, in `Advanced Options`, click **Apply URL to Existing Tileset** to load the exported mesh into your existent GameObject.

### Tilesets Manager

![Tilesets Manager Demo](docs/demo-tilesets-manager.gif)

Access via **Bentley → Tilesets** in the Unity Editor.

- **Browse Tilesets**: View all iTwin tilesets in your scene with thumbnails and metadata.
- **Search**: Filter tilesets by name or description.
- **Quick Actions**: `Select` in hierarchy, `focus` in scene view, or `browse` tileset metadata.
- **Bentley Viewer Integration**: Open any iModel directly in the Bentley iTwin Viewer, maintaining full connection with the Bentley ecosystem.

---
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions Editor/iTwinForUnity.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Editor/iTwinForUnity/Authentication.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Editor/iTwinForUnity/Authentication/Core.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 117 additions & 0 deletions Editor/iTwinForUnity/Authentication/Core/AuthConfigManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;

/// <summary>
/// Manages OAuth configuration and URL generation for authentication flow
/// </summary>
public class AuthConfigManager
{
public static readonly Dictionary<string, string> FixedScopes = new Dictionary<string, string>
{
{ "mesh-export", AuthConstants.MESH_EXPORT_SCOPE },
{ "general", AuthConstants.GENERAL_SCOPE }
};

/// <summary>
/// Creates configuration for mesh export authentication
/// </summary>
public static AuthConfig CreateMeshExportConfig()
{
return new AuthConfig(
clientId: "native-2686",
redirectUri: AuthConstants.DEFAULT_REDIRECT_URI,
scopes: FixedScopes["mesh-export"]
);
}

/// <summary>
/// Creates configuration for general authentication
/// </summary>
public static AuthConfig CreateGeneralConfig()
{
return new AuthConfig(
clientId: "native-2686",
redirectUri: AuthConstants.DEFAULT_REDIRECT_URI,
scopes: FixedScopes["general"]
);
}

/// <summary>
/// Creates configuration from provided parameters
/// </summary>
public static AuthConfig CreateConfig(string clientId, string redirectUri, string scopes)
{
return new AuthConfig(clientId, redirectUri, scopes);
}

/// <summary>
/// Validates authentication configuration
/// </summary>
public static bool ValidateConfig(AuthConfig config)
{
if (string.IsNullOrEmpty(config.ClientId))
{
Debug.LogError("BentleyAuthManager_Editor: Client ID cannot be null or empty");
return false;
}

if (string.IsNullOrEmpty(config.RedirectUri))
{
Debug.LogError("BentleyAuthManager_Editor: Redirect URI cannot be null or empty");
return false;
}

if (!Uri.TryCreate(config.RedirectUri, UriKind.Absolute, out _))
{
Debug.LogError("BentleyAuthManager_Editor: Invalid redirect URI format");
return false;
}

return true;
}

/// <summary>
/// Encapsulates OAuth configuration parameters
/// </summary>
public class AuthConfig
{
public string ClientId { get; }
public string RedirectUri { get; }
public string Scopes { get; }

public AuthConfig(string clientId, string redirectUri, string scopes)
{
ClientId = clientId;
RedirectUri = redirectUri;
Scopes = scopes;
}

/// <summary>
/// Generates the authorization URL for OAuth flow
/// </summary>
public string GenerateAuthorizationUrl(string state, string codeChallenge)
{
var parameters = new Dictionary<string, string>
{
["client_id"] = ClientId,
["response_type"] = "code",
["redirect_uri"] = RedirectUri,
["scope"] = Scopes,
["state"] = state,
["code_challenge"] = codeChallenge,
["code_challenge_method"] = "S256"
};

var queryString = new StringBuilder();
foreach (var param in parameters)
{
if (queryString.Length > 0) queryString.Append("&");
queryString.Append($"{Uri.EscapeDataString(param.Key)}={Uri.EscapeDataString(param.Value)}");
}

return $"{AuthConstants.AUTHORIZATION_URL}?{queryString}";
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

129 changes: 129 additions & 0 deletions Editor/iTwinForUnity/Authentication/Core/AuthStateManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using UnityEditor;

/// <summary>
/// Manages token lifecycle including storage, validation, and expiry checking
/// </summary>
public class AuthStateManager
{
private BentleyAuthManager parentAuth;

public AuthStateManager(BentleyAuthManager parentAuth)
{
this.parentAuth = parentAuth;
}

/// <summary>
/// Gets the current valid access token, returning null if expired/invalid.
/// Does NOT automatically trigger refresh or login.
/// </summary>
public string GetCurrentAccessToken()
{
if (!string.IsNullOrEmpty(parentAuth.accessToken) &&
parentAuth.expiryTimeUtc > DateTime.UtcNow.AddMinutes(AuthConstants.TOKEN_EXPIRY_BUFFER_MINUTES))
return parentAuth.accessToken;
return null;
}

/// <summary>
/// Determines if user is logged in based on valid tokens
/// </summary>
public bool IsLoggedIn()
{
return GetCurrentAccessToken() != null || !string.IsNullOrEmpty(parentAuth.refreshToken);
}

/// <summary>
/// Gets the token expiry time in UTC
/// </summary>
public DateTime GetExpiryTimeUtc()
{
return parentAuth.expiryTimeUtc;
}

/// <summary>
/// Processes token response and updates internal state
/// </summary>
public void ProcessTokenResponse(string json)
{
try
{
var tokenResponse = Newtonsoft.Json.JsonConvert.DeserializeObject<TokenResponse>(json);
parentAuth.accessToken = tokenResponse.access_token;
parentAuth.refreshToken = tokenResponse.refresh_token;
parentAuth.expiryTimeUtc = DateTime.UtcNow.AddSeconds(tokenResponse.expires_in);

SaveTokensToPrefs();
}
catch (Exception ex)
{
UnityEngine.Debug.LogError($"BentleyAuthManager_Editor: Failed to process token response: {ex.Message}");
throw;
}
}

/// <summary>
/// Saves tokens to EditorPrefs for persistence
/// </summary>
public void SaveTokensToPrefs()
{
if (!string.IsNullOrEmpty(parentAuth.accessToken))
EditorPrefs.SetString(AuthConstants.PREF_ACCESS_TOKEN, parentAuth.accessToken);

if (!string.IsNullOrEmpty(parentAuth.refreshToken))
EditorPrefs.SetString(AuthConstants.PREF_REFRESH_TOKEN, parentAuth.refreshToken);

EditorPrefs.SetString(AuthConstants.PREF_EXPIRY, parentAuth.expiryTimeUtc.ToString("O"));
}

/// <summary>
/// Loads stored tokens from EditorPrefs
/// </summary>
public void LoadStoredTokens()
{
if (EditorPrefs.HasKey(AuthConstants.PREF_ACCESS_TOKEN) && EditorPrefs.HasKey(AuthConstants.PREF_EXPIRY))
{
parentAuth.accessToken = EditorPrefs.GetString(AuthConstants.PREF_ACCESS_TOKEN);
parentAuth.refreshToken = EditorPrefs.GetString(AuthConstants.PREF_REFRESH_TOKEN, "");
string storedExpiry = EditorPrefs.GetString(AuthConstants.PREF_EXPIRY);

if (DateTime.TryParse(storedExpiry, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
{
parentAuth.expiryTimeUtc = dt;
// Clear expired tokens immediately
if (parentAuth.expiryTimeUtc <= DateTime.UtcNow)
{
ClearTokens();
}
}
else
{
UnityEngine.Debug.LogWarning("BentleyAuthManager_Editor: Could not parse stored expiry time, clearing tokens.");
ClearTokens();
}
}
}

/// <summary>
/// Clears all stored tokens and state
/// </summary>
public void ClearTokens()
{
parentAuth.accessToken = null;
parentAuth.refreshToken = null;
parentAuth.expiryTimeUtc = DateTime.MinValue;

// Clear from EditorPrefs
EditorPrefs.DeleteKey(AuthConstants.PREF_ACCESS_TOKEN);
EditorPrefs.DeleteKey(AuthConstants.PREF_REFRESH_TOKEN);
EditorPrefs.DeleteKey(AuthConstants.PREF_EXPIRY);
}

[Serializable]
private class TokenResponse
{
public string access_token;
public string refresh_token;
public int expires_in;
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading