diff --git a/Documentation~/iTwinForUnity/README.md b/Documentation~/iTwinForUnity/README.md new file mode 100644 index 00000000..0a75ba90 --- /dev/null +++ b/Documentation~/iTwinForUnity/README.md @@ -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. + +--- \ No newline at end of file diff --git a/Documentation~/iTwinForUnity/docs/demo-mesh-export.gif b/Documentation~/iTwinForUnity/docs/demo-mesh-export.gif new file mode 100644 index 00000000..b3ae1e8e Binary files /dev/null and b/Documentation~/iTwinForUnity/docs/demo-mesh-export.gif differ diff --git a/Documentation~/iTwinForUnity/docs/demo-tilesets-manager.gif b/Documentation~/iTwinForUnity/docs/demo-tilesets-manager.gif new file mode 100644 index 00000000..078b9c4d Binary files /dev/null and b/Documentation~/iTwinForUnity/docs/demo-tilesets-manager.gif differ diff --git a/Editor/iTwinForUnity.meta b/Editor/iTwinForUnity.meta new file mode 100644 index 00000000..eeec93ca --- /dev/null +++ b/Editor/iTwinForUnity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce3f0bc5912c5e34c86489d6ad9c1610 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Authentication.meta b/Editor/iTwinForUnity/Authentication.meta new file mode 100644 index 00000000..28ba8812 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 493da90826e83aa4dabfae48e9d43ebb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Authentication/Core.meta b/Editor/iTwinForUnity/Authentication/Core.meta new file mode 100644 index 00000000..a4e6645d --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 71a1747d2c5ed55448dd4b3a56df74d4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Authentication/Core/AuthConfigManager.cs b/Editor/iTwinForUnity/Authentication/Core/AuthConfigManager.cs new file mode 100644 index 00000000..47a111dd --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/AuthConfigManager.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +/// +/// Manages OAuth configuration and URL generation for authentication flow +/// +public class AuthConfigManager +{ + public static readonly Dictionary FixedScopes = new Dictionary + { + { "mesh-export", AuthConstants.MESH_EXPORT_SCOPE }, + { "general", AuthConstants.GENERAL_SCOPE } + }; + + /// + /// Creates configuration for mesh export authentication + /// + public static AuthConfig CreateMeshExportConfig() + { + return new AuthConfig( + clientId: "native-2686", + redirectUri: AuthConstants.DEFAULT_REDIRECT_URI, + scopes: FixedScopes["mesh-export"] + ); + } + + /// + /// Creates configuration for general authentication + /// + public static AuthConfig CreateGeneralConfig() + { + return new AuthConfig( + clientId: "native-2686", + redirectUri: AuthConstants.DEFAULT_REDIRECT_URI, + scopes: FixedScopes["general"] + ); + } + + /// + /// Creates configuration from provided parameters + /// + public static AuthConfig CreateConfig(string clientId, string redirectUri, string scopes) + { + return new AuthConfig(clientId, redirectUri, scopes); + } + + /// + /// Validates authentication configuration + /// + 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; + } + + /// + /// Encapsulates OAuth configuration parameters + /// + 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; + } + + /// + /// Generates the authorization URL for OAuth flow + /// + public string GenerateAuthorizationUrl(string state, string codeChallenge) + { + var parameters = new Dictionary + { + ["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}"; + } + } +} diff --git a/Editor/iTwinForUnity/Authentication/Core/AuthConfigManager.cs.meta b/Editor/iTwinForUnity/Authentication/Core/AuthConfigManager.cs.meta new file mode 100644 index 00000000..257e94ba --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/AuthConfigManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 80c06bad522f02e4eb147349278d3f0c \ No newline at end of file diff --git a/Editor/iTwinForUnity/Authentication/Core/AuthStateManager.cs b/Editor/iTwinForUnity/Authentication/Core/AuthStateManager.cs new file mode 100644 index 00000000..5c924779 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/AuthStateManager.cs @@ -0,0 +1,129 @@ +using System; +using UnityEditor; + +/// +/// Manages token lifecycle including storage, validation, and expiry checking +/// +public class AuthStateManager +{ + private BentleyAuthManager parentAuth; + + public AuthStateManager(BentleyAuthManager parentAuth) + { + this.parentAuth = parentAuth; + } + + /// + /// Gets the current valid access token, returning null if expired/invalid. + /// Does NOT automatically trigger refresh or login. + /// + public string GetCurrentAccessToken() + { + if (!string.IsNullOrEmpty(parentAuth.accessToken) && + parentAuth.expiryTimeUtc > DateTime.UtcNow.AddMinutes(AuthConstants.TOKEN_EXPIRY_BUFFER_MINUTES)) + return parentAuth.accessToken; + return null; + } + + /// + /// Determines if user is logged in based on valid tokens + /// + public bool IsLoggedIn() + { + return GetCurrentAccessToken() != null || !string.IsNullOrEmpty(parentAuth.refreshToken); + } + + /// + /// Gets the token expiry time in UTC + /// + public DateTime GetExpiryTimeUtc() + { + return parentAuth.expiryTimeUtc; + } + + /// + /// Processes token response and updates internal state + /// + public void ProcessTokenResponse(string json) + { + try + { + var tokenResponse = Newtonsoft.Json.JsonConvert.DeserializeObject(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; + } + } + + /// + /// Saves tokens to EditorPrefs for persistence + /// + 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")); + } + + /// + /// Loads stored tokens from EditorPrefs + /// + 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(); + } + } + } + + /// + /// Clears all stored tokens and state + /// + 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; + } +} diff --git a/Editor/iTwinForUnity/Authentication/Core/AuthStateManager.cs.meta b/Editor/iTwinForUnity/Authentication/Core/AuthStateManager.cs.meta new file mode 100644 index 00000000..4da83b56 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/AuthStateManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 89664de2d4117d046ab295c24e681a79 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Authentication/Core/BentleyAuthCore.cs b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthCore.cs new file mode 100644 index 00000000..485e7ccb --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthCore.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using UnityEngine; + +/// +/// Core authentication logic coordinator that orchestrates the OAuth flow +/// +public class BentleyAuthCore +{ + private readonly BentleyAuthManager parentAuth; + private readonly AuthStateManager stateManager; + private readonly PKCEManager pkceManager; + private readonly HttpListenerManager httpManager; + private readonly TokenExchangeClient tokenClient; + private readonly MainThreadSynchronizer synchronizer; + + public BentleyAuthCore(BentleyAuthManager parentAuth, AuthStateManager stateManager) + { + this.parentAuth = parentAuth; + this.stateManager = stateManager; + this.pkceManager = new PKCEManager(); + this.httpManager = new HttpListenerManager(); + this.tokenClient = new TokenExchangeClient(); + this.synchronizer = new MainThreadSynchronizer(); + } + + /// + /// Initiates the complete OAuth authentication flow + /// + public async Task StartAuthenticationFlow(AuthConfigManager.AuthConfig config, Action onComplete, Action onError) + { + try + { + if (!AuthConfigManager.ValidateConfig(config)) + { + onError?.Invoke("Invalid authentication configuration"); + return false; + } + + // Generate PKCE parameters + string codeVerifier = pkceManager.GenerateCodeVerifier(); + string codeChallenge = pkceManager.GenerateCodeChallenge(codeVerifier); + string state = pkceManager.GenerateSecureState(); + + // Start HTTP listener + if (!httpManager.StartListener(config.RedirectUri).Result) + { + onError?.Invoke("Failed to start HTTP listener"); + return false; + } + + // Generate and open authorization URL + string authUrl = config.GenerateAuthorizationUrl(state, codeChallenge); + Application.OpenURL(authUrl); + + // Wait for callback + var callbackResult = await httpManager.WaitForCallback(); + + if (callbackResult.IsError) + { + await synchronizer.ExecuteOnMainThread(() => onError?.Invoke(callbackResult.Error)); + return false; + } + + // Validate state parameter + if (callbackResult.State != state) + { + await synchronizer.ExecuteOnMainThread(() => onError?.Invoke("State parameter mismatch - possible CSRF attack")); + return false; + } + + // Exchange authorization code for tokens + string tokenResponse = await tokenClient.ExchangeCodeForTokens(config, callbackResult.Code, codeVerifier); + + if (string.IsNullOrEmpty(tokenResponse)) + { + await synchronizer.ExecuteOnMainThread(() => onError?.Invoke("Failed to exchange authorization code for tokens")); + return false; + } + + // Process token response on main thread + await synchronizer.ExecuteOnMainThread(() => + { + try + { + stateManager.ProcessTokenResponse(tokenResponse); + onComplete?.Invoke(stateManager.GetCurrentAccessToken()); + } + catch (Exception ex) + { + onError?.Invoke($"Failed to process token response: {ex.Message}"); + } + }); + + return true; + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Authentication flow failed: {ex.Message}"); + await synchronizer.ExecuteOnMainThread(() => onError?.Invoke($"Authentication failed: {ex.Message}")); + return false; + } + finally + { + httpManager.StopListener(); + } + } + + /// + /// Attempts to refresh the access token using stored refresh token + /// + public async Task RefreshTokenAsync(AuthConfigManager.AuthConfig config, Action onComplete, Action onError) + { + try + { + if (string.IsNullOrEmpty(parentAuth.refreshToken)) + { + await synchronizer.ExecuteOnMainThread(() => onError?.Invoke("No refresh token available")); + return false; + } + + string tokenResponse = await tokenClient.RefreshAccessToken(config, parentAuth.refreshToken); + + if (string.IsNullOrEmpty(tokenResponse)) + { + await synchronizer.ExecuteOnMainThread(() => onError?.Invoke("Failed to refresh access token")); + return false; + } + + await synchronizer.ExecuteOnMainThread(() => + { + try + { + stateManager.ProcessTokenResponse(tokenResponse); + onComplete?.Invoke(stateManager.GetCurrentAccessToken()); + } + catch (Exception ex) + { + onError?.Invoke($"Failed to process refresh token response: {ex.Message}"); + } + }); + + return true; + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Token refresh failed: {ex.Message}"); + await synchronizer.ExecuteOnMainThread(() => onError?.Invoke($"Token refresh failed: {ex.Message}")); + return false; + } + } + + /// + /// Performs complete logout including token cleanup + /// + public void Logout() + { + try + { + stateManager.ClearTokens(); + httpManager.StopListener(); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error during logout: {ex.Message}"); + } + } + + /// + /// Stops only the HTTP listener without clearing tokens + /// + public void StopListener() + { + try + { + httpManager.StopListener(); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error stopping HTTP listener: {ex.Message}"); + } + } + + /// + /// Represents the result of an OAuth callback + /// + public class CallbackResult + { + public bool IsError => !string.IsNullOrEmpty(Error); + public string Code { get; set; } + public string State { get; set; } + public string Error { get; set; } + } +} diff --git a/Editor/iTwinForUnity/Authentication/Core/BentleyAuthCore.cs.meta b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthCore.cs.meta new file mode 100644 index 00000000..57ef5d60 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthCore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b3ca340e7b982ba438046c0c51861b37 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Authentication/Core/BentleyAuthManager.cs b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthManager.cs new file mode 100644 index 00000000..d6175259 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthManager.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections; +using System.Threading.Tasks; +using UnityEditor; +using UnityEngine; + +/// +/// Refactored BentleyAuthManager using component-based architecture +/// Maintains exact same public API for backward compatibility +/// +public class BentleyAuthManager +{ + [Header("Bentley IMS Configuration")] + public string clientId; + public string redirectUri; + + // Core components + private readonly AuthStateManager stateManager; + private readonly BentleyAuthCore authCore; + private readonly MainThreadSynchronizer synchronizer; + + // Internal state (exposed for components) + internal string accessToken; + internal string refreshToken; + internal DateTime expiryTimeUtc; + + // Legacy callback support + public Action OnLoginComplete; + public Action RequestExchangeCode; + + // Current authentication configuration + private AuthConfigManager.AuthConfig currentConfig; + + public BentleyAuthManager() + { + // Initialize components + stateManager = new AuthStateManager(this); + authCore = new BentleyAuthCore(this, stateManager); + synchronizer = new MainThreadSynchronizer(); + + // Load stored client ID and tokens + clientId = EditorPrefs.GetString(AuthConstants.PREF_CLIENT_ID, ""); + redirectUri = EditorPrefs.GetString(AuthConstants.PREF_REDIRECT_URI, AuthConstants.DEFAULT_REDIRECT_URI); + stateManager.LoadStoredTokens(); + + // Set default configuration + UpdateAuthConfiguration(); + } + + /// + /// Updates authentication configuration based on current client ID and redirect URI + /// + private void UpdateAuthConfiguration() + { + if (!string.IsNullOrEmpty(clientId)) + { + currentConfig = AuthConfigManager.CreateConfig( + clientId, + !string.IsNullOrEmpty(redirectUri) ? redirectUri : AuthConstants.DEFAULT_REDIRECT_URI, + AuthConstants.DEFAULT_SCOPES + ); + } + else + { + currentConfig = AuthConfigManager.CreateConfig( + "native-2686", // Default mesh export client ID + !string.IsNullOrEmpty(redirectUri) ? redirectUri : AuthConstants.DEFAULT_REDIRECT_URI, + AuthConstants.MESH_EXPORT_SCOPE + ); + clientId = currentConfig.ClientId; + } + } + + /// + /// Call this when Client ID changes in the EditorWindow + /// + public void SaveClientId() + { + EditorPrefs.SetString(AuthConstants.PREF_CLIENT_ID, clientId); + UpdateAuthConfiguration(); + } + + /// + /// Call this when Redirect URI changes in the EditorWindow + /// + public void SaveRedirectUri() + { + EditorPrefs.SetString(AuthConstants.PREF_REDIRECT_URI, redirectUri); + UpdateAuthConfiguration(); + } + + /// + /// Gets the current valid access token, returning null if expired/invalid. + /// Does NOT automatically trigger refresh or login. + /// + public string GetCurrentAccessToken() + { + return stateManager.GetCurrentAccessToken(); + } + + /// + /// Determines if user is logged in based on valid tokens + /// + public bool IsLoggedIn() + { + return stateManager.IsLoggedIn(); + } + + /// + /// Gets the token expiry time in UTC + /// + public DateTime GetExpiryTimeUtc() + { + return stateManager.GetExpiryTimeUtc(); + } + + /// + /// Legacy coroutine interface for getting access token + /// Tries current token, then refresh, then initiates login if needed + /// + public IEnumerator GetAccessTokenCoroutine(Action callback) + { + // Convert async flow to coroutine for Unity compatibility + var task = GetAccessTokenAsync(); + + while (!task.IsCompleted) + { + yield return null; + } + + if (task.Exception != null) + { + callback?.Invoke(null, task.Exception.GetBaseException().Message); + } + else + { + callback?.Invoke(task.Result, null); + } + } + + /// + /// Async method to get a valid access token + /// + public async Task GetAccessTokenAsync() + { + try + { + // 1. Check current token + string currentToken = GetCurrentAccessToken(); + if (currentToken != null) + { + return currentToken; + } + + // 2. Try refreshing if refresh token available + if (!string.IsNullOrEmpty(refreshToken)) + { + var refreshResult = await TryRefreshTokenAsync(); + if (refreshResult.Success) + { + return refreshResult.Token; + } + else + { + Debug.LogWarning("BentleyAuthManager_Editor: Refresh failed. Proceeding to login."); + if (!string.IsNullOrEmpty(refreshResult.Error)) + { + Logout(); // Clear potentially invalid tokens + } + } + } + + // 3. Initiate full login flow + var loginResult = await PerformLoginAsync(); + if (loginResult.Success) + { + return loginResult.Token; + } + else + { + throw new InvalidOperationException($"Login failed: {loginResult.Error}"); + } + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error getting access token: {ex.Message}"); + throw; + } + } + + /// + /// Legacy coroutine interface for token refresh + /// + public IEnumerator RefreshTokenCoroutine(Action callback) + { + var task = TryRefreshTokenAsync(); + + while (!task.IsCompleted) + { + yield return null; + } + + if (task.Exception != null) + { + callback?.Invoke(false, task.Exception.GetBaseException().Message); + } + else + { + var result = task.Result; + callback?.Invoke(result.Success, result.Error); + } + } + + /// + /// Attempts to refresh the access token + /// + private async Task TryRefreshTokenAsync() + { + if (string.IsNullOrEmpty(refreshToken)) + { + return new AuthResult { Success = false, Error = "No refresh token available" }; + } + + try + { + UpdateAuthConfiguration(); + + var taskSource = new TaskCompletionSource(); + + await authCore.RefreshTokenAsync( + currentConfig, + onComplete: token => taskSource.SetResult(new AuthResult { Success = true, Token = token }), + onError: error => taskSource.SetResult(new AuthResult { Success = false, Error = error }) + ); + + return await taskSource.Task; + } + catch (Exception ex) + { + return new AuthResult { Success = false, Error = ex.Message }; + } + } + + /// + /// Legacy method for starting login (browser-based) + /// + public void StartLogin() + { + if (string.IsNullOrEmpty(clientId)) + { + Debug.LogError("BentleyAuthManager_Editor: Client ID is not set!"); + OnLoginComplete?.Invoke(false, "Client ID is not set."); + return; + } + + // Start async login and handle callbacks + _ = PerformLoginAsync().ContinueWith(task => + { + if (task.IsFaulted) + { + synchronizer.ScheduleOnMainThread(() => + OnLoginComplete?.Invoke(false, task.Exception?.GetBaseException()?.Message ?? "Login failed")); + } + else + { + var result = task.Result; + synchronizer.ScheduleOnMainThread(() => + OnLoginComplete?.Invoke(result.Success, result.Error)); + } + }); + } + + /// + /// Performs the complete login flow + /// + private async Task PerformLoginAsync() + { + try + { + UpdateAuthConfiguration(); + + if (!AuthConfigManager.ValidateConfig(currentConfig)) + { + return new AuthResult { Success = false, Error = "Invalid authentication configuration" }; + } + + var taskSource = new TaskCompletionSource(); + + bool success = await authCore.StartAuthenticationFlow( + currentConfig, + onComplete: token => taskSource.SetResult(new AuthResult { Success = true, Token = token }), + onError: error => taskSource.SetResult(new AuthResult { Success = false, Error = error }) + ); + + if (!success) + { + return new AuthResult { Success = false, Error = "Failed to start authentication flow" }; + } + + return await taskSource.Task; + } + catch (Exception ex) + { + return new AuthResult { Success = false, Error = ex.Message }; + } + } + + /// + /// Legacy coroutine interface for code exchange + /// + public IEnumerator ExchangeCodeCoroutine(string authorizationCode, Action callback) + { + // This method is now handled internally by the authentication flow + // Maintaining for backward compatibility but implementation is simplified + callback?.Invoke(true, "Code exchange handled internally"); + yield break; + } + + /// + /// Clears all tokens and performs logout + /// + public void Logout() + { + authCore.Logout(); + } + + /// + /// Legacy cleanup method + /// + public void Cleanup() + { + authCore.Logout(); + } + + /// + /// Cleanup method that only stops HTTP listener without clearing tokens + /// Use this when closing editor windows to preserve authentication across sessions + /// + public void CleanupWithoutLogout() + { + try + { + authCore.StopListener(); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error during cleanup: {ex.Message}"); + } + } + + /// + /// Legacy method for stopping HTTP listener + /// + private void StopHttpListener() + { + // Now handled internally by HttpListenerManager + } + + /// + /// Legacy method for clearing tokens + /// + private void ClearTokens() + { + stateManager.ClearTokens(); + } + + /// + /// Result structure for authentication operations + /// + private class AuthResult + { + public bool Success { get; set; } + public string Token { get; set; } + public string Error { get; set; } + } +} diff --git a/Editor/iTwinForUnity/Authentication/Core/BentleyAuthManager.cs.meta b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthManager.cs.meta new file mode 100644 index 00000000..f2bb7ed8 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/Core/BentleyAuthManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c939324d30c35364986bcef146145da0 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Authentication/OAuth.meta b/Editor/iTwinForUnity/Authentication/OAuth.meta new file mode 100644 index 00000000..8c22bbf5 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/OAuth.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ceff30e2820511d42bd8bc86fd5cc9de +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Authentication/OAuth/HttpListenerManager.cs b/Editor/iTwinForUnity/Authentication/OAuth/HttpListenerManager.cs new file mode 100644 index 00000000..8c22d20b --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/OAuth/HttpListenerManager.cs @@ -0,0 +1,220 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using System.Text.RegularExpressions; +using System.Collections.Generic; +using System.Collections.Specialized; +using UnityEngine; + +/// +/// Manages HTTP listener for OAuth callback handling +/// +public class HttpListenerManager +{ + private HttpListener listener; + private TaskCompletionSource callbackCompletion; + private string redirectUri; + + /// + /// Parse query string into a NameValueCollection (Unity-compatible replacement for HttpUtility.ParseQueryString) + /// + private static NameValueCollection ParseQueryString(string query) + { + var result = new NameValueCollection(); + + if (string.IsNullOrEmpty(query)) + return result; + + // Remove leading '?' if present + if (query.StartsWith("?")) + query = query.Substring(1); + + string[] pairs = query.Split('&'); + foreach (string pair in pairs) + { + if (string.IsNullOrEmpty(pair)) continue; + + string[] parts = pair.Split('='); + if (parts.Length >= 2) + { + string key = UnityEngine.Networking.UnityWebRequest.UnEscapeURL(parts[0]); + string value = UnityEngine.Networking.UnityWebRequest.UnEscapeURL(parts[1]); + result[key] = value; + } + else if (parts.Length == 1) + { + string key = UnityEngine.Networking.UnityWebRequest.UnEscapeURL(parts[0]); + result[key] = ""; + } + } + + return result; + } + + /// + /// Starts the HTTP listener on the specified redirect URI + /// + public Task StartListener(string redirectUri) + { + try + { + this.redirectUri = redirectUri; + + if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out Uri uri)) + { + Debug.LogError($"BentleyAuthManager_Editor: Invalid redirect URI: {redirectUri}"); + return Task.FromResult(false); + } + + listener = new HttpListener(); + listener.Prefixes.Add($"{uri.Scheme}://{uri.Host}:{uri.Port}/"); + + listener.Start(); + + return Task.FromResult(true); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Failed to start HTTP listener: {ex.Message}"); + return Task.FromResult(false); + } + } + + /// + /// Waits for the OAuth callback and returns the result + /// + public async Task WaitForCallback() + { + if (listener == null || !listener.IsListening) + { + return new BentleyAuthCore.CallbackResult { Error = "HTTP listener not started" }; + } + + callbackCompletion = new TaskCompletionSource(); + + try + { + // Start listening for requests in background + _ = Task.Run(async () => + { + try + { + var context = await listener.GetContextAsync(); + await ProcessCallback(context); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error processing callback: {ex.Message}"); + callbackCompletion?.TrySetResult(new BentleyAuthCore.CallbackResult { Error = ex.Message }); + } + }); + + return await callbackCompletion.Task; + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error waiting for callback: {ex.Message}"); + return new BentleyAuthCore.CallbackResult { Error = ex.Message }; + } + } + + /// + /// Processes the incoming OAuth callback request + /// + private async Task ProcessCallback(HttpListenerContext context) + { + var request = context.Request; + var response = context.Response; + + try + { + // Parse query parameters + var queryParams = ParseQueryString(request.Url.Query); + + var result = new BentleyAuthCore.CallbackResult(); + + // Check for error in callback + if (!string.IsNullOrEmpty(queryParams["error"])) + { + result.Error = $"OAuth error: {queryParams["error"]} - {queryParams["error_description"]}"; + await SendHtmlResponse(response, AuthConstants.ERROR_RESPONSE_HTML); + } + else if (!string.IsNullOrEmpty(queryParams["code"])) + { + result.Code = queryParams["code"]; + result.State = queryParams["state"]; + await SendHtmlResponse(response, AuthConstants.SUCCESS_RESPONSE_HTML); + } + else + { + result.Error = "No authorization code or error received in callback"; + await SendHtmlResponse(response, AuthConstants.ERROR_RESPONSE_HTML); + } + + callbackCompletion?.TrySetResult(result); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error processing OAuth callback: {ex.Message}"); + + try + { + await SendHtmlResponse(response, AuthConstants.ERROR_RESPONSE_HTML); + } + catch { } + + callbackCompletion?.TrySetResult(new BentleyAuthCore.CallbackResult { Error = ex.Message }); + } + } + + /// + /// Sends HTML response to the browser + /// + private async Task SendHtmlResponse(HttpListenerResponse response, string html) + { + try + { + byte[] buffer = System.Text.Encoding.UTF8.GetBytes(html); + response.ContentLength64 = buffer.Length; + response.ContentType = "text/html"; + response.StatusCode = 200; + + await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); + response.OutputStream.Close(); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error sending HTML response: {ex.Message}"); + } + } + + /// + /// Stops the HTTP listener + /// + public void StopListener() + { + try + { + if (listener?.IsListening == true) + { + listener.Stop(); + } + + listener?.Close(); + listener = null; + + // Cancel any pending callback + callbackCompletion?.TrySetCanceled(); + callbackCompletion = null; + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Error stopping HTTP listener: {ex.Message}"); + } + } + + /// + /// Checks if the listener is currently running + /// + public bool IsListening => listener?.IsListening == true; +} diff --git a/Editor/iTwinForUnity/Authentication/OAuth/HttpListenerManager.cs.meta b/Editor/iTwinForUnity/Authentication/OAuth/HttpListenerManager.cs.meta new file mode 100644 index 00000000..1eae6b07 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/OAuth/HttpListenerManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c2a107c75ac4e344d8ea221d578b33f8 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Authentication/OAuth/PKCEManager.cs b/Editor/iTwinForUnity/Authentication/OAuth/PKCEManager.cs new file mode 100644 index 00000000..9923f79a --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/OAuth/PKCEManager.cs @@ -0,0 +1,107 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +/// +/// Manages PKCE (Proof Key for Code Exchange) parameters for OAuth security +/// +public class PKCEManager +{ + private const int CODE_VERIFIER_LENGTH = 128; + private const string CODE_VERIFIER_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + + /// + /// Generates a cryptographically secure code verifier for PKCE + /// + public string GenerateCodeVerifier() + { + var bytes = new byte[CODE_VERIFIER_LENGTH]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + + var result = new StringBuilder(CODE_VERIFIER_LENGTH); + foreach (byte b in bytes) + { + result.Append(CODE_VERIFIER_CHARSET[b % CODE_VERIFIER_CHARSET.Length]); + } + + return result.ToString(); + } + + /// + /// Generates the code challenge from the code verifier using SHA256 + /// + public string GenerateCodeChallenge(string codeVerifier) + { + if (string.IsNullOrEmpty(codeVerifier)) + throw new ArgumentException("Code verifier cannot be null or empty", nameof(codeVerifier)); + + using (var sha256 = SHA256.Create()) + { + byte[] challengeBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + return Convert.ToBase64String(challengeBytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + } + + /// + /// Generates a secure random state parameter for CSRF protection + /// + public string GenerateSecureState() + { + var bytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(bytes); + } + + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + /// + /// Validates that a code verifier meets PKCE requirements + /// + public bool ValidateCodeVerifier(string codeVerifier) + { + if (string.IsNullOrEmpty(codeVerifier)) + return false; + + if (codeVerifier.Length < 43 || codeVerifier.Length > 128) + return false; + + // Check that all characters are valid for PKCE + foreach (char c in codeVerifier) + { + if (!CODE_VERIFIER_CHARSET.Contains(c.ToString())) + return false; + } + + return true; + } + + /// + /// Verifies that a code challenge was correctly generated from a code verifier + /// + public bool VerifyCodeChallenge(string codeVerifier, string codeChallenge) + { + if (string.IsNullOrEmpty(codeVerifier) || string.IsNullOrEmpty(codeChallenge)) + return false; + + try + { + string expectedChallenge = GenerateCodeChallenge(codeVerifier); + return expectedChallenge == codeChallenge; + } + catch + { + return false; + } + } +} diff --git a/Editor/iTwinForUnity/Authentication/OAuth/PKCEManager.cs.meta b/Editor/iTwinForUnity/Authentication/OAuth/PKCEManager.cs.meta new file mode 100644 index 00000000..8812856a --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/OAuth/PKCEManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 386e3f6ca6941b64fbe286929fc29857 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Authentication/OAuth/TokenExchangeClient.cs b/Editor/iTwinForUnity/Authentication/OAuth/TokenExchangeClient.cs new file mode 100644 index 00000000..d26cfcff --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/OAuth/TokenExchangeClient.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using UnityEngine; + +/// +/// Handles OAuth token exchange operations with the authorization server +/// +public class TokenExchangeClient +{ + private static readonly HttpClient httpClient = new HttpClient(); + + static TokenExchangeClient() + { + httpClient.Timeout = TimeSpan.FromSeconds(30); + } + + /// + /// Exchanges authorization code for access and refresh tokens + /// + public async Task ExchangeCodeForTokens(AuthConfigManager.AuthConfig config, string authorizationCode, string codeVerifier) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + + if (string.IsNullOrEmpty(authorizationCode)) + throw new ArgumentException("Authorization code cannot be null or empty", nameof(authorizationCode)); + + if (string.IsNullOrEmpty(codeVerifier)) + throw new ArgumentException("Code verifier cannot be null or empty", nameof(codeVerifier)); + + try + { + var parameters = new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = config.ClientId, + ["code"] = authorizationCode, + ["redirect_uri"] = config.RedirectUri, + ["code_verifier"] = codeVerifier + }; + + return await SendTokenRequest(parameters); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Failed to exchange authorization code: {ex.Message}"); + return null; + } + } + + /// + /// Refreshes the access token using the refresh token + /// + public async Task RefreshAccessToken(AuthConfigManager.AuthConfig config, string refreshToken) + { + if (config == null) + throw new ArgumentNullException(nameof(config)); + + if (string.IsNullOrEmpty(refreshToken)) + throw new ArgumentException("Refresh token cannot be null or empty", nameof(refreshToken)); + + try + { + var parameters = new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = config.ClientId, + ["refresh_token"] = refreshToken + }; + + return await SendTokenRequest(parameters); + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Failed to refresh access token: {ex.Message}"); + return null; + } + } + + /// + /// Sends a token request to the authorization server + /// + private async Task SendTokenRequest(Dictionary parameters) + { + try + { + var content = new FormUrlEncodedContent(parameters); + + var response = await httpClient.PostAsync(AuthConstants.TOKEN_URL, content); + + if (!response.IsSuccessStatusCode) + { + string errorContent = await response.Content.ReadAsStringAsync(); + Debug.LogError($"BentleyAuthManager_Editor: Token request failed with status {response.StatusCode}: {errorContent}"); + return null; + } + + string responseContent = await response.Content.ReadAsStringAsync(); + + return responseContent; + } + catch (HttpRequestException ex) + { + Debug.LogError($"BentleyAuthManager_Editor: HTTP error during token request: {ex.Message}"); + return null; + } + catch (TaskCanceledException ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Token request timed out: {ex.Message}"); + return null; + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Unexpected error during token request: {ex.Message}"); + return null; + } + } + + /// + /// Validates token response format before processing + /// + public bool ValidateTokenResponse(string tokenResponse) + { + if (string.IsNullOrEmpty(tokenResponse)) + return false; + + try + { + var tokenData = Newtonsoft.Json.JsonConvert.DeserializeObject(tokenResponse); + + return !string.IsNullOrEmpty(tokenData.access_token) && + tokenData.expires_in > 0; + } + catch (Exception ex) + { + Debug.LogError($"BentleyAuthManager_Editor: Token response validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Model for token response validation + /// + private class TokenValidationModel + { + public string access_token { get; set; } + public string refresh_token { get; set; } + public int expires_in { get; set; } + public string token_type { get; set; } + } +} diff --git a/Editor/iTwinForUnity/Authentication/OAuth/TokenExchangeClient.cs.meta b/Editor/iTwinForUnity/Authentication/OAuth/TokenExchangeClient.cs.meta new file mode 100644 index 00000000..5cd7949c --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/OAuth/TokenExchangeClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f1b24ba6e7bf2541bfba91d5cd12696 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Authentication/README.md b/Editor/iTwinForUnity/Authentication/README.md new file mode 100644 index 00000000..a5e5612d --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/README.md @@ -0,0 +1,189 @@ +# Authentication System + +This directory contains all components related to OAuth 2.0 authentication with Bentley's iTwin platform using PKCE (Proof Key for Code Exchange) for enhanced security. + +## Overview + +The authentication system provides secure access to Bentley's iTwin platform APIs through: +- OAuth 2.0 authorization with PKCE flow +- Secure token storage and management +- Automatic token refresh capabilities +- Session persistence across Unity Editor restarts + +## Architecture + +The authentication system follows a layered architecture: +- **Core Components**: Handle token lifecycle and state management +- **OAuth Components**: Implement the OAuth 2.0 PKCE flow +- **Main Manager**: Orchestrates the authentication process + +## Security Features + +### PKCE (Proof Key for Code Exchange) +- Generates cryptographically secure code verifier and challenge +- Prevents authorization code interception attacks +- No client secret required, suitable for public clients + +### Secure Storage +- Tokens are encrypted before storage in Unity Editor Preferences +- Sensitive data is cleared from memory when no longer needed +- Session data persists securely between Unity sessions + +### Token Management +- Automatic token expiration checking +- Proactive token refresh before expiration +- Graceful handling of expired or invalid tokens + +## Directory Structure + +``` +Authentication/ +├── README.md # This file - Authentication overview +├── Core/ # Core authentication managers +│ ├── AuthStateManager.cs # Token lifecycle and validation +│ ├── AuthConfigManager.cs # Configuration and storage +│ └── BentleyAuthCore.cs # Core authentication logic +└── OAuth/ # OAuth 2.0 PKCE implementation + ├── PKCEManager.cs # PKCE code generation and validation + ├── HttpListenerManager.cs # Local HTTP server for callbacks + └── TokenExchangeClient.cs # Token exchange and refresh +``` + +## Authentication Flow + +1. **Initialization**: Load configuration and check for existing valid tokens +2. **PKCE Generation**: Create code verifier and challenge for the OAuth flow +3. **Authorization Request**: Open browser to Bentley's authorization server +4. **Local Callback**: Start local HTTP server to receive authorization code +5. **Token Exchange**: Exchange authorization code for access and refresh tokens +6. **Token Storage**: Securely store tokens with encryption +7. **Token Refresh**: Automatically refresh tokens before expiration + +## Component Responsibilities + +### Core Components (`/Core`) + +#### AuthStateManager.cs +- **Purpose**: Manages token lifecycle including storage, validation, and expiry checking +- **Key Methods**: + - `GetCurrentAccessToken()`: Returns valid access token or null if expired + - `IsLoggedIn()`: Determines authentication status + - `SaveTokens()`: Securely stores tokens with encryption + - `ClearTokens()`: Removes all stored authentication data + +#### AuthConfigManager.cs +- **Purpose**: Handles configuration management and secure storage operations +- **Key Methods**: + - `SaveClientId()`: Stores client ID in Editor Preferences + - `LoadClientId()`: Retrieves stored client ID + - `EncryptToken()`: Encrypts sensitive data before storage + - `DecryptToken()`: Decrypts stored sensitive data + +#### BentleyAuthCore.cs +- **Purpose**: Core authentication logic and workflow orchestration +- **Key Methods**: + - `StartAuthenticationFlow()`: Initiates the complete OAuth flow + - `ProcessAuthorizationCode()`: Handles callback with authorization code + - `RefreshAccessToken()`: Refreshes expired access tokens + +### OAuth Components (`/OAuth`) + +#### PKCEManager.cs +- **Purpose**: Implements PKCE (Proof Key for Code Exchange) security enhancement +- **Key Methods**: + - `GenerateCodeVerifier()`: Creates cryptographically secure code verifier + - `GenerateCodeChallenge()`: Creates SHA256 challenge from verifier + - `ValidateCodeChallenge()`: Validates PKCE parameters + +#### HttpListenerManager.cs +- **Purpose**: Manages local HTTP server for OAuth callback handling +- **Key Methods**: + - `StartListener()`: Starts local server on available port + - `WaitForCallback()`: Waits for OAuth callback with authorization code + - `StopListener()`: Cleanly stops the HTTP server + +#### TokenExchangeClient.cs +- **Purpose**: Handles HTTP requests for token exchange and refresh operations +- **Key Methods**: + - `ExchangeCodeForTokens()`: Exchanges authorization code for tokens + - `RefreshTokens()`: Refreshes access token using refresh token + - `ValidateTokenResponse()`: Validates and parses token responses + +## Usage Patterns + +### Basic Authentication Check +```csharp +if (authManager.IsLoggedIn()) +{ + string token = authManager.GetCurrentAccessToken(); + // Use token for API requests +} +else +{ + // Initiate login flow + yield return authManager.GetAccessTokenCoroutine(OnTokenReceived); +} +``` + +### Automatic Token Refresh +The system automatically handles token refresh: +- Checks token expiration before each API call +- Refreshes tokens proactively with 5-minute buffer +- Falls back to full re-authentication if refresh fails + +## Error Handling + +### Network Errors +- HTTP connection failures are logged and user-friendly messages displayed +- Retry logic for transient network issues +- Graceful degradation when authentication services are unavailable + +### Token Errors +- Invalid or expired tokens trigger automatic refresh attempts +- Malformed tokens are cleared and require re-authentication +- Token storage errors fall back to session-only authentication + +### User Cancellation +- Browser-based authentication can be cancelled by the user +- Local HTTP server is properly cleaned up on cancellation +- UI state is restored to pre-authentication state + +## Security Considerations + +### Data Protection +- All sensitive data (tokens, secrets) is encrypted before storage +- Memory containing sensitive data is cleared after use +- No sensitive data is logged or exposed in error messages + +### Network Security +- All communications use HTTPS for production endpoints +- Local callback server uses HTTP only for localhost (standard OAuth practice) +- PKCE prevents authorization code interception attacks + +### Session Management +- Tokens have appropriate expiration times +- Sessions can be invalidated/logged out cleanly +- No long-lived client secrets or sensitive configuration + +## Development Guidelines + +### Adding Authentication Features +1. Determine if the feature belongs in Core or OAuth layer +2. Follow the existing dependency injection pattern +3. Implement comprehensive error handling +4. Ensure sensitive data is handled securely +5. Add appropriate unit tests for authentication logic + +### Testing Authentication +- Mock HTTP responses for unit testing +- Test error scenarios (network failures, invalid tokens, etc.) +- Verify secure storage and cleanup operations +- Test the complete OAuth flow in integration tests + +### Security Review +When modifying authentication code: +1. Review all data storage for encryption requirements +2. Ensure no sensitive data appears in logs +3. Verify proper cleanup of sensitive data from memory +4. Test error handling doesn't expose sensitive information +5. Validate OAuth flow compliance with security best practices diff --git a/Editor/iTwinForUnity/Authentication/README.md.meta b/Editor/iTwinForUnity/Authentication/README.md.meta new file mode 100644 index 00000000..ff1651a3 --- /dev/null +++ b/Editor/iTwinForUnity/Authentication/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9d5e9b10256a7eb409e6c7a40441ff18 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/BentleyTilesetMetadataEditor.cs b/Editor/iTwinForUnity/BentleyTilesetMetadataEditor.cs new file mode 100644 index 00000000..db41de7e --- /dev/null +++ b/Editor/iTwinForUnity/BentleyTilesetMetadataEditor.cs @@ -0,0 +1,156 @@ +using UnityEditor; +using UnityEngine; + +[CustomEditor(typeof(BentleyTilesetMetadata))] +public class BentleyTilesetMetadataEditor : Editor +{ + private GUIStyle headerStyle; + private GUIStyle subheaderStyle; + private GUIStyle thumbnailBoxStyle; + private GUIStyle infoBoxStyle; + private GUIStyle copyButtonStyle; + private Texture2D headerBackground; + private Texture2D sectionBackground; + + private bool showDetails = false; + + private void InitStyles() + { + if (headerStyle != null) return; + + // Create header style with white text + headerStyle = new GUIStyle(EditorStyles.boldLabel); + headerStyle.fontSize = 14; + headerStyle.normal.textColor = Color.white; + headerStyle.margin = new RectOffset(0, 0, 10, 4); + + // Create subheader style with white text + subheaderStyle = new GUIStyle(EditorStyles.boldLabel); + subheaderStyle.fontSize = 12; + subheaderStyle.normal.textColor = Color.white; + subheaderStyle.margin = new RectOffset(4, 0, 8, 2); + + // Create thumbnail box style + thumbnailBoxStyle = new GUIStyle(EditorStyles.helpBox); + thumbnailBoxStyle.padding = new RectOffset(10, 10, 10, 10); + thumbnailBoxStyle.margin = new RectOffset(0, 0, 5, 10); + + // Create info box style + infoBoxStyle = new GUIStyle(EditorStyles.helpBox); + infoBoxStyle.padding = new RectOffset(12, 12, 12, 12); + infoBoxStyle.margin = new RectOffset(0, 0, 8, 8); + + // Create copy button style + copyButtonStyle = new GUIStyle(GUI.skin.button); + copyButtonStyle.fontSize = 10; + copyButtonStyle.padding = new RectOffset(4, 4, 2, 2); + copyButtonStyle.normal.textColor = Color.white; + + // Create background textures + headerBackground = new Texture2D(1, 1); + headerBackground.SetPixel(0, 0, new Color(0.92f, 0.92f, 0.92f)); + headerBackground.Apply(); + + sectionBackground = new Texture2D(1, 1); + sectionBackground.SetPixel(0, 0, new Color(0.97f, 0.97f, 0.97f)); + sectionBackground.Apply(); + } + + public override void OnInspectorGUI() + { + InitStyles(); + + BentleyTilesetMetadata metadata = (BentleyTilesetMetadata)target; + + // MOVED: iModel Thumbnail section to be first - at the top + Texture2D iModelThumb = metadata.GetIModelThumbnail(); + + if (iModelThumb != null) + { + EditorGUILayout.BeginVertical(thumbnailBoxStyle); + EditorGUILayout.LabelField("iModel Thumbnail", headerStyle); + + // Create full-width container for thumbnail + Rect thumbRect = GUILayoutUtility.GetRect(GUIContent.none, GUIStyle.none, GUILayout.Height(200)); + GUI.Box(thumbRect, GUIContent.none); + + // Draw the texture filling the space (StretchToFill) + GUI.DrawTexture(thumbRect, iModelThumb, ScaleMode.StretchToFill); + + EditorGUILayout.EndVertical(); + } + + // Primary Info Section with white text + EditorGUILayout.BeginVertical(thumbnailBoxStyle); + + // Display model name and description + EditorGUILayout.LabelField("Primary Information", headerStyle); + EditorGUILayout.Space(2); + + // Create a white text label style + GUIStyle whiteTextStyle = new GUIStyle(EditorStyles.textField); + whiteTextStyle.normal.textColor = Color.white; + + // Property fields with copy buttons + DrawPropertyWithCopyButton("iModel Name", metadata.iModelName, whiteTextStyle); + DrawPropertyWithCopyButton("Description", metadata.iModelDescription, whiteTextStyle); + DrawPropertyWithCopyButton("Export Date", metadata.exportDate, whiteTextStyle); + + EditorGUILayout.EndVertical(); + + // Details Foldout with white text + GUIStyle foldoutStyle = new GUIStyle(EditorStyles.foldoutHeader); + foldoutStyle.normal.textColor = Color.white; + foldoutStyle.onNormal.textColor = Color.white; + showDetails = EditorGUILayout.Foldout(showDetails, "Additional Details", true, foldoutStyle); + + if (showDetails) + { + EditorGUILayout.BeginVertical(infoBoxStyle); + + DrawPropertyWithCopyButton("iTwin ID", metadata.iTwinId, whiteTextStyle); + DrawPropertyWithCopyButton("iModel ID", metadata.iModelId, whiteTextStyle); + + string changesetDisplay = !string.IsNullOrEmpty(metadata.changesetId) ? metadata.changesetId : "Latest"; + DrawPropertyWithCopyButton("Changeset", changesetDisplay, whiteTextStyle); + + EditorGUILayout.EndVertical(); + } + + EditorGUILayout.Space(10); + + // Button section removed - moved to BentleyTilesetsWindow + } + + private void DrawPropertyWithCopyButton(string label, string value, GUIStyle textStyle) + { + EditorGUILayout.BeginHorizontal(); + + // Label + EditorGUILayout.LabelField(label, textStyle, GUILayout.Width(120)); + + // Text field (read-only) + GUI.enabled = false; + EditorGUILayout.TextField(value, textStyle); + GUI.enabled = true; + + // Copy button with icon instead of text + GUIContent copyIcon = EditorGUIUtility.IconContent("Clipboard"); + copyIcon.tooltip = "Copy value to clipboard"; + + if (GUILayout.Button(copyIcon, copyButtonStyle, GUILayout.Width(30), GUILayout.Height(18))) + { + GUIUtility.systemCopyBuffer = value; + EditorUtility.DisplayDialog("Copied", $"Copied to clipboard", "OK"); + } + + EditorGUILayout.EndHorizontal(); + } + + private void OnDisable() + { + // Clean up textures + if (headerBackground != null) DestroyImmediate(headerBackground); + if (sectionBackground != null) DestroyImmediate(sectionBackground); + } +} \ No newline at end of file diff --git a/Editor/iTwinForUnity/BentleyTilesetMetadataEditor.cs.meta b/Editor/iTwinForUnity/BentleyTilesetMetadataEditor.cs.meta new file mode 100644 index 00000000..dde88b8f --- /dev/null +++ b/Editor/iTwinForUnity/BentleyTilesetMetadataEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7ad71974556429f42bd79a51b5502dcd \ No newline at end of file diff --git a/Editor/iTwinForUnity/Core.meta b/Editor/iTwinForUnity/Core.meta new file mode 100644 index 00000000..021a7fd3 --- /dev/null +++ b/Editor/iTwinForUnity/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ffde5582ee4e9b24486ba6d34bd71541 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Core/README.md b/Editor/iTwinForUnity/Core/README.md new file mode 100644 index 00000000..7268bef0 --- /dev/null +++ b/Editor/iTwinForUnity/Core/README.md @@ -0,0 +1,135 @@ +# Core Components + +This directory contains the core business logic components that form the foundation of the iTwin Unity plugin. These components handle the essential functionality independent of UI concerns. + +## Overview + +The Core layer represents the business logic tier of the application architecture. It contains: +- Core services for API communication +- Business logic components +- Workflow orchestration +- Data management and processing + +## Architecture Principles + +### Separation of Concerns +Core components are isolated from UI logic, making them: +- Easier to test independently +- Reusable across different UI implementations +- More maintainable and less prone to breaking changes + +### Dependency Injection +Core components receive their dependencies through constructors, enabling: +- Better testability through dependency mocking +- Loose coupling between components +- Easier maintenance and refactoring + +### Single Responsibility +Each core component has a clearly defined, single responsibility: +- Services handle external API communication +- Managers handle data state and lifecycle +- Processors handle data transformation and validation + +## Directory Structure + +``` +Core/ +├── README.md # This file - Core architecture overview +└── Services/ # External API communication services + ├── README.md # Services documentation + └── BentleyAPIClient.cs # Primary API communication service +``` + +## Component Types + +### Services +Services handle communication with external systems and APIs. They: +- Abstract API complexity from other components +- Handle authentication and authorization +- Manage error handling and retry logic +- Provide consistent interfaces for data access + +### Future Extensions +As the plugin grows, additional component types may be added: +- **Managers**: Handle data lifecycle and state management +- **Processors**: Transform and validate data +- **Validators**: Ensure data integrity and business rules +- **Factories**: Create complex objects with proper initialization + +## Integration with Other Layers + +### With Presentation Layer (EditorWindows) +- Core services are injected into editor windows +- Editor windows call service methods and handle UI updates based on results +- Core components notify UI through callbacks or state updates + +### With Authentication Layer +- Core services use authentication managers for API authorization +- Services handle token refresh and authentication errors gracefully + +### With Common Layer +- Core components use shared data models and constants +- Utility functions are leveraged for common operations + +## Development Guidelines + +### Adding New Core Components +1. Identify the single responsibility of the new component +2. Define clear interfaces and dependencies +3. Implement comprehensive error handling +4. Add XML documentation for all public APIs +5. Follow the established naming conventions +6. Update this README with component documentation + +### Testing Considerations +- Core components should be easily unit testable +- Dependencies should be injectable for mocking +- Business logic should be isolated from Unity-specific code where possible + +### Performance +- Core components should be efficient and not block the UI +- Use asynchronous operations for long-running tasks +- Consider memory usage and object lifecycle management + +## Common Patterns + +### Coroutine-Based Async Operations +Most external operations use Unity coroutines: +```csharp +public IEnumerator ProcessDataCoroutine() +{ + // Async operation implementation + yield return operation; + // Handle results +} +``` + +### Dependency Injection Pattern +Core components receive dependencies through constructors: +```csharp +public class ServiceClass +{ + private IDependency dependency; + + public ServiceClass(IDependency dependency) + { + this.dependency = dependency; + } +} +``` + +### Error Handling Pattern +Consistent error handling with user-friendly messages: +```csharp +if (result.IsSuccess) +{ + // Handle success +} +else +{ + // Log technical details + Debug.LogError($"Technical error: {result.Error}"); + // Show user-friendly message + statusMessage = "A user-friendly error message"; +} +``` diff --git a/Editor/iTwinForUnity/Core/README.md.meta b/Editor/iTwinForUnity/Core/README.md.meta new file mode 100644 index 00000000..eecc6ff5 --- /dev/null +++ b/Editor/iTwinForUnity/Core/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 48b56285e7eee5048bb6bf2986d4ac6f +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Core/Services.meta b/Editor/iTwinForUnity/Core/Services.meta new file mode 100644 index 00000000..aac6d0ff --- /dev/null +++ b/Editor/iTwinForUnity/Core/Services.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 95169d03d4207154eb3b12dcc11c4bd8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Core/Services/BentleyAPIClient.cs b/Editor/iTwinForUnity/Core/Services/BentleyAPIClient.cs new file mode 100644 index 00000000..4f27a436 --- /dev/null +++ b/Editor/iTwinForUnity/Core/Services/BentleyAPIClient.cs @@ -0,0 +1,414 @@ +using UnityEngine; +using UnityEngine.Networking; +using Unity.EditorCoroutines.Editor; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System; +using Newtonsoft.Json; + +/// +/// Handles all communication with Bentley's iTwin platform APIs. +/// This service class manages HTTP requests for iTwins, iModels, changesets, and thumbnails. +/// All methods return Unity coroutines for asynchronous execution in the Editor. +/// +public class BentleyAPIClient +{ + /// + /// Reference to the parent editor window for state updates and UI refreshing + /// + private BentleyWorkflowEditorWindow parentWindow; + + /// + /// Initializes the API client with a reference to the parent editor window. + /// + /// The editor window that will receive API responses and state updates + public BentleyAPIClient(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Retrieves all iTwins accessible to the authenticated user. + /// Filters out inactive iTwins and resets pagination state. + /// Updates the parent window's state to SelectITwin on success or Error on failure. + /// + /// Unity coroutine for asynchronous execution + public IEnumerator GetMyITwinsCoroutine() + { + // Reset pagination when loading iTwins + parentWindow.iTwinsCurrentPage = 0; + + string token = parentWindow.authManager.GetCurrentAccessToken(); + var req = UnityWebRequest.Get("https://api.bentley.com/itwins?includeInactive=false"); + req.SetRequestHeader("Authorization", $"Bearer {token}"); + req.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v1+json"); + yield return req.SendWebRequest(); + if (req.result == UnityWebRequest.Result.Success) + { + var response = JsonConvert.DeserializeObject(req.downloadHandler.text); + parentWindow.myITwins = response.iTwins; + + // Don't prefetch all thumbnails - we'll load them on demand + // as each page is displayed + + parentWindow.currentState = WorkflowState.SelectITwin; + } + else + { + parentWindow.statusMessage = $"Failed to fetch iTwins: {req.error}"; + parentWindow.currentState = WorkflowState.Error; + } + parentWindow.currentCoroutineHandle = null; + parentWindow.Repaint(); + } + + /// + /// Fetches the thumbnail image for a specific iTwin asynchronously. + /// Sets the loading state on the iTwin object and updates it with the downloaded texture. + /// + /// The iTwin object to fetch the thumbnail for + /// Unity coroutine for asynchronous execution + public IEnumerator FetchITwinThumbnail(ITwin twin) + { + twin.loadingThumbnail = true; + var url = $"https://api.bentley.com/itwins/{twin.id}/image"; + var req = UnityWebRequestTexture.GetTexture(url); + req.SetRequestHeader("Authorization", $"Bearer {parentWindow.authManager.GetCurrentAccessToken()}"); + yield return req.SendWebRequest(); + if (req.result == UnityWebRequest.Result.Success) + twin.thumbnail = DownloadHandlerTexture.GetContent(req); + twin.loadingThumbnail = false; + + // Mark thumbnail as loaded + twin.thumbnailLoaded = true; + } + + /// + /// Retrieves all iModels within a specific iTwin. + /// Uses the v2 API format and resets pagination state for the new dataset. + /// Updates the parent window's state to SelectIModel on success or Error on failure. + /// + /// The unique identifier of the iTwin to query + /// Unity coroutine for asynchronous execution + public IEnumerator GetITwinIModelsCoroutine(string itwinId) + { + // Reset pagination when loading new iModels + parentWindow.iModelsCurrentPage = 0; + + string token = parentWindow.authManager.GetCurrentAccessToken(); + + // Update to use v2 API format exactly like the web version + var req = UnityWebRequest.Get($"https://api.bentley.com/imodels?iTwinId={itwinId}"); + req.SetRequestHeader("Authorization", $"Bearer {token}"); + req.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v2+json"); // Match web version + req.SetRequestHeader("Prefer", "return=representation"); // Add this header to match web + + yield return req.SendWebRequest(); + + if (req.result == UnityWebRequest.Result.Success) + { + try + { + var response = JsonConvert.DeserializeObject(req.downloadHandler.text); + parentWindow.myIModels = response.iModels; + + + foreach (var model in parentWindow.myIModels) + { + // If any model has a missing description, we'll flag it + if (string.IsNullOrEmpty(model.description)) + { + // Initialize with a default message + model.description = $"iModel created for {model.displayName}"; + } + } + + parentWindow.currentState = WorkflowState.SelectIModel; + } + catch (Exception ex) + { + Debug.LogError($"Error parsing iModels response: {ex.Message}"); + parentWindow.statusMessage = "Failed to parse iModels data"; + parentWindow.currentState = WorkflowState.Error; + } + } + else if (req.result == UnityWebRequest.Result.ProtocolError && req.responseCode == 429) + { + // Specific handling for rate limit error + Debug.LogWarning("Rate limit hit when fetching iModels. Please try again in a few minutes."); + parentWindow.statusMessage = "Rate limit exceeded. Please try again later."; + parentWindow.currentState = WorkflowState.LoggedIn; // Return to logged in state instead of error + parentWindow.currentCoroutineHandle = null; + } + else + { + parentWindow.statusMessage = $"Failed to fetch iModels: {req.error}"; + parentWindow.currentState = WorkflowState.Error; + } + parentWindow.currentCoroutineHandle = null; + parentWindow.Repaint(); + } + + /// + /// Fetches the thumbnail image for a specific iModel asynchronously. + /// Includes optimization to avoid duplicate requests and rate limiting handling. + /// Implements throttling to prevent excessive UI repaints during bulk thumbnail loading. + /// + /// The iModel object to fetch the thumbnail for + /// Unity coroutine for asynchronous execution + public IEnumerator FetchIModelThumbnail(IModel model) + { + if (model.loadingThumbnail || model.thumbnail != null) + yield break; + + model.loadingThumbnail = true; + yield return new WaitForSeconds(0.2f); + + var url = $"https://api.bentley.com/imodels/{model.id}/thumbnail"; + var req = UnityWebRequestTexture.GetTexture(url); + req.SetRequestHeader("Authorization", $"Bearer {parentWindow.authManager.GetCurrentAccessToken()}"); + yield return req.SendWebRequest(); + + if (req.result == UnityWebRequest.Result.Success) + { + model.thumbnail = DownloadHandlerTexture.GetContent(req); + } + else if (req.result == UnityWebRequest.Result.ProtocolError && req.responseCode == 429) + { + yield return new WaitForSeconds(2.0f); + } + + model.loadingThumbnail = false; + model.thumbnailLoaded = true; // Mark as loaded regardless of success/failure + + // Throttle repaints for performance + if (UnityEditor.EditorApplication.timeSinceStartup - parentWindow.lastRepaintTime > 0.05f) + { + parentWindow.lastRepaintTime = (float)UnityEditor.EditorApplication.timeSinceStartup; + parentWindow.Repaint(); + } + } + + /// + /// Fetches detailed information for a specific iModel, including description and metadata. + /// Includes optimization to avoid duplicate requests and handles API rate limiting. + /// Updates the iModel object with the retrieved description data. + /// + /// The iModel object to fetch details for + /// Unity coroutine for asynchronous execution + public IEnumerator FetchIModelDetailsCoroutine(IModel model) + { + if (model.loadingDetails || !string.IsNullOrEmpty(model.description)) + yield break; + + model.loadingDetails = true; + yield return new WaitForSeconds(0.2f); + + var url = $"https://api.bentley.com/imodels/{model.id}"; + var req = UnityWebRequest.Get(url); + req.SetRequestHeader("Authorization", $"Bearer {parentWindow.authManager.GetCurrentAccessToken()}"); + req.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v1+json"); + + yield return req.SendWebRequest(); + + if (req.result == UnityWebRequest.Result.Success) + { + try + { + var response = JsonConvert.DeserializeObject(req.downloadHandler.text); + if (response?.iModel != null && !string.IsNullOrEmpty(response.iModel.description)) + { + model.description = response.iModel.description; + } + } + catch (Exception ex) + { + Debug.LogWarning($"Error parsing iModel details: {ex.Message}"); + } + } + else if (req.result == UnityWebRequest.Result.ProtocolError && req.responseCode == 429) + { + yield return new WaitForSeconds(2.0f); + } + else + { + Debug.LogWarning($"Failed to fetch iModel details: {req.error}"); + } + + model.loadingDetails = false; + model.detailsLoaded = true; // Mark as loaded regardless + + // Throttle repaints for performance + if (UnityEditor.EditorApplication.timeSinceStartup - parentWindow.lastRepaintTime > 0.05f) + { + parentWindow.lastRepaintTime = (float)UnityEditor.EditorApplication.timeSinceStartup; + parentWindow.Repaint(); + } + } + + /// + /// Fetches changesets for a specific iModel, retrieving up to 20 most recent changesets. + /// Avoids duplicate requests and sorts changesets by creation date (newest first). + /// Updates the iModel object with the retrieved changesets data. + /// + /// The iModel object to fetch changesets for + /// Unity coroutine for asynchronous execution + public IEnumerator FetchIModelChangesets(IModel model) + { + if (model.loadingChangesets || (model.changesets != null && model.changesets.Count > 0)) + yield break; + + model.loadingChangesets = true; + + var url = $"https://api.bentley.com/imodels/{model.id}/changesets?$top=20"; + var req = UnityWebRequest.Get(url); + req.SetRequestHeader("Authorization", $"Bearer {parentWindow.authManager.GetCurrentAccessToken()}"); + req.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v2+json"); + req.SetRequestHeader("Prefer", "return=representation"); + + yield return req.SendWebRequest(); + + if (req.result == UnityWebRequest.Result.Success) + { + try + { + var response = JsonConvert.DeserializeObject(req.downloadHandler.text); + model.changesets = response.changesets ?? new List(); + + // Sort by creation date, newest first (to match web UI behavior) + model.changesets = model.changesets.OrderByDescending(cs => cs.createdDate).ToList(); + } + catch (Exception ex) + { + Debug.LogWarning($"Error parsing changesets: {ex.Message}"); + model.changesets = new List(); + } + } + else if (req.result == UnityWebRequest.Result.ProtocolError && req.responseCode == 429) + { + yield return new WaitForSeconds(2.0f); + } + else + { + Debug.LogWarning($"Failed to fetch changesets: {req.error}"); + model.changesets = new List(); + } + + model.loadingChangesets = false; + + // Throttle repaints for performance + if (UnityEditor.EditorApplication.timeSinceStartup - parentWindow.lastRepaintTime > 0.05f) + { + parentWindow.lastRepaintTime = (float)UnityEditor.EditorApplication.timeSinceStartup; + parentWindow.Repaint(); + } + } + + /// + /// Legacy method for retrieving changesets by iModel ID. + /// Retrieves up to 20 changesets and updates the parent window's state. + /// Consider using FetchIModelChangesets for new implementations. + /// + /// The unique identifier of the iModel + /// Unity coroutine for asynchronous execution + public IEnumerator GetChangeSetsCoroutine(string imodelId) + { + string token = parentWindow.authManager.GetCurrentAccessToken(); + + var req = UnityWebRequest.Get($"https://api.bentley.com/imodels/{imodelId}/changesets?$top=20"); + req.SetRequestHeader("Authorization", $"Bearer {token}"); + req.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v2+json"); + req.SetRequestHeader("Prefer", "return=representation"); + + yield return req.SendWebRequest(); + if (req.result == UnityWebRequest.Result.Success) + { + var response = JsonConvert.DeserializeObject(req.downloadHandler.text); + parentWindow.myChangeSets = response.changesets; + parentWindow.currentState = WorkflowState.StartingExport; + } + else + { + parentWindow.statusMessage = $"Failed to fetch changesets: {req.error}"; + parentWindow.currentState = WorkflowState.Error; + } + parentWindow.currentCoroutineHandle = null; + parentWindow.Repaint(); + } + + /// + /// Orchestrates the complete mesh export workflow from start to finish. + /// Handles export initiation, progress monitoring, and final tileset URL retrieval. + /// Updates the parent window's state throughout the process to reflect current status. + /// + /// Unity coroutine for asynchronous execution + public IEnumerator RunFullExportWorkflowCoroutine() + { + parentWindow.finalTilesetUrl = ""; + string currentAccessToken = parentWindow.authManager.GetCurrentAccessToken(); + string exportIModelId = !string.IsNullOrEmpty(parentWindow.selectedIModelId) ? parentWindow.selectedIModelId : parentWindow.iModelId; + + parentWindow.currentState = WorkflowState.StartingExport; + parentWindow.statusMessage = $"Checking for existing export or starting new export for iModel '{exportIModelId}'..."; + parentWindow.Repaint(); + + MeshExportClient.ExportWrapper exportResult = null; + string exportError = null; + + // Use GetOrStartExportCoroutine to check for existing exports first + var exportOp = parentWindow.meshClient.GetOrStartExportCoroutine( + currentAccessToken, + exportIModelId, + parentWindow.changesetId, + parentWindow.exportType, + (result, error) => { + exportResult = result; + exportError = error; + } + ); + yield return EditorCoroutineUtility.StartCoroutine(exportOp, parentWindow); + + if (!string.IsNullOrEmpty(exportError)) + { + parentWindow.statusMessage = $"Export failed: {exportError}"; + parentWindow.currentState = WorkflowState.Error; + parentWindow.currentCoroutineHandle = null; + parentWindow.Repaint(); + yield break; + } + + if (exportResult == null || string.IsNullOrEmpty(exportResult.Href)) + { + parentWindow.statusMessage = "Export job started but no URL returned. Check the Bentley iTwin portal for job status."; + parentWindow.currentState = WorkflowState.Error; + parentWindow.currentCoroutineHandle = null; + parentWindow.Repaint(); + yield break; + } + + string downloadHref = exportResult.Href; + parentWindow.statusMessage = $"Export started! Download Href: {downloadHref}"; + + try + { + int qIndex = downloadHref.IndexOf('?'); + if (qIndex >= 0) + parentWindow.finalTilesetUrl = downloadHref.Insert(qIndex, "/tileset.json"); + else + parentWindow.finalTilesetUrl = downloadHref.TrimEnd('/') + "/tileset.json"; + + parentWindow.statusMessage = "Export job initiated successfully! Tileset URL ready."; + parentWindow.currentState = WorkflowState.ExportComplete; + } + catch (Exception ex) + { + parentWindow.statusMessage = $"Error building final URL: {ex.Message}"; + parentWindow.currentState = WorkflowState.Error; + parentWindow.finalTilesetUrl = ""; + } + + parentWindow.currentCoroutineHandle = null; + parentWindow.Repaint(); + } +} diff --git a/Editor/iTwinForUnity/Core/Services/BentleyAPIClient.cs.meta b/Editor/iTwinForUnity/Core/Services/BentleyAPIClient.cs.meta new file mode 100644 index 00000000..1b947c7c --- /dev/null +++ b/Editor/iTwinForUnity/Core/Services/BentleyAPIClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a7317298a532984784a9806395f912e \ No newline at end of file diff --git a/Editor/iTwinForUnity/Core/Services/README.md b/Editor/iTwinForUnity/Core/Services/README.md new file mode 100644 index 00000000..baad7f80 --- /dev/null +++ b/Editor/iTwinForUnity/Core/Services/README.md @@ -0,0 +1,68 @@ +# Core Services + +This directory contains the core business logic services that handle communication with external APIs and orchestrate major workflows. + +## Overview + +Core services are responsible for: +- Communicating with Bentley's iTwin platform APIs +- Managing data flow between UI components and external services +- Orchestrating complex workflows like authentication and mesh export +- Handling error states and retry logic +- Abstracting API complexity from UI components + +## Architecture + +Services in this directory follow these principles: +- **Single Responsibility**: Each service handles one specific area of functionality +- **Dependency Injection**: Services receive their dependencies through constructors +- **Asynchronous Operations**: All external API calls use Unity coroutines +- **Error Handling**: Robust error handling with user-friendly error messages +- **State Management**: Services update parent components about operation status + +## Services + +### BentleyAPIClient.cs +The primary service for communicating with Bentley's iTwin platform APIs. + +**Responsibilities:** +- Fetching iTwins, iModels, and changesets from Bentley's APIs +- Managing thumbnail downloads with rate limiting +- Handling API authentication and authorization headers +- Orchestrating the complete mesh export workflow +- Managing pagination for large datasets + +**Key Features:** +- Rate limiting protection to avoid API throttling +- Optimized thumbnail loading with caching +- Comprehensive error handling and retry logic +- Performance optimizations for UI responsiveness + +**Usage:** +This service is typically instantiated by editor windows and used to fetch data from Bentley's platform. All methods return Unity coroutines that can be started using EditorCoroutineUtility. + +## Error Handling + +All services implement consistent error handling patterns: +- Network errors are logged and user-friendly messages are displayed +- Rate limiting (HTTP 429) responses trigger automatic retry with backoff +- Authentication errors propagate to the parent window for token refresh +- Parsing errors are caught and logged without crashing the application + +## Performance Considerations + +Services implement several performance optimizations: +- Thumbnail loading includes deduplication to avoid redundant requests +- UI repaint throttling prevents excessive refreshes during bulk operations +- Pagination support for large datasets +- Async operations prevent blocking the Unity Editor UI + +## Extension Points + +When adding new services: +1. Follow the existing dependency injection pattern +2. Use Unity coroutines for all asynchronous operations +3. Implement comprehensive error handling +4. Update parent components about operation status +5. Consider performance implications for UI responsiveness +6. Add XML documentation for all public methods diff --git a/Editor/iTwinForUnity/Core/Services/README.md.meta b/Editor/iTwinForUnity/Core/Services/README.md.meta new file mode 100644 index 00000000..87d7aca3 --- /dev/null +++ b/Editor/iTwinForUnity/Core/Services/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e7d33c632930b0b4293c312cea7b29f8 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Editor.asmdef b/Editor/iTwinForUnity/Editor.asmdef new file mode 100644 index 00000000..b339f229 --- /dev/null +++ b/Editor/iTwinForUnity/Editor.asmdef @@ -0,0 +1,21 @@ +{ + "name": "iTwinForUnity.Editor", + "rootNamespace": "", + "references": [ + "iTwinForUnity.Runtime", + "CesiumRuntime", + "CesiumEditor", + "Unity.EditorCoroutines.Editor" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Editor/iTwinForUnity/Editor.asmdef.meta b/Editor/iTwinForUnity/Editor.asmdef.meta new file mode 100644 index 00000000..d85948d1 --- /dev/null +++ b/Editor/iTwinForUnity/Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6260df5ef068d0448be5d504cbcea983 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/EditorWindows.meta b/Editor/iTwinForUnity/EditorWindows.meta new file mode 100644 index 00000000..2b500c05 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 761a8534de5f73b438fd6bead218fc57 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/EditorWindows/BentleyTilesetsWindow.cs b/Editor/iTwinForUnity/EditorWindows/BentleyTilesetsWindow.cs new file mode 100644 index 00000000..9bfaf5cc --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/BentleyTilesetsWindow.cs @@ -0,0 +1,211 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; +using System.Linq; +using System.Collections; +using Unity.EditorCoroutines.Editor; +using UnityEngine.Networking; +using Newtonsoft.Json; + + +public class BentleyTilesetsWindow : EditorWindow +{ + #region Fields and Properties + // Data - public for component access + public Vector2 scrollPosition; + public List savedViews = new List(); + public string searchText = ""; + public bool shouldClearSearchFocus = false; + + // Animation and UI states - public for component access + public float lastRepaintTime = 0f; + public int spinnerFrame = 0; + public readonly string[] spinnerFrames = new string[] { "◐", "◓", "◑", "◒" }; + public int hoveredCardIndex = -1; + + // Colors + private readonly Color headerBgColor = new Color(0.15f, 0.15f, 0.15f); + private readonly Color cardBgColor = new Color(0.22f, 0.22f, 0.22f); + private readonly Color cardBgHoverColor = new Color(0.26f, 0.26f, 0.26f); + private readonly Color accentColor = new Color(0.2f, 0.6f, 0.9f); + private readonly Color primaryTextColor = new Color(0.9f, 0.9f, 0.9f); + private readonly Color secondaryTextColor = new Color(0.7f, 0.7f, 0.7f); + + // Pagination - public for component access + public int currentPage = 0; + public const int ITEMS_PER_PAGE = 3; + + // Textures and visual elements + private Texture2D cardBackgroundTexture; + private Texture2D cardBackgroundHoverTexture; + private Texture2D noThumbnailTexture; + private GUIContent searchIcon; + private GUIContent clearIcon; + private GUIContent backIcon; + private GUIContent hierarchyIcon; + private GUIContent focusIcon; + private GUIContent webIcon; + + // Styles + private GUIStyle headerStyle; + private GUIStyle titleStyle; + private GUIStyle descriptionStyle; + private GUIStyle searchBoxStyle; + private GUIStyle cardStyle; + private GUIStyle buttonStyle; + private GUIStyle iconButtonStyle; + private GUIStyle emptyStateStyle; + private GUIStyle spinnerStyle; + private GUIStyle breadcrumbStyle; + private GUIStyle infoLabelStyle; + private GUIStyle backButtonStyle; + private GUIStyle footerStyle; + private GUIStyle dividerStyle; + private GUIStyle selectionCardStyle; + private GUIStyle itemTitleStyle; + private GUIStyle itemDescriptionStyle; + + // Component instances - following the established architecture pattern + private TilesetsWindowController windowController; + private TilesetDataManager dataManager; + private SearchComponent searchComponent; + private TilesetCardRenderer cardRenderer; + private PaginationComponent paginationComponent; + private EmptyStateComponent emptyStateComponent; + #endregion + + [MenuItem("Bentley/Tilesets")] + public static void ShowWindow() + { + var window = GetWindow("Tilesets"); + window.minSize = new Vector2(500, 400); + } + + #region Initialization & Cleanup + private void OnEnable() + { + // Initialize components following the established pattern + InitializeComponents(); + + // Refresh data through data manager + dataManager?.RefreshSavedViews(); + + EditorApplication.update += OnEditorUpdate; + } + + private void InitializeComponents() + { + // Initialize all components following the dependency injection pattern + windowController = new TilesetsWindowController(this); + dataManager = new TilesetDataManager(this); + searchComponent = new SearchComponent(this); + cardRenderer = new TilesetCardRenderer(this); + paginationComponent = new PaginationComponent(this); + emptyStateComponent = new EmptyStateComponent(this); + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + TilesetsUIStyles.CleanupTextures(); + } + + private void OnEditorUpdate() + { + // Delegate to window controller + windowController?.OnEditorUpdate(); + } + #endregion + + #region GUI Methods + private void OnGUI() + { + // Initialize styles if not already done + TilesetsUIStyles.InitializeStyles(); + + // Check if components are initialized + if (searchComponent == null) return; + + EditorGUILayout.BeginVertical(); + + DrawTilesetsSection(); + DrawFooter(); + + EditorGUILayout.EndVertical(); + + // Handle keyboard shortcuts through controller + windowController?.HandleKeyboardInput(); + } + + private void DrawFooter() + { + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(10); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + GUILayout.Space(6); + } + + private void DrawTilesetsSection() + { + GUILayout.Space(16); + + // Delegate search UI to search component + searchComponent.DrawSearchSection(); + + // Get filtered views from search component + var filteredViews = searchComponent.GetFilteredViews(); + + // Content area + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(16); + + if (savedViews.Count == 0 || filteredViews.Count == 0) + { + // Handle empty states + searchComponent.GetEmptyStateMessage(out string title, out string description); + emptyStateComponent.DrawEmptyState(title, description); + } + else + { + DrawMainContent(filteredViews); + } + + GUILayout.Space(16); + EditorGUILayout.EndHorizontal(); + } + + private void DrawMainContent(System.Collections.Generic.List filteredViews) + { + // Main content with scrollable list + EditorGUILayout.BeginVertical(); + + // Scrollable list + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + + // Get paged items from pagination component + paginationComponent.GetPagedItems(filteredViews, out int startIndex, out int endIndex); + + // Display only the current page using card renderer + for (int i = startIndex; i < endIndex; i++) + { + cardRenderer.DrawTilesetCard(filteredViews[i], i); + } + + EditorGUILayout.EndScrollView(); + + GUILayout.Space(10); + + // Pagination controls + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + paginationComponent.DrawPaginationControls(filteredViews); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + #endregion +} \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/BentleyTilesetsWindow.cs.meta b/Editor/iTwinForUnity/EditorWindows/BentleyTilesetsWindow.cs.meta new file mode 100644 index 00000000..1f236997 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/BentleyTilesetsWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97de016668609a04dab25dc309eba83b \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/BentleyWorkflowEditorWindow.cs b/Editor/iTwinForUnity/EditorWindows/BentleyWorkflowEditorWindow.cs new file mode 100644 index 00000000..4efffd78 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/BentleyWorkflowEditorWindow.cs @@ -0,0 +1,350 @@ +using UnityEngine; +using UnityEditor; +using System.Collections; +using CesiumForUnity; +using Unity.EditorCoroutines.Editor; +using System; +using System.Collections.Generic; +using System.Linq; + +public class BentleyWorkflowEditorWindow : EditorWindow +{ + // Managers + public BentleyAuthManager authManager; + public MeshExportClient meshClient; + + // Input Parameters + public string clientId = ""; + public string redirectUri = ""; + public string iModelId = "YOUR_IMODEL_ID"; + public string changesetId = ""; + public string exportType = "3DTILES"; + + // State - Using proper WorkflowState enum + public WorkflowState currentState = WorkflowState.Idle; + public string statusMessage = "Ready"; + public string finalTilesetUrl = ""; + public EditorCoroutine currentCoroutineHandle = null; + + // Target Cesium Tileset + public Cesium3DTileset targetCesiumTileset; + + // EditorPrefs Keys + private const string PrefIModelId = "Bentley_Editor_iModelId"; + private const string PrefChangesetId = "Bentley_Editor_ChangesetId"; + + // iTwin/iModel/Changeset Selection State - Using proper data types + public List myITwins = new List(); + public List myIModels = new List(); + public List myChangeSets = new List(); + public string selectedITwinId; + public string selectedIModelId; + + public static readonly GUIContent SpinnerContent = EditorGUIUtility.IconContent("WaitSpin00"); + + // UI State & Styling + public bool showAuthSection = true; + public bool showAdvancedAuthSettings = false; + private Vector2 scrollPosition; + + // Pagination state variables + public int iTwinsCurrentPage = 0; + public int iModelsCurrentPage = 0; + public const int ITEMS_PER_PAGE = 3; + public string iTwinsSearchText = ""; + public string iModelsSearchText = ""; + + // UI state flag + public bool shouldClearSearchFocus = false; + + // Advanced options state + public bool advancedCesiumOptionsVisible = false; + + // Animation timing + public float lastRepaintTime = 0f; + + // Component instances - use object type to avoid circular dependencies during compilation + private object stateManager; + private object apiClient; + private object helperMethods; + private object welcomeComponent; + private object stepIndicatorComponent; + private object authComponent; + private object projectBrowserComponent; + private object exportComponent; + private object cesiumComponent; + + [MenuItem("Bentley/Mesh Export")] + public static void ShowWindow() + { + var window = GetWindow("Bentley Mesh Export"); + window.minSize = new Vector2(500, 650); + } + + private void OnEnable() + { + authManager = new BentleyAuthManager(); + meshClient = new MeshExportClient(); + authManager.RequestExchangeCode = StartExchangeCodeCoroutine; + + clientId = authManager.clientId; + redirectUri = authManager.redirectUri; + iModelId = EditorPrefs.GetString(PrefIModelId, iModelId); + changesetId = EditorPrefs.GetString(PrefChangesetId, changesetId); + + // Initialize components - delay to avoid circular dependency + EditorApplication.delayCall += InitializeComponents; + + EditorApplication.update += AnimateSpinner; + + UpdateLoginState(); + } + + private void InitializeComponents() + { + stateManager = new WorkflowStateManager(this); + apiClient = new BentleyAPIClient(this); + helperMethods = new BentleyHelperMethods(this); + welcomeComponent = new WelcomeComponent(this); + stepIndicatorComponent = new WorkflowStepIndicatorComponent(this, (WorkflowStateManager)stateManager); + authComponent = new AuthenticationComponent(this); + projectBrowserComponent = new ProjectBrowserComponent(this); + exportComponent = new ExportComponent(this); + cesiumComponent = new CesiumIntegrationComponent(this); + } + + private void AnimateSpinner() + { + if (EditorApplication.timeSinceStartup - lastRepaintTime > 0.1f) + { + lastRepaintTime = (float)EditorApplication.timeSinceStartup; + Repaint(); + } + } + + private void OnDisable() + { + authManager?.SaveClientId(); + EditorPrefs.SetString(PrefIModelId, iModelId); + EditorPrefs.SetString(PrefChangesetId, changesetId); + StopCurrentCoroutine(); + // Only clean up HTTP listener, preserve tokens for next session + authManager?.CleanupWithoutLogout(); + EditorApplication.update -= AnimateSpinner; + } + + public void UpdateLoginState() + { + if (authManager.IsLoggedIn()) + { + if (authManager.GetCurrentAccessToken() != null) + { + currentState = WorkflowState.LoggedIn; + statusMessage = $"Logged In. Token expires: {authManager.GetExpiryTimeUtc().ToLocalTime()}"; + } + else + { + currentState = WorkflowState.Idle; + statusMessage = "Login expired. Refresh or Login needed."; + } + } + else + { + currentState = WorkflowState.Idle; + statusMessage = "Not logged in."; + } + Repaint(); + } + + private void OnGUI() + { + // Check if components are initialized + if (welcomeComponent == null) return; + + EditorGUILayout.BeginVertical(new GUIStyle { padding = new RectOffset(15, 15, 15, 15) }); + + if (authManager.IsLoggedIn()) + { + ((WorkflowStepIndicatorComponent)stepIndicatorComponent).DrawWorkflowStepIndicator(); + EditorGUILayout.Space(5); + } + + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + + if (!authManager.IsLoggedIn()) + { + ((WelcomeComponent)welcomeComponent).DrawWelcomeSection(); + ((AuthenticationComponent)authComponent).DrawAuthSection(); + } + else + { + ((AuthenticationComponent)authComponent).DrawAuthSection(); + + if (currentState >= WorkflowState.LoggedIn && currentState <= WorkflowState.SelectIModel) + { + ((ProjectBrowserComponent)projectBrowserComponent).DrawITwinIModelSelection(); + } + else + { + ((ExportComponent)exportComponent).DrawExportSection(); + } + + if (currentState == WorkflowState.ExportComplete) + { + ((CesiumIntegrationComponent)cesiumComponent).DrawCesiumSection(); + } + } + + EditorGUILayout.Space(20); + + EditorGUILayout.EndScrollView(); + EditorGUILayout.EndVertical(); + } + + // Auth callback methods + private void StartExchangeCodeCoroutine(string code) + { + statusMessage = "Authorization code received, exchanging for token..."; + Repaint(); + EditorCoroutineUtility.StartCoroutine( + authManager.ExchangeCodeCoroutine(code, OnExchangeCodeComplete), this); + } + + private void OnExchangeCodeComplete(bool success, string error) + { + if (!success) + { + statusMessage = $"Error during token exchange: {error}"; + currentState = WorkflowState.Error; + currentCoroutineHandle = null; + Repaint(); + } + } + + public void OnGetTokenComplete(string token, string error) + { + currentCoroutineHandle = null; + if (!string.IsNullOrEmpty(error)) + { + statusMessage = $"Failed to get token: {error}"; + currentState = WorkflowState.Error; + Debug.LogError("BentleyWorkflowEditorWindow: " + statusMessage); + } + else + { + if (!string.IsNullOrEmpty(token)) + { + EditorPrefs.SetString("Bentley_Auth_Token", token); + } + + statusMessage = $"Token ready. Expires: {authManager.GetExpiryTimeUtc().ToLocalTime()}"; + currentState = WorkflowState.LoggedIn; + } + Repaint(); + } + + // Delegate coroutines to API client + public IEnumerator GetMyITwinsCoroutine() + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).GetMyITwinsCoroutine(); + } + } + + public IEnumerator FetchITwinThumbnail(ITwin twin) + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).FetchITwinThumbnail(twin); + } + } + + public IEnumerator GetITwinIModelsCoroutine(string itwinId) + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).GetITwinIModelsCoroutine(itwinId); + } + } + + public IEnumerator FetchIModelThumbnail(IModel model) + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).FetchIModelThumbnail(model); + } + } + + public IEnumerator FetchIModelDetailsCoroutine(IModel model) + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).FetchIModelDetailsCoroutine(model); + } + } + + public IEnumerator FetchIModelChangesets(IModel model) + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).FetchIModelChangesets(model); + } + } + + public IEnumerator GetChangeSetsCoroutine(string imodelId) + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).GetChangeSetsCoroutine(imodelId); + } + } + + public IEnumerator RunFullExportWorkflowCoroutine() + { + if (apiClient != null) + { + yield return ((BentleyAPIClient)apiClient).RunFullExportWorkflowCoroutine(); + } + } + + // Delegate helper methods + public string GetDisplayDescription(IModel model) + { + if (helperMethods != null) + return ((BentleyHelperMethods)helperMethods).GetDisplayDescription(model); + return "Loading..."; + } + + public string GetAuthToken() + { + if (helperMethods != null) + return ((BentleyHelperMethods)helperMethods).GetAuthToken(); + return authManager?.GetCurrentAccessToken() ?? ""; + } + + public void UpdateMetadataWithThumbnail(BentleyTilesetMetadata metadata, ITwin selectedITwin, IModel selectedIModel) + { + if (helperMethods != null) + ((BentleyHelperMethods)helperMethods).UpdateMetadataWithThumbnail(metadata, selectedITwin, selectedIModel); + } + + public void StopCurrentCoroutine() + { + if (helperMethods != null) + ((BentleyHelperMethods)helperMethods).StopCurrentCoroutine(); + else if (currentCoroutineHandle != null) + { + EditorCoroutineUtility.StopCoroutine(currentCoroutineHandle); + currentCoroutineHandle = null; + } + } + + public IEnumerator DelayedPlaceOrigin(GameObject selectedObject) + { + if (helperMethods != null) + { + yield return ((BentleyHelperMethods)helperMethods).DelayedPlaceOrigin(selectedObject); + } + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/BentleyWorkflowEditorWindow.cs.meta b/Editor/iTwinForUnity/EditorWindows/BentleyWorkflowEditorWindow.cs.meta new file mode 100644 index 00000000..3e8465a5 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/BentleyWorkflowEditorWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 589993f738fa6ef4ab050addcee66ad7 \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components.meta b/Editor/iTwinForUnity/EditorWindows/Components.meta new file mode 100644 index 00000000..23096fbf --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 839487f7f7036fc48933bc7565bdf8bd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/EditorWindows/Components/AuthenticationComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/AuthenticationComponent.cs new file mode 100644 index 00000000..b66d3fbd --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/AuthenticationComponent.cs @@ -0,0 +1,239 @@ +using UnityEngine; +using UnityEditor; +using Unity.EditorCoroutines.Editor; +using System; + +/// +/// Handles all authentication-related UI elements and user interactions within the workflow editor. +/// This component provides a complete authentication interface including client ID configuration, +/// login/logout controls, token management, and visual feedback for authentication states. +/// +public class AuthenticationComponent +{ + /// + /// Reference to the parent workflow editor window for state updates and coordination + /// + private BentleyWorkflowEditorWindow parentWindow; + + /// + /// Initializes the authentication component with a reference to the parent window. + /// + /// The workflow editor window that contains this component + public AuthenticationComponent(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Renders the complete authentication section UI including client ID configuration, + /// login/logout controls, token status display, and authentication progress feedback. + /// The UI adapts its layout based on the current authentication state. + /// + public void DrawAuthSection() + { + // Section header with icon + EditorGUILayout.BeginHorizontal(); + + if (!parentWindow.authManager.IsLoggedIn()) + { + // More prominent header when logged out + EditorGUILayout.LabelField("Authentication", BentleyUIStyles.sectionHeaderStyle); + } + else + { + // Collapsible section when logged in + parentWindow.showAuthSection = EditorGUILayout.Foldout(parentWindow.showAuthSection, "Authentication", true, BentleyUIStyles.foldoutStyle); + + // Show login status indicator + GUIStyle statusStyle = new GUIStyle(EditorStyles.miniLabel); + statusStyle.normal.textColor = Color.green; + EditorGUILayout.LabelField("● Logged in", statusStyle, GUILayout.Width(70)); + } + + EditorGUILayout.EndHorizontal(); + + // Only check if section should be shown when logged in + if (!parentWindow.authManager.IsLoggedIn() || parentWindow.showAuthSection) + { + // Rest of authentication section remains the same... + EditorGUILayout.BeginVertical(BentleyUIStyles.cardStyle); + + // Client ID field with better spacing and alignment + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginChangeCheck(); + + EditorGUILayout.LabelField("Client ID", GUILayout.Width(80)); + parentWindow.clientId = EditorGUILayout.TextField(parentWindow.clientId, GUILayout.Height(20)); + + if (EditorGUI.EndChangeCheck()) + { + parentWindow.authManager.clientId = parentWindow.clientId; + } + + if (GUILayout.Button(BentleyIconHelper.GetIconOnlyContent("d_SaveAs", "Save Client ID to Editor Preferences"), + GUILayout.Width(28), GUILayout.Height(20))) + { + parentWindow.authManager.SaveClientId(); + EditorUtility.DisplayDialog("Client ID Saved", "Client ID saved to Editor Preferences.", "OK"); + } + + EditorGUILayout.EndHorizontal(); + + // Advanced Settings Section + EditorGUILayout.Space(5); + parentWindow.showAdvancedAuthSettings = EditorGUILayout.Foldout( + parentWindow.showAdvancedAuthSettings, + "Advanced Settings", + true, + BentleyUIStyles.foldoutStyle + ); + + if (parentWindow.showAdvancedAuthSettings) + { + EditorGUILayout.BeginVertical(BentleyUIStyles.helpBoxStyle); + + // Redirect URI field + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginChangeCheck(); + + EditorGUILayout.LabelField("Redirect URI", GUILayout.Width(80)); + parentWindow.redirectUri = EditorGUILayout.TextField( + parentWindow.redirectUri, + GUILayout.Height(20) + ); + + if (EditorGUI.EndChangeCheck()) + { + parentWindow.authManager.redirectUri = parentWindow.redirectUri; + } + + if (GUILayout.Button(BentleyIconHelper.GetIconOnlyContent("d_SaveAs", "Save Redirect URI to Editor Preferences"), + GUILayout.Width(28), GUILayout.Height(20))) + { + parentWindow.authManager.SaveRedirectUri(); + EditorUtility.DisplayDialog("Redirect URI Saved", "Redirect URI saved to Editor Preferences.", "OK"); + } + + EditorGUILayout.EndHorizontal(); + + // Reset to default button + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Reset to Default", GUILayout.Width(120), GUILayout.Height(20))) + { + parentWindow.redirectUri = AuthConstants.DEFAULT_REDIRECT_URI; + parentWindow.authManager.redirectUri = parentWindow.redirectUri; + parentWindow.authManager.SaveRedirectUri(); + parentWindow.Repaint(); + } + EditorGUILayout.EndHorizontal(); + + // Help text + EditorGUILayout.Space(5); + EditorGUILayout.LabelField( + "⚠️ Important: Make sure this URI matches exactly with the Redirect URI configured in your iTwin app registration.", + BentleyUIStyles.richTextLabelStyle + ); + + EditorGUILayout.EndVertical(); + } + + // Make login button more prominent when logged out + EditorGUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginDisabledGroup(parentWindow.currentState == WorkflowState.LoggingIn); + + string loginButtonText = parentWindow.authManager.IsLoggedIn() ? "Refresh Token" : "Login to Bentley"; + GUIContent loginButtonContent = new GUIContent(loginButtonText, "Login with Bentley account"); + + if (!parentWindow.authManager.IsLoggedIn()) + { + // Larger, centered login button when logged out + GUILayout.FlexibleSpace(); + if (GUILayout.Button(loginButtonContent, GUILayout.Height(36), GUILayout.Width(200))) + { + parentWindow.StopCurrentCoroutine(); + parentWindow.currentState = WorkflowState.LoggingIn; + parentWindow.statusMessage = "Initiating login..."; + parentWindow.Repaint(); + parentWindow.currentCoroutineHandle = EditorCoroutineUtility.StartCoroutine( + parentWindow.authManager.GetAccessTokenCoroutine(parentWindow.OnGetTokenComplete), parentWindow); + } + GUILayout.FlexibleSpace(); + } + else + { + // Normal sized button when logged in + if (GUILayout.Button(loginButtonContent, GUILayout.Height(30))) + { + parentWindow.StopCurrentCoroutine(); + parentWindow.currentState = WorkflowState.LoggingIn; + parentWindow.statusMessage = "Initiating login..."; + parentWindow.Repaint(); + parentWindow.currentCoroutineHandle = EditorCoroutineUtility.StartCoroutine( + parentWindow.authManager.GetAccessTokenCoroutine(parentWindow.OnGetTokenComplete), parentWindow); + } + + GUILayout.Space(10); + GUIContent logoutButtonContent = new GUIContent("Logout", "Logout from Bentley account"); + + if (GUILayout.Button(logoutButtonContent, GUILayout.Height(30))) + { + parentWindow.StopCurrentCoroutine(); + parentWindow.authManager.Logout(); + parentWindow.UpdateLoginState(); + } + } + + EditorGUI.EndDisabledGroup(); + EditorGUILayout.EndHorizontal(); + + // Add token expiration info when logged in + if (parentWindow.authManager.IsLoggedIn() && parentWindow.authManager.GetCurrentAccessToken() != null) + { + EditorGUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Token expires:", GUILayout.Width(100)); + + // Format expiration time with color based on how close it is to expiration + DateTime expiry = parentWindow.authManager.GetExpiryTimeUtc().ToLocalTime(); + TimeSpan timeLeft = expiry - DateTime.Now; + + GUIStyle expiryStyle = new GUIStyle(EditorStyles.label); + if (timeLeft.TotalMinutes < 5) + expiryStyle.normal.textColor = Color.red; + else if (timeLeft.TotalMinutes < 15) + expiryStyle.normal.textColor = new Color(1.0f, 0.5f, 0.0f); // Orange + + EditorGUILayout.LabelField(expiry.ToString("HH:mm:ss - MM/dd/yyyy"), expiryStyle); + EditorGUILayout.EndHorizontal(); + } + + // Show login status indicator when logging in + if (parentWindow.currentState == WorkflowState.LoggingIn) + { + EditorGUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + // Create a horizontal group with fixed width to match the login button + EditorGUILayout.BeginHorizontal(GUILayout.Width(200)); + GUILayout.Label(BentleyWorkflowEditorWindow.SpinnerContent, GUILayout.Width(24), GUILayout.Height(24)); + // Use centered text style + GUIStyle centeredLabelStyle = new GUIStyle(EditorStyles.label) { + alignment = TextAnchor.MiddleCenter, + fontSize = 12 + }; + EditorGUILayout.LabelField("Logging in...", centeredLabelStyle); + EditorGUILayout.EndHorizontal(); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.EndVertical(); + } + + EditorGUILayout.Space(15); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/AuthenticationComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/AuthenticationComponent.cs.meta new file mode 100644 index 00000000..51fbc7f4 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/AuthenticationComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f06477ff18fe39d4595c530416d457b7 \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/CesiumIntegrationComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/CesiumIntegrationComponent.cs new file mode 100644 index 00000000..ccce252c --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/CesiumIntegrationComponent.cs @@ -0,0 +1,323 @@ +using UnityEngine; +using UnityEditor; +using CesiumForUnity; +using Unity.EditorCoroutines.Editor; +using System.Linq; +using System.Collections; + +public class CesiumIntegrationComponent +{ + private BentleyWorkflowEditorWindow parentWindow; + + public CesiumIntegrationComponent(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + public void DrawCesiumSection() + { + EditorGUILayout.Space(5); + EditorGUILayout.LabelField("Cesium Integration", BentleyUIStyles.sectionHeaderStyle); + + // Draw divider under header + var dividerRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(dividerRect, new Color(0.5f, 0.5f, 0.5f, 0.5f)); + EditorGUILayout.Space(8); + + EditorGUILayout.BeginVertical(BentleyUIStyles.cardStyle); + + // Main recommended option + EditorGUILayout.HelpBox("Create a Cesium 3D Tileset with the exported data.", MessageType.Info); + + EditorGUILayout.Space(10); + + // Create button centered + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + GUIContent createButtonContent = new GUIContent("Create Cesium Tileset", + "Create a new Cesium3DTileset with the exported URL"); + + if (GUILayout.Button(createButtonContent, GUILayout.Height(36), GUILayout.Width(180))) + { + CreateAndApplyTileset(); + } + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // Advanced options foldout + EditorGUILayout.Space(10); + + // Store the advanced options state in a class field + parentWindow.advancedCesiumOptionsVisible = EditorGUILayout.Foldout(parentWindow.advancedCesiumOptionsVisible, "Advanced Options", true); + + if (parentWindow.advancedCesiumOptionsVisible) + { + EditorGUI.indentLevel++; + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.Space(5); + EditorGUILayout.LabelField("Manual Tileset Assignment", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("Use this option to apply the URL to an existing Cesium tileset in your scene.", MessageType.Info); + + EditorGUILayout.Space(5); + + // Target tileset field + parentWindow.targetCesiumTileset = (Cesium3DTileset)EditorGUILayout.ObjectField( + new GUIContent("Target Cesium Tileset", "Drag a Cesium3DTileset component from your scene here."), + parentWindow.targetCesiumTileset, + typeof(Cesium3DTileset), + true); + + EditorGUILayout.Space(5); + + // Apply button + EditorGUI.BeginDisabledGroup(parentWindow.targetCesiumTileset == null); + if (GUILayout.Button(new GUIContent("Apply URL to Existing Tileset", + "Apply the exported URL to the selected Cesium Tileset"), GUILayout.Height(30))) + { + ApplyUrlToCesium(); + } + EditorGUI.EndDisabledGroup(); + + EditorGUILayout.EndVertical(); + EditorGUI.indentLevel--; + } + + EditorGUILayout.EndVertical(); + } + + // New method to create and apply a tileset + private void CreateAndApplyTileset() + { + if (string.IsNullOrEmpty(parentWindow.finalTilesetUrl)) + { + EditorUtility.DisplayDialog("Missing URL", "No exported URL is available. Complete the export process first.", "OK"); + return; + } + + // Step 1: Find or create a CesiumGeoreference + CesiumForUnity.CesiumGeoreference georeference = null; + + // Check if there's an existing CesiumGeoreference in the scene + var references = Object.FindObjectsByType(FindObjectsSortMode.None); + if (references != null && references.Length > 0) + { + georeference = references[0]; + } + else + { + // Create a new CesiumGeoreference + GameObject georeferenceObj = new GameObject("CesiumGeoreference"); + georeference = Undo.AddComponent(georeferenceObj); + } + + // Step 2: Create a new GameObject for the tileset + string tilesetName = "BentleyTileset"; + + // Get iTwin/iModel names for better naming if available + var selectedITwin = parentWindow.myITwins.FirstOrDefault(tw => tw.id == parentWindow.selectedITwinId); + var selectedIModel = parentWindow.myIModels.FirstOrDefault(im => im.id == parentWindow.selectedIModelId); + + if (selectedIModel != null && !string.IsNullOrEmpty(selectedIModel.displayName)) + { + tilesetName = selectedIModel.displayName; + } + else if (selectedITwin != null && !string.IsNullOrEmpty(selectedITwin.displayName)) + { + tilesetName = selectedITwin.displayName + "_Model"; + } + + GameObject tilesetObj = new GameObject(tilesetName); + Undo.RegisterCreatedObjectUndo(tilesetObj, "Create Bentley Tileset"); + + // Make it a child of the georeference + Undo.SetTransformParent(tilesetObj.transform, georeference.transform, "Set Tileset Parent"); + + // Step 3: Add Cesium3DTileset component + var tileset = Undo.AddComponent(tilesetObj); + + // Step 4: Set the URL and disable physics meshes + Undo.RecordObject(tileset, "Set Tileset URL"); + tileset.url = parentWindow.finalTilesetUrl; + tileset.tilesetSource = CesiumForUnity.CesiumDataSource.FromUrl; + tileset.createPhysicsMeshes = false; // Disable physics meshes by default + + // Step 5: Add metadata component + var metadata = Undo.AddComponent(tilesetObj); + + // Step 6: Set the metadata + var selectedITwinObj = parentWindow.myITwins.FirstOrDefault(tw => tw.id == parentWindow.selectedITwinId); + var selectedIModelObj = parentWindow.myIModels.FirstOrDefault(im => im.id == parentWindow.selectedIModelId); + + // Get changeset info + string changesetVersion = ""; + string changesetDescription = ""; + string changesetCreatedDate = ""; + + if (!string.IsNullOrEmpty(parentWindow.changesetId) && selectedIModelObj?.changesets != null) + { + var changeset = selectedIModelObj.changesets.FirstOrDefault(cs => cs.id == parentWindow.changesetId); + if (changeset != null) + { + changesetVersion = changeset.version; + changesetDescription = changeset.description; + changesetCreatedDate = changeset.createdDate.ToString("yyyy-MM-dd HH:mm:ss"); + } + } + + // Set the extended metadata with thumbnails + Undo.RecordObject(metadata, "Set Bentley Tileset Metadata"); + metadata.SetExtendedMetadata( + parentWindow.selectedITwinId, + selectedITwinObj?.displayName ?? "", + parentWindow.selectedIModelId, + selectedIModelObj?.displayName ?? "", + selectedIModelObj?.description ?? "", + parentWindow.changesetId, + changesetVersion, + changesetDescription, + changesetCreatedDate, + parentWindow.finalTilesetUrl, + selectedITwinObj?.thumbnail, + selectedIModelObj?.thumbnail + ); + + EditorUtility.SetDirty(tileset); + EditorUtility.SetDirty(metadata); + + // Step 7: Select and focus the new tileset using the same logic as the Focus button + Selection.activeGameObject = tilesetObj; + EditorGUIUtility.PingObject(tilesetObj); + + // Use the same focus logic as TilesetSceneOperations.FocusOnObject + EditorApplication.delayCall += () => + { + // Frame the object in the scene view + SceneView sceneView = SceneView.lastActiveSceneView; + if (sceneView != null) + { + sceneView.FrameSelected(); + sceneView.Repaint(); + EditorWindow.FocusWindowIfItsOpen(); + } + else + { + SceneView.FrameLastActiveSceneView(); + } + + // Place origin using the same logic as the Focus button + EditorApplication.delayCall += () => + { + EditorCoroutineUtility.StartCoroutine(DelayedPlaceOriginAtCamera(tilesetObj), parentWindow); + }; + }; + + } + + private void ApplyUrlToCesium() + { + if (parentWindow.targetCesiumTileset != null && !string.IsNullOrEmpty(parentWindow.finalTilesetUrl)) + { + // Get metadata component or add one + var metadata = parentWindow.targetCesiumTileset.GetComponent(); + if (metadata == null) + { + metadata = Undo.AddComponent(parentWindow.targetCesiumTileset.gameObject); + } + + // Set the URL + Undo.RecordObject(parentWindow.targetCesiumTileset, "Set Bentley Tileset URL"); + parentWindow.targetCesiumTileset.url = parentWindow.finalTilesetUrl; + parentWindow.targetCesiumTileset.tilesetSource = CesiumForUnity.CesiumDataSource.FromUrl; + + // Get selected data for metadata + var selectedITwin = parentWindow.myITwins.FirstOrDefault(tw => tw.id == parentWindow.selectedITwinId); + var selectedIModel = parentWindow.myIModels.FirstOrDefault(im => im.id == parentWindow.selectedIModelId); + + // If thumbnails aren't loaded yet, try to trigger loading + if (selectedIModel != null && !selectedIModel.thumbnailLoaded) + { + if (!selectedIModel.loadingThumbnail) + { + EditorCoroutineUtility.StartCoroutine(parentWindow.FetchIModelThumbnail(selectedIModel), parentWindow); + } + } + + // Get changeset info + string changesetVersion = ""; + string changesetDescription = ""; + string changesetCreatedDate = ""; + + if (!string.IsNullOrEmpty(parentWindow.changesetId) && selectedIModel?.changesets != null) + { + var changeset = selectedIModel.changesets.FirstOrDefault(cs => cs.id == parentWindow.changesetId); + if (changeset != null) + { + changesetVersion = changeset.version; + changesetDescription = changeset.description; + changesetCreatedDate = changeset.createdDate.ToString("yyyy-MM-dd HH:mm:ss"); + } + } + + // Set basic metadata immediately (will be updated with thumbnails later if needed) + Undo.RecordObject(metadata, "Set Bentley Tileset Metadata"); + metadata.SetExtendedMetadata( + parentWindow.selectedITwinId, + selectedITwin?.displayName ?? "", + parentWindow.selectedIModelId, + selectedIModel?.displayName ?? "", + selectedIModel?.description ?? "", + parentWindow.changesetId, + changesetVersion, + changesetDescription, + changesetCreatedDate, + parentWindow.finalTilesetUrl, + selectedITwin?.thumbnail, + selectedIModel?.thumbnail + ); + + EditorUtility.SetDirty(parentWindow.targetCesiumTileset); + EditorUtility.SetDirty(metadata); + + // If thumbnails are still loading, set up a callback to update metadata when they're ready + if ((selectedITwin != null && selectedITwin.loadingThumbnail) || + (selectedIModel != null && selectedIModel.loadingThumbnail)) + { + parentWindow.UpdateMetadataWithThumbnail(metadata, selectedITwin, selectedIModel); + } + + EditorUtility.DisplayDialog("URL Applied", "The exported URL has been applied to the selected Cesium tileset.", "OK"); + } + else if (string.IsNullOrEmpty(parentWindow.finalTilesetUrl)) + { + EditorUtility.DisplayDialog("Missing URL", "No exported URL is available. Complete the export process first.", "OK"); + } + } + + // Add this helper method to delay placing the origin at camera position (like the Focus button) + private IEnumerator DelayedPlaceOriginAtCamera(GameObject selectedObject) + { + // Wait for one frame to ensure SceneView has updated + yield return null; + + // Find CesiumGeoreference in parent hierarchy of the selected object + Transform current = selectedObject.transform; + CesiumForUnity.CesiumGeoreference georeference = null; + while (current != null && georeference == null) + { + georeference = current.GetComponent(); + if (georeference == null) + current = current.parent; + } + + if (georeference != null) + { + // Use the same logic as the Focus button - place georeference at camera position + CesiumForUnity.CesiumEditorUtility.PlaceGeoreferenceAtCameraPosition(georeference); + } + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/CesiumIntegrationComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/CesiumIntegrationComponent.cs.meta new file mode 100644 index 00000000..750daf95 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/CesiumIntegrationComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a92f30a075af83a439f4f572be8f5fc2 \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/EmptyStateComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/EmptyStateComponent.cs new file mode 100644 index 00000000..28cfb5d2 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/EmptyStateComponent.cs @@ -0,0 +1,54 @@ +using UnityEngine; +using UnityEditor; + +public class EmptyStateComponent +{ + private BentleyTilesetsWindow parentWindow; + + public EmptyStateComponent(BentleyTilesetsWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Draws the empty state display + /// + public void DrawEmptyState(string title, string description) + { + EditorGUILayout.BeginVertical(GUILayout.ExpandHeight(true)); + GUILayout.FlexibleSpace(); + + // Title with larger font + GUILayout.Label(title, new GUIStyle(TilesetsUIStyles.emptyStateStyle) { fontSize = 16 }); + GUILayout.Space(8); + + // Description + GUILayout.Label(description, TilesetsUIStyles.emptyStateStyle); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndVertical(); + } + + /// + /// Draws a loading spinner state + /// + public void DrawLoadingState(string message = "Loading...") + { + EditorGUILayout.BeginVertical(GUILayout.ExpandHeight(true)); + GUILayout.FlexibleSpace(); + + // Get current spinner frame from controller + var controller = new TilesetsWindowController(parentWindow); + string spinnerFrame = controller.GetCurrentSpinnerFrame(); + + // Spinner + GUILayout.Label(spinnerFrame, TilesetsUIStyles.spinnerStyle); + GUILayout.Space(8); + + // Loading message + GUILayout.Label(message, TilesetsUIStyles.emptyStateStyle); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndVertical(); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/EmptyStateComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/EmptyStateComponent.cs.meta new file mode 100644 index 00000000..ff98a5af --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/EmptyStateComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6bac1cadc8f2620448063f7d9fec995d \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/ExportComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/ExportComponent.cs new file mode 100644 index 00000000..dea8cf64 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/ExportComponent.cs @@ -0,0 +1,126 @@ +using UnityEngine; +using UnityEditor; +using Unity.EditorCoroutines.Editor; + +public class ExportComponent +{ + private BentleyWorkflowEditorWindow parentWindow; + + public ExportComponent(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + public void DrawExportSection() + { + EditorGUILayout.LabelField("Export Settings", BentleyUIStyles.sectionHeaderStyle); + + // Draw divider under header + var dividerRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(dividerRect, new Color(0.5f, 0.5f, 0.5f, 0.5f)); + EditorGUILayout.Space(8); + + EditorGUILayout.BeginVertical(BentleyUIStyles.cardStyle); + + // Skip displaying these when export is complete + if (parentWindow.currentState != WorkflowState.ExportComplete) + { + if (!string.IsNullOrEmpty(parentWindow.selectedIModelId)) + { + EditorGUILayout.LabelField("iModel ID", parentWindow.selectedIModelId); + } + else + { + parentWindow.iModelId = EditorGUILayout.TextField(new GUIContent("iModel ID", "The ID of the iModel to export"), parentWindow.iModelId); + } + + EditorGUILayout.Space(10); + + bool canStartExport = (parentWindow.currentState == WorkflowState.StartingExport || parentWindow.currentState == WorkflowState.LoggedIn || + parentWindow.currentState == WorkflowState.ExportComplete) + && !string.IsNullOrEmpty(parentWindow.selectedIModelId) && parentWindow.currentCoroutineHandle == null; + + EditorGUI.BeginDisabledGroup(!canStartExport); + + GUIContent exportButtonContent = new GUIContent("Start Export", "Begin the mesh export workflow"); + + if (GUILayout.Button(exportButtonContent, GUILayout.Height(30))) + { + parentWindow.StopCurrentCoroutine(); + parentWindow.statusMessage = "Starting export workflow..."; + parentWindow.currentState = WorkflowState.StartingExport; + parentWindow.Repaint(); + parentWindow.currentCoroutineHandle = EditorCoroutineUtility.StartCoroutine(parentWindow.RunFullExportWorkflowCoroutine(), parentWindow); + } + EditorGUI.EndDisabledGroup(); + + // Back button that goes to data selection + if (canStartExport) + { + if (GUILayout.Button("← Back to Data Selection", GUILayout.Height(22))) + { + parentWindow.currentState = WorkflowState.SelectIModel; + parentWindow.statusMessage = "Select an iModel to export."; + parentWindow.Repaint(); + } + } + + if (!canStartExport && parentWindow.currentCoroutineHandle == null) + { + EditorGUILayout.HelpBox("Select an iModel to start an export.", MessageType.Warning); + } + } + + // Export status indicators + if (parentWindow.currentCoroutineHandle != null && + (parentWindow.currentState == WorkflowState.StartingExport || + parentWindow.currentState == WorkflowState.PollingExport || + parentWindow.currentState == WorkflowState.LoggingIn)) + { + EditorGUILayout.Space(10); + EditorGUILayout.HelpBox($"{parentWindow.statusMessage}", MessageType.Info); + + // Progress bar + EditorGUILayout.Space(5); + float progress = (float)(EditorApplication.timeSinceStartup % 2.0f) / 2.0f; + Rect progressRect = EditorGUILayout.GetControlRect(false, 8); + EditorGUI.DrawRect(progressRect, new Color(0.3f, 0.3f, 0.3f, 0.3f)); + Rect fillRect = new Rect(progressRect); + fillRect.width = fillRect.width * progress; + EditorGUI.DrawRect(fillRect, new Color(0.0f, 0.7f, 0.0f, 0.7f)); + } + else if (parentWindow.currentState == WorkflowState.Error) + { + EditorGUILayout.Space(10); + EditorGUILayout.HelpBox($"Error: {parentWindow.statusMessage}", MessageType.Error); + } + // Show success message only when export is complete + else if (parentWindow.currentState == WorkflowState.ExportComplete) + { + EditorGUILayout.Space(15); + + // Success message and icon + EditorGUILayout.BeginHorizontal(); + GUIStyle successIconStyle = new GUIStyle(EditorStyles.label); + successIconStyle.normal.textColor = Color.green; + GUIContent checkIcon = new GUIContent("✓"); + EditorGUILayout.LabelField(checkIcon, successIconStyle, GUILayout.Width(20)); + EditorGUILayout.LabelField("Export Complete!", BentleyUIStyles.subheaderStyle); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + EditorGUILayout.HelpBox("Your export has been successfully completed. You can now use the Cesium Integration section below to apply the exported tileset to your scene.", MessageType.Info); + + EditorGUILayout.Space(10); + if (GUILayout.Button("Start New Export", GUILayout.Height(30))) + { + parentWindow.currentState = WorkflowState.LoggedIn; + parentWindow.statusMessage = "Ready to begin a new export."; + parentWindow.Repaint(); + } + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(15); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/ExportComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/ExportComponent.cs.meta new file mode 100644 index 00000000..022bb742 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/ExportComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 526be6e30e4c4db4b87ac50cda42e099 \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/PaginationComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/PaginationComponent.cs new file mode 100644 index 00000000..8c76e1a7 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/PaginationComponent.cs @@ -0,0 +1,123 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; + +public class PaginationComponent +{ + private BentleyTilesetsWindow parentWindow; + + public PaginationComponent(BentleyTilesetsWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Draws pagination controls if needed + /// + public void DrawPaginationControls(List filteredViews) + { + // Only draw pagination if we have more items than fit on one page + if (filteredViews.Count <= TilesetConstants.ITEMS_PER_PAGE) + return; + + // Calculate total pages + int totalPages = Mathf.CeilToInt((float)filteredViews.Count / TilesetConstants.ITEMS_PER_PAGE); + + // Make sure current page is valid + if (parentWindow.currentPage >= totalPages) + parentWindow.currentPage = totalPages - 1; + + // Create a centered horizontal group for all pagination controls + EditorGUILayout.BeginHorizontal(); + + // Add flexible space to push everything to the center + GUILayout.FlexibleSpace(); + + // Previous page button + DrawPreviousButton(); + + // Page number buttons + DrawPageNumbers(totalPages); + + // Next page button + DrawNextButton(totalPages); + + // Add flexible space to push everything to the center + GUILayout.FlexibleSpace(); + + EditorGUILayout.EndHorizontal(); + } + + /// + /// Gets the visible items for the current page + /// + public void GetPagedItems(List filteredViews, out int startIndex, out int endIndex) + { + startIndex = parentWindow.currentPage * TilesetConstants.ITEMS_PER_PAGE; + endIndex = Mathf.Min(startIndex + TilesetConstants.ITEMS_PER_PAGE, filteredViews.Count); + } + + private void DrawPreviousButton() + { + EditorGUI.BeginDisabledGroup(parentWindow.currentPage <= 0); + if (GUILayout.Button("◄", GUILayout.Height(TilesetConstants.BUTTON_HEIGHT), GUILayout.Width(30))) + { + parentWindow.currentPage--; + parentWindow.shouldClearSearchFocus = true; + parentWindow.Repaint(); + } + EditorGUI.EndDisabledGroup(); + } + + private void DrawPageNumbers(int totalPages) + { + // Get page numbers to display using the helper + List pageNumbers = TilesetsPaginationHelper.GetPaginationNumbers(parentWindow.currentPage, totalPages); + + // Display page numbers + foreach (int pageIndex in pageNumbers) + { + if (pageIndex == -1) + { + // This is an ellipsis + GUILayout.Label("...", GUILayout.Width(20)); + } + else + { + DrawPageButton(pageIndex); + } + } + } + + private void DrawPageButton(int pageIndex) + { + // Create button style + GUIStyle pageButtonStyle = new GUIStyle(GUI.skin.button); + + // Highlight current page + if (pageIndex == parentWindow.currentPage) + { + pageButtonStyle.fontStyle = FontStyle.Bold; + pageButtonStyle.normal.textColor = TilesetConstants.AccentColor; + } + + if (GUILayout.Button((pageIndex + 1).ToString(), pageButtonStyle, GUILayout.Width(30), GUILayout.Height(TilesetConstants.BUTTON_HEIGHT))) + { + parentWindow.currentPage = pageIndex; + parentWindow.shouldClearSearchFocus = true; + parentWindow.Repaint(); + } + } + + private void DrawNextButton(int totalPages) + { + EditorGUI.BeginDisabledGroup(parentWindow.currentPage >= totalPages - 1); + if (GUILayout.Button("►", GUILayout.Height(TilesetConstants.BUTTON_HEIGHT), GUILayout.Width(30))) + { + parentWindow.currentPage++; + parentWindow.shouldClearSearchFocus = true; + parentWindow.Repaint(); + } + EditorGUI.EndDisabledGroup(); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/PaginationComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/PaginationComponent.cs.meta new file mode 100644 index 00000000..b89c9408 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/PaginationComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9222d348a195d214193a7446154ec0c5 \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/ProjectBrowserComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/ProjectBrowserComponent.cs new file mode 100644 index 00000000..35b02a20 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/ProjectBrowserComponent.cs @@ -0,0 +1,531 @@ +using UnityEngine; +using UnityEditor; +using Unity.EditorCoroutines.Editor; +using System.Collections.Generic; +using System.Linq; +using System; + +/// +/// Provides a comprehensive browsing interface for iTwins and iModels with advanced features +/// including pagination, thumbnail loading, search integration, and responsive layout. +/// This component handles the display and selection of projects from Bentley's iTwin platform. +/// +public class ProjectBrowserComponent +{ + /// + /// Reference to the parent workflow editor window for state management and coordination + /// + private BentleyWorkflowEditorWindow parentWindow; + + /// + /// Initializes the project browser component with a reference to the parent window. + /// + /// The workflow editor window that contains this component + public ProjectBrowserComponent(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Renders the main project browsing interface, adapting its display based on the current + /// workflow state (iTwin selection vs iModel selection). Includes responsive layout, + /// pagination controls, and integrated search functionality. + /// + public void DrawITwinIModelSelection() + { + // Clear section header with step indication + EditorGUILayout.LabelField( + parentWindow.currentState == WorkflowState.SelectITwin ? "Select an iTwin Project" : "Select an iModel", + BentleyUIStyles.sectionHeaderStyle); + + // Draw divider under header + var dividerRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(dividerRect, new Color(0.5f, 0.5f, 0.5f, 0.5f)); + EditorGUILayout.Space(8); + + EditorGUILayout.BeginVertical(BentleyUIStyles.cardStyle); + + if (parentWindow.currentState == WorkflowState.LoggedIn) + { + EditorGUILayout.HelpBox("Click the button below to fetch your iTwin projects.", MessageType.Info); + EditorGUILayout.Space(10); + + // Center the button + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + if (GUILayout.Button(new GUIContent("Fetch my iTwins", "Retrieve your iTwin projects"), + GUILayout.Height(32), GUILayout.Width(200))) + { + parentWindow.currentState = WorkflowState.FetchingITwins; + parentWindow.statusMessage = "Fetching iTwins..."; + parentWindow.Repaint(); + parentWindow.currentCoroutineHandle = EditorCoroutineUtility.StartCoroutine(parentWindow.GetMyITwinsCoroutine(), parentWindow); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + else if (parentWindow.currentState == WorkflowState.FetchingITwins || parentWindow.currentState == WorkflowState.FetchingIModels) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + GUILayout.Label(BentleyWorkflowEditorWindow.SpinnerContent, GUILayout.Width(32), GUILayout.Height(32)); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + // Create properly centered label style + GUIStyle centeredHeaderStyle = new GUIStyle(BentleyUIStyles.subheaderStyle); + centeredHeaderStyle.alignment = TextAnchor.MiddleCenter; + + // Use centered style and fixed width + EditorGUILayout.LabelField( + parentWindow.currentState == WorkflowState.FetchingITwins ? "Loading iTwins..." : "Loading iModels...", + centeredHeaderStyle, + GUILayout.MinWidth(150)); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + else if (parentWindow.currentState == WorkflowState.SelectITwin) + { + DrawITwinSelection(); + } + else if (parentWindow.currentState == WorkflowState.SelectIModel) + { + DrawIModelSelection(); + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(15); + } + + private void DrawITwinSelection() + { + // Add search box at top + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Search:", GUILayout.Width(60)); + + // Store the previous control's name to detect focus change + string searchControlName = "iTwinSearchField"; + GUI.SetNextControlName(searchControlName); + + // Check if we should clear focus + if (parentWindow.shouldClearSearchFocus) + { + GUI.FocusControl(null); + parentWindow.shouldClearSearchFocus = false; + } + + string newSearchText = EditorGUILayout.TextField(parentWindow.iTwinsSearchText, GUILayout.Height(20)); + if (newSearchText != parentWindow.iTwinsSearchText) + { + parentWindow.iTwinsSearchText = newSearchText; + parentWindow.iTwinsCurrentPage = 0; // Reset to first page when search changes + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(10); + + // Filter iTwins based on search text + List filteredITwins = string.IsNullOrEmpty(parentWindow.iTwinsSearchText) + ? parentWindow.myITwins + : parentWindow.myITwins.Where(tw => tw.displayName.IndexOf(parentWindow.iTwinsSearchText, StringComparison.OrdinalIgnoreCase) >= 0).ToList(); + + + // Display message if no results + if (filteredITwins.Count == 0 && !string.IsNullOrEmpty(parentWindow.iTwinsSearchText)) + { + EditorGUILayout.HelpBox($"No iTwins found matching '{parentWindow.iTwinsSearchText}'", MessageType.Info); + } + + // Calculate visible items based on current page and filtered results + int startIndex = parentWindow.iTwinsCurrentPage * BentleyWorkflowEditorWindow.ITEMS_PER_PAGE; + int endIndex = Mathf.Min(startIndex + BentleyWorkflowEditorWindow.ITEMS_PER_PAGE, filteredITwins.Count); + + // Display only the current page of filtered iTwins + for (int i = startIndex; i < endIndex; i++) + { + var tw = filteredITwins[i]; + + // Add this line to define the thumbnail endpoint + string thumbnailEndpoint = $"thumbnail_itwin_{tw.id}"; + + // Only fetch thumbnails for visible items if not already loading or loaded + if (tw.thumbnail == null && !tw.loadingThumbnail && !tw.thumbnailLoaded && ApiRateLimiter.CanMakeRequest(thumbnailEndpoint)) + { + EditorCoroutineUtility.StartCoroutine(parentWindow.FetchITwinThumbnail(tw), parentWindow); + } + + EditorGUILayout.BeginVertical(BentleyUIStyles.selectionCardStyle); + EditorGUILayout.BeginHorizontal(); + + // Display thumbnail with proper sizing + Rect thumbnailRect = EditorGUILayout.GetControlRect(false, 64, GUILayout.Width(64)); + Texture2D tex = tw.loadingThumbnail ? (Texture2D)BentleyWorkflowEditorWindow.SpinnerContent.image : tw.thumbnail ?? Texture2D.grayTexture; + GUI.DrawTexture(thumbnailRect, tex, ScaleMode.ScaleAndCrop); // <-- changed here + + EditorGUILayout.BeginVertical(GUILayout.Height(64)); + + // Display iTwin name with better styling + EditorGUILayout.LabelField(tw.displayName, BentleyUIStyles.itemTitleStyle); + // We could add id or other info here if helpful + EditorGUILayout.LabelField($"Project ID: {tw.id.Substring(0, 8)}...", BentleyUIStyles.itemDescriptionStyle); + + EditorGUILayout.EndVertical(); + + // Select button on the right with proper vertical centering + EditorGUILayout.BeginVertical(GUILayout.Width(70), GUILayout.Height(64)); + GUILayout.FlexibleSpace(); + if (GUILayout.Button("Select", GUILayout.Height(24), GUILayout.Width(70))) + { + parentWindow.selectedITwinId = tw.id; + parentWindow.iTwinsSearchText = ""; // Clear iTwin search text + parentWindow.iModelsSearchText = ""; // Clear iModel search text for next screen + parentWindow.shouldClearSearchFocus = true; // Add this line to clear focus + parentWindow.currentState = WorkflowState.FetchingIModels; + parentWindow.statusMessage = "Fetching iModels..."; + parentWindow.Repaint(); + parentWindow.currentCoroutineHandle = EditorCoroutineUtility.StartCoroutine(parentWindow.GetITwinIModelsCoroutine(tw.id), parentWindow); + } + GUILayout.FlexibleSpace(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + // Add page navigation buttons if we have more than one page in filtered results + if (filteredITwins.Count > BentleyWorkflowEditorWindow.ITEMS_PER_PAGE) + { + DrawPaginationControls(filteredITwins.Count, ref parentWindow.iTwinsCurrentPage, "iTwins"); + } + + // Back button aligned to the left + EditorGUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button(new GUIContent("← Back", "Return to previous step"), + GUILayout.Height(24), GUILayout.Width(100))) + { + parentWindow.currentState = WorkflowState.LoggedIn; + parentWindow.iTwinsSearchText = ""; // Clear search text when going back + parentWindow.shouldClearSearchFocus = true; // Add this line + } + EditorGUILayout.EndHorizontal(); + } + + private void DrawIModelSelection() + { + // Add search box at top + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Search:", GUILayout.Width(60)); + + // Store the previous control's name to detect focus change + string searchControlName = "iModelSearchField"; + GUI.SetNextControlName(searchControlName); + + // Check if we should clear focus + if (parentWindow.shouldClearSearchFocus) + { + GUI.FocusControl(null); + parentWindow.shouldClearSearchFocus = false; + } + + string newSearchText = EditorGUILayout.TextField(parentWindow.iModelsSearchText, GUILayout.Height(20)); + if (newSearchText != parentWindow.iModelsSearchText) + { + parentWindow.iModelsSearchText = newSearchText; + parentWindow.iModelsCurrentPage = 0; // Reset to first page when search changes + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(10); + + // Filter iModels based on search text + List filteredIModels = string.IsNullOrEmpty(parentWindow.iModelsSearchText) + ? parentWindow.myIModels + : parentWindow.myIModels.Where(im => im.displayName.IndexOf(parentWindow.iModelsSearchText, StringComparison.OrdinalIgnoreCase) >= 0).ToList(); + + + // Display message if no results + if (filteredIModels.Count == 0 && !string.IsNullOrEmpty(parentWindow.iModelsSearchText)) + { + EditorGUILayout.HelpBox($"No iModels found matching '{parentWindow.iModelsSearchText}'", MessageType.Info); + } + + // Calculate visible items based on current page and filtered results + int startIndex = parentWindow.iModelsCurrentPage * BentleyWorkflowEditorWindow.ITEMS_PER_PAGE; + int endIndex = Mathf.Min(startIndex + BentleyWorkflowEditorWindow.ITEMS_PER_PAGE, filteredIModels.Count); + + // Display only the current page of filtered iModels + for (int i = startIndex; i < endIndex; i++) + { + var im = filteredIModels[i]; + + // Add rate limiting to all API calls + string thumbnailEndpoint = $"thumbnail_{im.id}"; + string detailsEndpoint = $"details_{im.id}"; + string changesetsEndpoint = $"changesets_{im.id}"; + + // Only fetch thumbnail if needed and rate limiting allows + if (im.thumbnail == null && !im.loadingThumbnail && !im.thumbnailLoaded && ApiRateLimiter.CanMakeRequest(thumbnailEndpoint)) + { + EditorCoroutineUtility.StartCoroutine(parentWindow.FetchIModelThumbnail(im), parentWindow); + } + + // Only fetch details if needed and rate limiting allows + if (!im.loadingDetails && !im.detailsLoaded && ApiRateLimiter.CanMakeRequest(detailsEndpoint)) + { + EditorCoroutineUtility.StartCoroutine(parentWindow.FetchIModelDetailsCoroutine(im), parentWindow); + } + + // Only fetch changesets if needed and rate limiting allows + if (!im.loadingChangesets && (im.changesets == null || im.changesets.Count == 0) && ApiRateLimiter.CanMakeRequest(changesetsEndpoint)) + { + EditorCoroutineUtility.StartCoroutine(parentWindow.FetchIModelChangesets(im), parentWindow); + } + + EditorGUILayout.BeginVertical(BentleyUIStyles.selectionCardStyle); + + // Header with name + EditorGUILayout.LabelField(im.displayName, BentleyUIStyles.itemTitleStyle); + + // Subtle divider + var itemDividerRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(itemDividerRect, new Color(0.5f, 0.5f, 0.5f, 0.2f)); + EditorGUILayout.Space(5); + + // Main content area with thumbnail and description + EditorGUILayout.BeginHorizontal(); + + // Left side - Thumbnail + EditorGUILayout.BeginVertical(GUILayout.Width(90)); + // Display thumbnail with proper sizing and border + Rect imageRect = EditorGUILayout.GetControlRect(false, 80, GUILayout.Width(80)); + Texture2D tex = im.loadingThumbnail ? (Texture2D)BentleyWorkflowEditorWindow.SpinnerContent.image : im.thumbnail ?? Texture2D.grayTexture; + + // Add a slight border around the image + EditorGUI.DrawRect(new Rect(imageRect.x-1, imageRect.y-1, imageRect.width+2, imageRect.height+2), + new Color(0.3f, 0.3f, 0.3f, 0.5f)); + GUI.DrawTexture(imageRect, tex, ScaleMode.ScaleAndCrop); // <-- changed here + EditorGUILayout.EndVertical(); + + // Middle - Description + EditorGUILayout.BeginVertical(); + + // Description + if (!string.IsNullOrEmpty(im.description)) + { + // Limit description length to prevent UI issues + string displayDescription = im.description; + if (displayDescription.Length > 200) + { + displayDescription = displayDescription.Substring(0, 197) + "..."; + } + EditorGUILayout.LabelField(displayDescription, BentleyUIStyles.itemDescriptionStyle); + } + else if (im.loadingDetails) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.Label(BentleyWorkflowEditorWindow.SpinnerContent, GUILayout.Width(16), GUILayout.Height(16)); + EditorGUILayout.LabelField("Loading description...", BentleyUIStyles.itemDescriptionStyle); + EditorGUILayout.EndHorizontal(); + } + else + { + // Use the helper method for consistent fallback text + EditorGUILayout.LabelField(parentWindow.GetDisplayDescription(im), BentleyUIStyles.itemDescriptionStyle); + } + + EditorGUILayout.Space(4); + EditorGUILayout.LabelField($"ID: {im.id.Substring(0, Math.Min(im.id.Length, 12))}...", BentleyUIStyles.itemDescriptionStyle); + + EditorGUILayout.EndVertical(); + EditorGUILayout.EndHorizontal(); + + // Changeset selection area + EditorGUILayout.Space(10); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Changeset:", GUILayout.Width(80)); + + if (im.loadingChangesets) + { + EditorGUILayout.BeginHorizontal(); + GUILayout.Label(BentleyWorkflowEditorWindow.SpinnerContent, GUILayout.Width(16), GUILayout.Height(16)); + EditorGUILayout.LabelField("Loading changesets..."); + EditorGUILayout.EndHorizontal(); + } + else if (im.changesets != null && im.changesets.Count > 0) + { + // Build options: "latest" + all changesets + List options = new List { "Latest Changeset" }; + + // Format each changeset similar to web version + foreach (var cs in im.changesets) + { + // Use a more readable format that matches the web experience + string displayText; + if (!string.IsNullOrEmpty(cs.description)) + { + displayText = $"{cs.description}"; + } + else + { + displayText = $"Version {cs.version}"; + } + options.Add(displayText); + } + + // Show a more visually appealing dropdown + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Version:", GUILayout.Width(60)); + + // Store previous index to detect changes + int prevIndex = im.selectedChangesetIndex; + im.selectedChangesetIndex = EditorGUILayout.Popup(im.selectedChangesetIndex, options.ToArray()); + + // If index changed, update UI + if (prevIndex != im.selectedChangesetIndex) + { + parentWindow.Repaint(); + } + + EditorGUILayout.EndHorizontal(); + } + else + { + EditorGUILayout.LabelField("No changesets available"); + } + + EditorGUILayout.EndHorizontal(); + + // Bottom button bar with select button right-aligned + EditorGUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + // Select button + if (GUILayout.Button("Select for Export", GUILayout.Height(28), GUILayout.Width(120))) + { + // Save the selected iModel and changeset + parentWindow.selectedIModelId = im.id; + parentWindow.iModelId = parentWindow.selectedIModelId; + parentWindow.iModelsSearchText = ""; // Clear iModel search text + + // Set the changeset ID based on selection + if (im.selectedChangesetIndex == 0) { + // Use latest changeset (empty string) + parentWindow.changesetId = string.Empty; + parentWindow.statusMessage = "Using latest changeset for export."; + } else if (im.changesets != null && im.changesets.Count > 0 && + im.selectedChangesetIndex <= im.changesets.Count) { + // Use specific changeset + var selectedChangeset = im.changesets[im.selectedChangesetIndex - 1]; + parentWindow.changesetId = selectedChangeset.id; + parentWindow.statusMessage = $"Using changeset: {selectedChangeset.description ?? selectedChangeset.version}"; + } else { + // Fallback if something went wrong + parentWindow.changesetId = string.Empty; + parentWindow.statusMessage = "Could not determine changeset, using latest."; + } + + // Skip the changeset selection state, go directly to export + parentWindow.currentState = WorkflowState.StartingExport; + parentWindow.Repaint(); + parentWindow.currentCoroutineHandle = EditorCoroutineUtility.StartCoroutine(parentWindow.RunFullExportWorkflowCoroutine(), parentWindow); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + // Add page navigation buttons for iModels + if (filteredIModels.Count > BentleyWorkflowEditorWindow.ITEMS_PER_PAGE) + { + DrawPaginationControls(filteredIModels.Count, ref parentWindow.iModelsCurrentPage, "iModels"); + } + + // Back button aligned to the left + EditorGUILayout.Space(5); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button(new GUIContent("← Back", "Return to iTwin selection"), + GUILayout.Height(24), GUILayout.Width(100))) + { + parentWindow.currentState = WorkflowState.SelectITwin; + parentWindow.iModelsSearchText = ""; // Clear search text when going back + parentWindow.shouldClearSearchFocus = true; // Add this line + } + EditorGUILayout.EndHorizontal(); + } + + private void DrawPaginationControls(int totalItems, ref int currentPage, string context) + { + // Calculate total pages based on FILTERED results + int totalPages = Mathf.CeilToInt((float)totalItems / BentleyWorkflowEditorWindow.ITEMS_PER_PAGE); + + // Make sure current page is valid for filtered results + if (currentPage >= totalPages) + currentPage = totalPages - 1; + + EditorGUILayout.Space(5); + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + + // Previous page button + EditorGUI.BeginDisabledGroup(currentPage <= 0); + if (GUILayout.Button("◄", GUILayout.Height(24), GUILayout.Width(30))) + { + currentPage--; + parentWindow.shouldClearSearchFocus = true; + parentWindow.Repaint(); + } + EditorGUI.EndDisabledGroup(); + + // Get page numbers to display + List pageNumbers = BentleyPaginationHelper.GetPaginationNumbers(currentPage, totalPages); + + // Display page numbers + foreach (int pageIndex in pageNumbers) + { + if (pageIndex == -1) + { + // This is an ellipsis + GUILayout.Label("...", GUILayout.Width(20)); + } + else + { + // Create button style + GUIStyle pageButtonStyle = new GUIStyle(GUI.skin.button); + + // Highlight current page + if (pageIndex == currentPage) + { + pageButtonStyle.fontStyle = FontStyle.Bold; + pageButtonStyle.normal.textColor = new Color(0.2f, 0.5f, 0.9f); + } + + if (GUILayout.Button((pageIndex + 1).ToString(), pageButtonStyle, GUILayout.Width(30), GUILayout.Height(24))) + { + currentPage = pageIndex; + parentWindow.shouldClearSearchFocus = true; + parentWindow.Repaint(); + } + } + } + + // Next page button + EditorGUI.BeginDisabledGroup(currentPage >= totalPages - 1); + if (GUILayout.Button("►", GUILayout.Height(24), GUILayout.Width(30))) + { + currentPage++; + parentWindow.shouldClearSearchFocus = true; + parentWindow.Repaint(); + } + EditorGUI.EndDisabledGroup(); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/ProjectBrowserComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/ProjectBrowserComponent.cs.meta new file mode 100644 index 00000000..64d3c3bc --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/ProjectBrowserComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1a447c4c4edee0742b6be83fcd89d60b \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/SearchComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/SearchComponent.cs new file mode 100644 index 00000000..1b76932f --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/SearchComponent.cs @@ -0,0 +1,74 @@ +using UnityEngine; +using UnityEditor; + +public class SearchComponent +{ + private BentleyTilesetsWindow parentWindow; + private TilesetSearchManager searchManager; + + public SearchComponent(BentleyTilesetsWindow parentWindow) + { + this.parentWindow = parentWindow; + this.searchManager = new TilesetSearchManager(parentWindow); + } + + /// + /// Draws the search section UI + /// + public void DrawSearchSection() + { + // Search area + EditorGUILayout.BeginHorizontal(); + GUILayout.Space(16); + + Rect searchRect = GUILayoutUtility.GetRect(GUIContent.none, TilesetsUIStyles.searchBoxStyle, + GUILayout.ExpandWidth(true), GUILayout.Height(TilesetConstants.SEARCH_BOX_HEIGHT)); + + // Draw search icon - increased size + var searchIcon = TilesetsIconHelper.GetSearchIcon(); + GUI.DrawTexture(new Rect(searchRect.x + 6, searchRect.y + 4, 20, 20), searchIcon.image); + + // Handle clear search focus + searchManager.HandleClearSearchFocus(); + + // Search field + GUI.SetNextControlName(TilesetConstants.SEARCH_CONTROL_NAME); + string newSearchText = EditorGUI.TextField(searchRect, parentWindow.searchText, TilesetsUIStyles.searchBoxStyle); + + // Update search text through manager + searchManager.UpdateSearchText(newSearchText); + + // Clear button if search has text + if (!string.IsNullOrEmpty(parentWindow.searchText)) + { + var clearIcon = TilesetsIconHelper.GetClearIcon(); + Rect clearRect = new Rect(searchRect.xMax - 24, searchRect.y + 4, 20, 20); + if (GUI.Button(clearRect, clearIcon, GUIStyle.none)) + { + searchManager.ClearSearch(); + } + } + + GUILayout.Space(16); + EditorGUILayout.EndHorizontal(); + + GUILayout.Space(12); + } + + /// + /// Gets filtered views based on current search text + /// + public System.Collections.Generic.List GetFilteredViews() + { + var dataManager = new TilesetDataManager(parentWindow); + return dataManager.GetFilteredViews(); + } + + /// + /// Gets the appropriate empty state message + /// + public void GetEmptyStateMessage(out string title, out string description) + { + searchManager.GetEmptyStateMessage(out title, out description); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/SearchComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/SearchComponent.cs.meta new file mode 100644 index 00000000..e5dd6a84 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/SearchComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4e710000de0c8d542b72a16d8788d35c \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/TilesetCardRenderer.cs b/Editor/iTwinForUnity/EditorWindows/Components/TilesetCardRenderer.cs new file mode 100644 index 00000000..4d1955bb --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/TilesetCardRenderer.cs @@ -0,0 +1,167 @@ +using UnityEngine; +using UnityEditor; + +public class TilesetCardRenderer +{ + private BentleyTilesetsWindow parentWindow; + + public TilesetCardRenderer(BentleyTilesetsWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Draws a single tileset card + /// + public void DrawTilesetCard(BentleyTilesetMetadata metadata, int index) + { + bool isHovered = parentWindow.hoveredCardIndex == index; + + // Use selectionCardStyle like BentleyWorkflowEditorWindow + EditorGUILayout.BeginVertical(TilesetsUIStyles.selectionCardStyle); + + // Store card rect - add a small element first to avoid the GetLastRect issue + GUILayout.Space(1); + Rect cardRect = GUILayoutUtility.GetLastRect(); + + // Add hover effect + if (isHovered) + EditorGUI.DrawRect(cardRect, TilesetConstants.CardBgHoverColor); + else + EditorGUI.DrawRect(cardRect, TilesetConstants.CardBgColor); + + // Handle hover state + HandleCardHover(cardRect, index); + + // Header with name - matching iTwin/iModel style + EditorGUILayout.LabelField(metadata.iModelName, TilesetsUIStyles.itemTitleStyle); + + // Subtle divider + DrawItemDivider(); + + // Main content area with thumbnail and description + DrawCardContent(metadata); + + // Action buttons + DrawCardButtons(metadata); + + EditorGUILayout.EndVertical(); + } + + private void HandleCardHover(Rect cardRect, int index) + { + // Check hover state + if (Event.current.type == EventType.Repaint) + { + // Calculate full card rect + Rect fullCardRect = new Rect(cardRect.x, cardRect.y - 1, cardRect.width, 300); // approximate height + + if (fullCardRect.Contains(Event.current.mousePosition)) + { + if (parentWindow.hoveredCardIndex != index) + { + parentWindow.hoveredCardIndex = index; + parentWindow.Repaint(); + } + } + else if (parentWindow.hoveredCardIndex == index && !fullCardRect.Contains(Event.current.mousePosition)) + { + parentWindow.hoveredCardIndex = -1; + parentWindow.Repaint(); + } + } + } + + private void DrawItemDivider() + { + var itemDividerRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(itemDividerRect, new Color(0.5f, 0.5f, 0.5f, 0.2f)); + EditorGUILayout.Space(5); + } + + private void DrawCardContent(BentleyTilesetMetadata metadata) + { + EditorGUILayout.BeginHorizontal(); + + // Left side - Thumbnail + DrawThumbnail(metadata); + + // Right side - Description and details + DrawDescription(metadata); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawThumbnail(BentleyTilesetMetadata metadata) + { + EditorGUILayout.BeginVertical(GUILayout.Width(90)); + + // Display thumbnail with proper sizing and border + Rect imageRect = EditorGUILayout.GetControlRect(false, TilesetConstants.THUMBNAIL_SIZE, GUILayout.Width(TilesetConstants.THUMBNAIL_SIZE)); + Texture2D thumbnail = metadata.GetIModelThumbnail(); + + // Add a slight border around the image + EditorGUI.DrawRect(new Rect(imageRect.x-1, imageRect.y-1, imageRect.width+2, imageRect.height+2), + new Color(0.3f, 0.3f, 0.3f, 0.5f)); + + if (thumbnail != null) + { + GUI.DrawTexture(imageRect, thumbnail, ScaleMode.ScaleAndCrop); + } + else + { + GUI.DrawTexture(imageRect, TilesetsUIStyles.GetNoThumbnailTexture()); + GUI.Label(imageRect, "No Thumbnail", + new GUIStyle(EditorStyles.centeredGreyMiniLabel) { fontSize = 12 }); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawDescription(BentleyTilesetMetadata metadata) + { + EditorGUILayout.BeginVertical(); + + // Description + var dataManager = new TilesetDataManager(parentWindow); + string displayDescription = dataManager.GetDisplayDescription(metadata); + EditorGUILayout.LabelField(displayDescription, TilesetsUIStyles.itemDescriptionStyle); + + EditorGUILayout.Space(4); + + // ID display + string displayId = dataManager.GetDisplayId(metadata); + EditorGUILayout.LabelField(displayId, TilesetsUIStyles.itemDescriptionStyle); + + EditorGUILayout.EndVertical(); + } + + private void DrawCardButtons(BentleyTilesetMetadata metadata) + { + EditorGUILayout.Space(8); + EditorGUILayout.BeginHorizontal(); + + // Hierarchy button + var hierarchyIcon = TilesetsIconHelper.GetHierarchyIcon(); + if (GUILayout.Button(new GUIContent(" Select", hierarchyIcon.image), TilesetsUIStyles.buttonStyle)) + { + TilesetSceneOperations.SelectAndPingObject(metadata.gameObject); + } + + // Focus button + var focusIcon = TilesetsIconHelper.GetFocusIcon(); + if (GUILayout.Button(new GUIContent(" Focus", focusIcon.image), TilesetsUIStyles.buttonStyle)) + { + TilesetSceneOperations.FocusOnObject(metadata.gameObject, parentWindow); + } + + // Web button - Open in iTwin Platform + var webIcon = TilesetsIconHelper.GetWebIcon(); + if (GUILayout.Button(new GUIContent(" Web", webIcon.image), TilesetsUIStyles.buttonStyle)) + { + TilesetSceneOperations.OpenWebUrl(metadata); + } + + EditorGUILayout.EndHorizontal(); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/TilesetCardRenderer.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/TilesetCardRenderer.cs.meta new file mode 100644 index 00000000..007c73c8 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/TilesetCardRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 07cd0fbe2521b4c4f80e37f3656cc7e4 \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/WelcomeComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/WelcomeComponent.cs new file mode 100644 index 00000000..6bd3a562 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/WelcomeComponent.cs @@ -0,0 +1,85 @@ +using UnityEngine; +using UnityEditor; + +public class WelcomeComponent +{ + private BentleyWorkflowEditorWindow parentWindow; + + public WelcomeComponent(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + public void DrawWelcomeSection() + { + EditorGUILayout.BeginVertical(BentleyUIStyles.cardStyle); + + // Bentley logo/header - Fix the title getting cut off + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + // Use wordWrapped style to prevent cutting off + GUIStyle titleStyle = new GUIStyle(EditorStyles.largeLabel) { + fontSize = 18, + fontStyle = FontStyle.Bold, + alignment = TextAnchor.MiddleCenter, + wordWrap = true // Add word wrap + }; + EditorGUILayout.LabelField("Bentley iTwin Mesh Export", titleStyle, GUILayout.ExpandWidth(true)); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(15); + + + // Welcome message + EditorGUILayout.LabelField( + "Welcome to the Bentley iTwin Mesh Export tool for Unity. This tool allows you to export 3D models from iTwin and use them in your Unity projects with Cesium.", + new GUIStyle(EditorStyles.wordWrappedLabel) { fontSize = 12 } + ); + + EditorGUILayout.Space(10); + + // How it works section + EditorGUILayout.LabelField("How it works:", EditorStyles.boldLabel); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("1. Log in with your Bentley account", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.LabelField("2. Select an iTwin project and iModel", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.LabelField("3. Choose a changeset version to export", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.LabelField("4. Apply the exported model to a Cesium tileset", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(10); + + // Prerequisites section + EditorGUILayout.LabelField("Prerequisites:", EditorStyles.boldLabel); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("• A Bentley iTwin account with access to projects", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.LabelField("• Client ID from the Bentley Developer Portal", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.LabelField("• Cesium for Unity package installed", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.LabelField("• Make sure that your Redirect URI matches with the one in the file Assets/Scripts/BentleyAuthManager", BentleyUIStyles.richTextLabelStyle); + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(15); + + // Fix the instruction text getting cut off + EditorGUILayout.BeginHorizontal(); + GUILayout.FlexibleSpace(); + GUIStyle instructionStyle = new GUIStyle(EditorStyles.boldLabel) { + fontSize = 13, + alignment = TextAnchor.MiddleCenter, + wordWrap = true, // Ensure text wraps if needed + fixedWidth = 0 // Let Unity calculate width based on content + }; + + // Get started prompt with arrow pointing to login section + EditorGUILayout.LabelField("Please log in below to get started ↓", instructionStyle, + GUILayout.ExpandWidth(true), GUILayout.MinWidth(250)); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(15); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/WelcomeComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/WelcomeComponent.cs.meta new file mode 100644 index 00000000..cdb2070c --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/WelcomeComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a31d6ab0f9c031e43b8e1a26a9ccab1b \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Components/WorkflowStepIndicatorComponent.cs b/Editor/iTwinForUnity/EditorWindows/Components/WorkflowStepIndicatorComponent.cs new file mode 100644 index 00000000..5d246872 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/WorkflowStepIndicatorComponent.cs @@ -0,0 +1,77 @@ +using UnityEngine; +using UnityEditor; + +public class WorkflowStepIndicatorComponent +{ + private BentleyWorkflowEditorWindow parentWindow; + private WorkflowStateManager stateManager; + + public WorkflowStepIndicatorComponent(BentleyWorkflowEditorWindow parentWindow, WorkflowStateManager stateManager) + { + this.parentWindow = parentWindow; + this.stateManager = stateManager; + } + + public void DrawWorkflowStepIndicator() + { + // Create a breadcrumb-like step indicator + EditorGUILayout.BeginHorizontal(); + + // Current state for navigation control + int currentStepLevel = stateManager.GetStepLevel(parentWindow.currentState); + + // Step 1: Authentication + GUIStyle step1Style = new GUIStyle(BentleyUIStyles.stepIndicatorStyle); + step1Style.richText = true; + if (currentStepLevel >= 1) { + string step1Text = currentStepLevel == 1 ? "1. Authentication" : "1. Authentication"; + if (GUILayout.Button(step1Text, step1Style, GUILayout.Height(20))) + { + parentWindow.currentState = WorkflowState.LoggedIn; + parentWindow.statusMessage = "Returned to Authentication."; + // Clear all search text when navigating via step indicator + parentWindow.iTwinsSearchText = ""; + parentWindow.iModelsSearchText = ""; + parentWindow.Repaint(); + } + } else { + EditorGUILayout.LabelField("1. Authentication", step1Style); + } + + EditorGUILayout.LabelField("→", step1Style, GUILayout.Width(15)); + + // Step 2: Select Data - Clickable if we're past this step + GUIStyle step2Style = new GUIStyle(BentleyUIStyles.stepIndicatorStyle); + step2Style.richText = true; + if (currentStepLevel > 2) { + string step2Text = "2. Select Data"; + if (GUILayout.Button(step2Text, step2Style, GUILayout.Height(20))) + { + parentWindow.currentState = WorkflowState.SelectITwin; + parentWindow.statusMessage = "Returned to Data Selection."; + // Clear all search text when navigating via step indicator + parentWindow.iTwinsSearchText = ""; + parentWindow.iModelsSearchText = ""; + parentWindow.Repaint(); + } + } else { + string step2Text = currentStepLevel == 2 ? "2. Select Data" : "2. Select Data"; + EditorGUILayout.LabelField(step2Text, step2Style); + } + + EditorGUILayout.LabelField("→", step1Style, GUILayout.Width(15)); + + // Step 3: Export - Never clickable as a navigation option + GUIStyle step3Style = new GUIStyle(BentleyUIStyles.stepIndicatorStyle); + step3Style.richText = true; + string step3Text = currentStepLevel == 3 ? "3. Export" : "3. Export"; + EditorGUILayout.LabelField(step3Text, step3Style); + + EditorGUILayout.EndHorizontal(); + + // Draw a subtle divider + var dividerRect = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(dividerRect, new Color(0.5f, 0.5f, 0.5f, 0.3f)); + EditorGUILayout.Space(8); + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Components/WorkflowStepIndicatorComponent.cs.meta b/Editor/iTwinForUnity/EditorWindows/Components/WorkflowStepIndicatorComponent.cs.meta new file mode 100644 index 00000000..41fff482 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Components/WorkflowStepIndicatorComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb2a7b47617163d469a98a89cde3070a \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Controllers.meta b/Editor/iTwinForUnity/EditorWindows/Controllers.meta new file mode 100644 index 00000000..0d8c6dc8 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Controllers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b415274648f7fa346ac0b4d4cb490f2e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/EditorWindows/Controllers/TilesetsWindowController.cs b/Editor/iTwinForUnity/EditorWindows/Controllers/TilesetsWindowController.cs new file mode 100644 index 00000000..a7fab78e --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Controllers/TilesetsWindowController.cs @@ -0,0 +1,61 @@ +using UnityEngine; +using UnityEditor; + +public class TilesetsWindowController +{ + private BentleyTilesetsWindow parentWindow; + + public TilesetsWindowController(BentleyTilesetsWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Handles the editor update for animation timing + /// + public void OnEditorUpdate() + { + // Update spinner animation + if (Time.realtimeSinceStartup - parentWindow.lastRepaintTime > TilesetConstants.SPINNER_FRAME_RATE) + { + parentWindow.lastRepaintTime = Time.realtimeSinceStartup; + parentWindow.spinnerFrame = (parentWindow.spinnerFrame + 1) % TilesetConstants.SpinnerFrames.Length; + parentWindow.Repaint(); + } + } + + /// + /// Handles keyboard input for the window + /// + public void HandleKeyboardInput() + { + Event e = Event.current; + + if (e.type == EventType.KeyDown) + { + // F5 to refresh + if (e.keyCode == KeyCode.F5) + { + // Refresh through data manager if available + var dataManager = new TilesetDataManager(parentWindow); + dataManager.RefreshSavedViews(); + e.Use(); + } + + // CTRL+F to focus search box + if (e.keyCode == KeyCode.F && e.control) + { + GUI.FocusControl(TilesetConstants.SEARCH_CONTROL_NAME); + e.Use(); + } + } + } + + /// + /// Gets the current spinner frame character + /// + public string GetCurrentSpinnerFrame() + { + return TilesetConstants.SpinnerFrames[parentWindow.spinnerFrame]; + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Controllers/TilesetsWindowController.cs.meta b/Editor/iTwinForUnity/EditorWindows/Controllers/TilesetsWindowController.cs.meta new file mode 100644 index 00000000..864347f4 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Controllers/TilesetsWindowController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4875a4d069c1fff419c9f9f9db63c66a \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/Controllers/WorkflowStateManager.cs b/Editor/iTwinForUnity/EditorWindows/Controllers/WorkflowStateManager.cs new file mode 100644 index 00000000..4c7922e7 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Controllers/WorkflowStateManager.cs @@ -0,0 +1,55 @@ +using UnityEngine; +using UnityEditor; + +public enum WorkflowState +{ + Idle, LoggingIn, LoggedIn, + FetchingITwins, SelectITwin, + FetchingIModels, SelectIModel, + StartingExport, PollingExport, ExportComplete, Error +} + +public class WorkflowStateManager +{ + private BentleyWorkflowEditorWindow parentWindow; + + public WorkflowStateManager(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + // Helper method to determine which step level we're at + public int GetStepLevel(WorkflowState state) + { + if (state <= WorkflowState.LoggedIn) + return 1; // Authentication + else if (state <= WorkflowState.SelectIModel) + return 2; // Select Data + else + return 3; // Export + } + + public MessageType GetMessageType(WorkflowState state) + { + switch (state) + { + case WorkflowState.Idle: + case WorkflowState.LoggingIn: + return MessageType.Info; + case WorkflowState.LoggedIn: + case WorkflowState.FetchingITwins: + case WorkflowState.SelectITwin: + case WorkflowState.FetchingIModels: + case WorkflowState.SelectIModel: + case WorkflowState.StartingExport: + case WorkflowState.PollingExport: + return MessageType.Info; + case WorkflowState.ExportComplete: + return MessageType.Info; + case WorkflowState.Error: + return MessageType.Error; + default: + return MessageType.Info; + } + } +} diff --git a/Editor/iTwinForUnity/EditorWindows/Controllers/WorkflowStateManager.cs.meta b/Editor/iTwinForUnity/EditorWindows/Controllers/WorkflowStateManager.cs.meta new file mode 100644 index 00000000..08354087 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/Controllers/WorkflowStateManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2892fca8039ece94cb7c18826c325118 \ No newline at end of file diff --git a/Editor/iTwinForUnity/EditorWindows/README.md b/Editor/iTwinForUnity/EditorWindows/README.md new file mode 100644 index 00000000..4e791fe7 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/README.md @@ -0,0 +1,302 @@ +# Editor Windows + +This directory contains Unity Editor window implementations that provide the main user interface for the iTwin Unity plugin. These windows enable users to browse iTwins, select projects, manage authentication, and export mesh data. + +## Overview + +The Editor Windows layer represents the presentation tier of the application architecture. It provides: +- User-friendly interfaces for complex workflows +- Component-based UI architecture for maintainability +- Responsive design that adapts to different window sizes +- Visual feedback for long-running operations + +## Architecture + +The Editor Windows follow a component-based architecture: +- **Main Windows**: Primary editor windows that host the overall workflow +- **UI Components**: Reusable UI components that handle specific aspects of the interface +- **Controllers**: Manage window state and coordinate between components + +## Directory Structure + +``` +EditorWindows/ +├── README.md # This file - Editor windows overview +├── BentleyWorkflowEditorWindow.cs # Main workflow editor window +├── BentleyTilesetsWindow.cs # Specialized tileset management window +├── Components/ # Reusable UI components +│ ├── AuthenticationComponent.cs # Authentication UI and controls +│ ├── CesiumIntegrationComponent.cs # Cesium-specific integration UI +│ ├── EmptyStateComponent.cs # Empty state and loading displays +│ ├── ExportComponent.cs # Mesh export controls and progress +│ ├── PaginationComponent.cs # Pagination controls for large datasets +│ ├── ProjectBrowserComponent.cs # iTwin and iModel browsing interface +│ ├── SearchComponent.cs # Search functionality for projects +│ ├── TilesetCardRenderer.cs # Individual tileset card rendering +│ ├── WelcomeComponent.cs # Welcome screen and initial setup +│ └── WorkflowStepIndicatorComponent.cs # Visual workflow progress indicator +└── Controllers/ # Window state and workflow management + ├── TilesetsWindowController.cs # Tileset window state management + └── WorkflowStateManager.cs # Main workflow state coordination +``` + +## Main Windows + +### BentleyWorkflowEditorWindow.cs +The primary editor window that provides a complete workflow for: +- User authentication with Bentley's iTwin platform +- Browsing and selecting iTwins and iModels +- Viewing project details and changesets +- Initiating and monitoring mesh exports +- Integrating with Cesium for Unity for 3D visualization + +**Key Features:** +- Step-by-step workflow with visual progress indicators +- Responsive design that adapts to window resizing +- Error handling with user-friendly messages +- Background operation support with progress tracking + +### BentleyTilesetsWindow.cs +A specialized window focused on tileset management: +- Advanced tileset browsing and filtering +- Bulk operations on multiple tilesets +- Detailed tileset metadata viewing +- Integration with the main workflow window + +## UI Components + +### Authentication Components + +#### AuthenticationComponent.cs +**Purpose**: Handles all authentication-related UI elements and user interactions. + +**Features:** +- Client ID configuration and validation +- Login/logout controls with visual feedback +- Token expiration warnings and refresh capabilities +- Secure storage of authentication preferences +- Responsive layout that adapts to authentication status + +**UI Elements:** +- Client ID input field with validation +- Login/logout buttons with appropriate states +- Token expiration display with color-coded warnings +- Loading indicators during authentication processes + +### Project Management Components + +#### ProjectBrowserComponent.cs +**Purpose**: Provides browsing interface for iTwins and iModels with advanced features. + +**Features:** +- Paginated display of large project lists +- Thumbnail loading with performance optimization +- Project details on-demand loading +- Search integration for quick project location +- Responsive card-based layout + +**Performance Optimizations:** +- Lazy loading of thumbnails and details +- UI repaint throttling during bulk operations +- Memory-efficient pagination with configurable page sizes + +#### SearchComponent.cs +**Purpose**: Implements search functionality across iTwins and iModels. + +**Features:** +- Real-time search with debouncing +- Multiple search criteria support +- Search result highlighting +- Integration with pagination for large result sets + +### Workflow Components + +#### WorkflowStepIndicatorComponent.cs +**Purpose**: Provides visual feedback about the current step in the workflow process. + +**Features:** +- Step-by-step progress visualization +- Current step highlighting +- Completion status indicators +- Navigation between completed steps + +#### ExportComponent.cs +**Purpose**: Manages the mesh export process with comprehensive progress tracking. + +**Features:** +- Export configuration controls +- Real-time progress monitoring +- Error handling and retry capabilities +- Export result validation and feedback +- Integration with external export services + +### Utility Components + +#### PaginationComponent.cs +**Purpose**: Provides consistent pagination controls across different data views. + +**Features:** +- Configurable page sizes +- Navigation controls (first, previous, next, last) +- Page number display with current position +- Integration with data loading states + +#### EmptyStateComponent.cs +**Purpose**: Displays appropriate messages and actions when data is not available. + +**Features:** +- Context-specific empty state messages +- Loading state indicators with animated spinners +- Error state displays with recovery actions +- Customizable call-to-action buttons + +#### TilesetCardRenderer.cs +**Purpose**: Renders individual tileset information in a consistent card format. + +**Features:** +- Responsive card layout +- Thumbnail display with loading states +- Metadata display with formatting +- Action buttons for tileset operations + +## Controllers + +### WorkflowStateManager.cs +**Purpose**: Manages the overall state of the main workflow window. + +**Responsibilities:** +- Coordinating state transitions between workflow steps +- Managing communication between UI components +- Handling error states and recovery +- Persisting workflow state across sessions + +### TilesetsWindowController.cs +**Purpose**: Controls the specialized tileset management window. + +**Responsibilities:** +- Managing tileset data loading and filtering +- Coordinating bulk operations +- Handling window-specific state management +- Integration with the main workflow + +## Design Patterns + +### Component-Based Architecture +Each UI component is self-contained and responsible for: +- Its own rendering logic +- User interaction handling +- State management for its specific functionality +- Communication with parent windows through defined interfaces + +### Observer Pattern +Components observe changes in: +- Authentication state +- Data loading states +- User selections +- Error conditions + +### Command Pattern +User actions are encapsulated as commands: +- Authentication requests +- Data refresh operations +- Export initiation +- Navigation between workflow steps + +## Performance Considerations + +### UI Responsiveness +- Long-running operations use Unity coroutines to avoid blocking the UI +- Progress indicators provide feedback during operations +- UI updates are throttled to prevent excessive repaints + +### Memory Management +- Thumbnails and large data sets are loaded on-demand +- Unused UI resources are properly disposed +- Pagination limits memory usage for large datasets + +### Network Efficiency +- API requests are debounced to avoid excessive calls +- Thumbnail loading includes rate limiting +- Error retry logic prevents request flooding + +## Styling and Theming + +### Consistent Visual Design +- Shared style classes ensure consistent appearance +- Icon usage follows Unity Editor conventions +- Color schemes adapt to Unity's light/dark themes +- Responsive layouts work across different window sizes + +### Accessibility +- Keyboard navigation support where applicable +- Screen reader compatible labels and descriptions +- High contrast support for visual elements +- Consistent focus indicators + +## Development Guidelines + +### Adding New Components +1. Identify the single responsibility of the component +2. Define clear interfaces for parent window communication +3. Implement proper state management +4. Add comprehensive error handling +5. Follow the established styling patterns +6. Include XML documentation for all public methods + +### Modifying Existing Components +1. Ensure backward compatibility with parent windows +2. Test across different Unity Editor themes +3. Verify responsive behavior at different window sizes +4. Update documentation for any interface changes + +### Performance Guidelines +- Use Unity's EditorGUILayout efficiently +- Minimize GUI calls in hot paths +- Implement proper resource disposal +- Consider memory implications of UI state + +## Common Patterns + +### Component Initialization +```csharp +public class ComponentName +{ + private ParentWindow parentWindow; + + public ComponentName(ParentWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + public void DrawComponent() + { + // Component rendering logic + } +} +``` + +### Error Handling in UI +```csharp +if (errorState) +{ + EditorGUILayout.HelpBox("User-friendly error message", MessageType.Error); + if (GUILayout.Button("Retry")) + { + // Retry logic + } +} +``` + +### Responsive Layout +```csharp +EditorGUILayout.BeginHorizontal(); +if (parentWindow.position.width > 400) +{ + // Wide layout +} +else +{ + // Compact layout +} +EditorGUILayout.EndHorizontal(); +``` diff --git a/Editor/iTwinForUnity/EditorWindows/README.md.meta b/Editor/iTwinForUnity/EditorWindows/README.md.meta new file mode 100644 index 00000000..803f0f43 --- /dev/null +++ b/Editor/iTwinForUnity/EditorWindows/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5d1d1539d94ac7843be9b63dbb8834b5 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Tilesets.meta b/Editor/iTwinForUnity/Tilesets.meta new file mode 100644 index 00000000..835a8ec8 --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 40b0a44d66ca7bf4198c8663a8dd884b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Tilesets/Management.meta b/Editor/iTwinForUnity/Tilesets/Management.meta new file mode 100644 index 00000000..3e58f9d6 --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets/Management.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 50998993778556446a2c813cfb5b3dc9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Tilesets/Management/TilesetDataManager.cs b/Editor/iTwinForUnity/Tilesets/Management/TilesetDataManager.cs new file mode 100644 index 00000000..9be3e594 --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets/Management/TilesetDataManager.cs @@ -0,0 +1,71 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; +using System.Linq; + +public class TilesetDataManager +{ + private BentleyTilesetsWindow parentWindow; + + public TilesetDataManager(BentleyTilesetsWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Refreshes the list of saved tileset views from the scene + /// + public void RefreshSavedViews() + { + parentWindow.savedViews = Object.FindObjectsByType(FindObjectsSortMode.None).ToList(); + } + + /// + /// Gets filtered views based on search text + /// + public List GetFilteredViews() + { + if (string.IsNullOrEmpty(parentWindow.searchText)) + { + return parentWindow.savedViews; + } + + return parentWindow.savedViews.Where(m => + (m.iModelName?.IndexOf(parentWindow.searchText, System.StringComparison.OrdinalIgnoreCase) >= 0) || + (m.iModelDescription?.IndexOf(parentWindow.searchText, System.StringComparison.OrdinalIgnoreCase) >= 0) + ).ToList(); + } + + /// + /// Gets the display description for a tileset metadata, truncating if necessary + /// + public string GetDisplayDescription(BentleyTilesetMetadata metadata) + { + if (string.IsNullOrEmpty(metadata.iModelDescription)) + { + return "No description available"; + } + + string displayDescription = metadata.iModelDescription; + if (displayDescription.Length > TilesetConstants.MAX_DESCRIPTION_LENGTH) + { + displayDescription = displayDescription.Substring(0, TilesetConstants.MAX_DESCRIPTION_LENGTH - 3) + "..."; + } + + return displayDescription; + } + + /// + /// Gets the display ID for a tileset metadata, truncating if necessary + /// + public string GetDisplayId(BentleyTilesetMetadata metadata) + { + if (string.IsNullOrEmpty(metadata.iModelId)) + { + return "No ID"; + } + + int maxLength = System.Math.Min(metadata.iModelId.Length, TilesetConstants.MAX_ID_DISPLAY_LENGTH); + return $"ID: {metadata.iModelId.Substring(0, maxLength)}..."; + } +} diff --git a/Editor/iTwinForUnity/Tilesets/Management/TilesetDataManager.cs.meta b/Editor/iTwinForUnity/Tilesets/Management/TilesetDataManager.cs.meta new file mode 100644 index 00000000..c20adfe9 --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets/Management/TilesetDataManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 945152f15d394c04c8c1b6022da9618f \ No newline at end of file diff --git a/Editor/iTwinForUnity/Tilesets/Management/TilesetSearchManager.cs b/Editor/iTwinForUnity/Tilesets/Management/TilesetSearchManager.cs new file mode 100644 index 00000000..85d41999 --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets/Management/TilesetSearchManager.cs @@ -0,0 +1,80 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; +using System.Linq; + +public class TilesetSearchManager +{ + private BentleyTilesetsWindow parentWindow; + + public TilesetSearchManager(BentleyTilesetsWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + /// + /// Updates the search text and resets pagination if changed + /// + public void UpdateSearchText(string newSearchText) + { + if (newSearchText != parentWindow.searchText) + { + parentWindow.searchText = newSearchText; + parentWindow.currentPage = 0; // Reset to first page when search changes + } + } + + /// + /// Clears the search text and focus + /// + public void ClearSearch() + { + parentWindow.searchText = ""; + parentWindow.shouldClearSearchFocus = true; + } + + /// + /// Sets focus to the search field + /// + public void FocusSearchField() + { + GUI.FocusControl(TilesetConstants.SEARCH_CONTROL_NAME); + } + + /// + /// Handles the clear search focus logic + /// + public void HandleClearSearchFocus() + { + if (parentWindow.shouldClearSearchFocus) + { + GUI.FocusControl(null); + parentWindow.shouldClearSearchFocus = false; + } + } + + /// + /// Checks if search results are empty + /// + public bool HasSearchResults(List filteredViews) + { + return filteredViews.Count > 0; + } + + /// + /// Gets the appropriate empty state message + /// + public void GetEmptyStateMessage(out string title, out string description) + { + if (parentWindow.savedViews.Count == 0) + { + title = "No Bentley iTwin tilesets found in the scene"; + description = "Import iTwin data using the Bentley Mesh Export tool first."; + } + else + { + title = $"No results matching '{parentWindow.searchText}'"; + description = "Try a different search term or clear the search."; + } + } +} diff --git a/Editor/iTwinForUnity/Tilesets/Management/TilesetSearchManager.cs.meta b/Editor/iTwinForUnity/Tilesets/Management/TilesetSearchManager.cs.meta new file mode 100644 index 00000000..7a8cd929 --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets/Management/TilesetSearchManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b2a5a72def68b5d4ab08f0f9a2db429c \ No newline at end of file diff --git a/Editor/iTwinForUnity/Tilesets/README.md b/Editor/iTwinForUnity/Tilesets/README.md new file mode 100644 index 00000000..a36d12f3 --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets/README.md @@ -0,0 +1,238 @@ +# Tileset Management System + +This directory contains components specifically designed for managing 3D tilesets within the Unity environment, including metadata handling, scene operations, and integration with Cesium for Unity. + +## Overview + +The Tileset Management System provides: +- Metadata management for 3D tilesets +- Custom Unity Editor interfaces for tileset configuration +- Scene integration and GameObject management +- Integration with Cesium for Unity for 3D visualization +- Persistence and serialization of tileset settings + +## Architecture + +The tileset system follows Unity's ScriptableObject pattern for persistent data management: +- **Metadata Components**: Store tileset configuration and properties +- **Editor Components**: Provide Unity Editor interfaces for tileset management +- **Management Components**: Handle tileset lifecycle and operations + +## Directory Structure + +``` +Tilesets/ +├── README.md # This file - Tileset system overview +├── BentleyTilesetMetadata.cs # Core tileset metadata ScriptableObject +├── BentleyTilesetMetadataEditor.cs # Custom Unity Editor for tileset metadata +└── Management/ # Tileset management and operations + └── [Future tileset management components] +``` + +## Core Components + +### BentleyTilesetMetadata.cs +**Purpose**: ScriptableObject that stores persistent metadata and configuration for individual tilesets. + +**Key Properties:** +- Tileset identification and source information +- Display configuration (name, description, visibility settings) +- Cesium integration parameters +- Performance and rendering settings +- Export and import configuration + +**Features:** +- Serializable for Unity asset system integration +- Validation of configuration parameters +- Default value management +- Integration with Unity's asset reference system + +**Usage Pattern:** +```csharp +// Creating a new tileset metadata asset +var metadata = CreateInstance(); +metadata.tilesetUrl = "https://example.com/tileset.json"; +metadata.displayName = "My Tileset"; +AssetDatabase.CreateAsset(metadata, "Assets/TilesetMetadata/MyTileset.asset"); +``` + +### BentleyTilesetMetadataEditor.cs +**Purpose**: Custom Unity Editor inspector that provides a user-friendly interface for configuring tileset metadata. + +**Features:** +- Intuitive property editing with validation +- Real-time preview of configuration changes +- Integration with Unity's undo system +- Help text and documentation for configuration options +- Visual indicators for required and optional properties + +**Key Editor Sections:** +- Basic tileset information (URL, name, description) +- Display and rendering settings +- Cesium integration configuration +- Performance optimization parameters +- Advanced settings and debugging options + +**UI Enhancements:** +- Custom property drawers for complex data types +- Validation feedback with error and warning messages +- Contextual help and tooltips +- Organized property groups with foldout sections + +## Integration Points + +### With Cesium for Unity +The tileset system integrates seamlessly with Cesium for Unity: +- Automatic creation of Cesium3DTileset components +- Configuration of Cesium-specific properties +- Handling of coordinate system transformations +- Performance optimization settings for large tilesets + +### With Authentication System +Tilesets may require authenticated access: +- Integration with Bentley authentication for private tilesets +- Automatic token refresh for long-running visualizations +- Secure handling of authentication headers for tileset requests + +### With Editor Windows +Tileset management integrates with the main workflow: +- Selection and configuration from the main workflow window +- Bulk operations on multiple tilesets +- Import/export functionality for tileset configurations + +## Data Management + +### Persistence +Tileset metadata is persisted using Unity's asset system: +- ScriptableObject assets store configuration data +- Version control friendly text-based serialization +- Integration with Unity's asset reference system +- Support for asset bundles and builds + +### Validation +Comprehensive validation ensures data integrity: +- URL format validation for tileset endpoints +- Required property checking +- Range validation for numeric parameters +- Dependency validation for related assets + +### Serialization +Custom serialization handles complex data types: +- Coordinate system parameters +- Authentication tokens (securely) +- Performance settings and optimization flags +- Custom property collections + +## Performance Considerations + +### Memory Management +- Lazy loading of tileset data +- Proper disposal of Unity resources +- Efficient caching of frequently accessed data +- Memory pool usage for temporary objects + +### Rendering Optimization +- Level-of-detail (LOD) configuration +- Culling and visibility optimization +- Texture and material management +- GPU memory usage monitoring + +### Loading Performance +- Asynchronous tileset loading +- Progress tracking and user feedback +- Error handling and recovery +- Bandwidth optimization for large tilesets + +## Development Guidelines + +### Adding New Metadata Properties +1. Add property to BentleyTilesetMetadata with appropriate attributes +2. Update BentleyTilesetMetadataEditor to include UI for the new property +3. Add validation logic if required +4. Update documentation and tooltips +5. Test serialization and version compatibility + +### Custom Property Drawers +When adding complex property types: +1. Create custom PropertyDrawer classes +2. Provide intuitive UI for data input +3. Include validation and error feedback +4. Support Unity's undo system +5. Test across different Unity Editor themes + +### Editor Integration +For new editor functionality: +1. Follow Unity's EditorWindow patterns +2. Provide proper undo/redo support +3. Handle asset modification detection +4. Include comprehensive error handling +5. Test with large numbers of tileset assets + +## Common Patterns + +### Metadata Creation Pattern +```csharp +[MenuItem("Assets/Create/Bentley/Tileset Metadata")] +public static void CreateTilesetMetadata() +{ + var metadata = CreateInstance(); + metadata.Initialize(); // Set default values + + string path = AssetDatabase.GetAssetPath(Selection.activeObject); + if (string.IsNullOrEmpty(path)) + path = "Assets"; + + string assetPath = AssetDatabase.GenerateUniqueAssetPath($"{path}/New Tileset.asset"); + AssetDatabase.CreateAsset(metadata, assetPath); + AssetDatabase.SaveAssets(); + + Selection.activeObject = metadata; +} +``` + +### Validation Pattern +```csharp +public bool ValidateConfiguration(out List errors) +{ + errors = new List(); + + if (string.IsNullOrEmpty(tilesetUrl)) + errors.Add("Tileset URL is required"); + + if (!Uri.IsWellFormedUriString(tilesetUrl, UriKind.Absolute)) + errors.Add("Tileset URL must be a valid absolute URL"); + + return errors.Count == 0; +} +``` + +### Editor Property Layout Pattern +```csharp +public override void OnInspectorGUI() +{ + serializedObject.Update(); + + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.LabelField("Basic Settings", EditorStyles.boldLabel); + + EditorGUILayout.PropertyField(serializedObject.FindProperty("tilesetUrl")); + EditorGUILayout.PropertyField(serializedObject.FindProperty("displayName")); + + EditorGUILayout.EndVertical(); + + if (serializedObject.ApplyModifiedProperties()) + { + // Handle property changes + ValidateAndUpdateUI(); + } +} +``` + +## Future Extensions + +The tileset system is designed for extensibility: +- Support for additional tileset formats beyond Cesium 3D Tiles +- Advanced analytics and performance monitoring +- Batch processing and automation tools +- Integration with external tileset management services +- Custom rendering pipelines and effects diff --git a/Editor/iTwinForUnity/Tilesets/README.md.meta b/Editor/iTwinForUnity/Tilesets/README.md.meta new file mode 100644 index 00000000..2f1a25fc --- /dev/null +++ b/Editor/iTwinForUnity/Tilesets/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 25561a040f8496d44a30818226e5f428 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Utils.meta b/Editor/iTwinForUnity/Utils.meta new file mode 100644 index 00000000..c14cf707 --- /dev/null +++ b/Editor/iTwinForUnity/Utils.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 148065533e6eb604db0aa635b947b7ae +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/iTwinForUnity/Utils/ApiRateLimiter.cs b/Editor/iTwinForUnity/Utils/ApiRateLimiter.cs new file mode 100644 index 00000000..71b9979e --- /dev/null +++ b/Editor/iTwinForUnity/Utils/ApiRateLimiter.cs @@ -0,0 +1,30 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; + +public static class ApiRateLimiter +{ + private static Dictionary lastRequestTimes = new Dictionary(); + private const float MIN_REQUEST_INTERVAL = 2.0f; // Increase to 2 seconds between requests to same endpoint + private static float lastGlobalRequestTime = 0f; + private const float GLOBAL_REQUEST_INTERVAL = 0.5f; // Global rate limiting + + public static bool CanMakeRequest(string endpoint) + { + float currentTime = (float)EditorApplication.timeSinceStartup; + + // Check global rate limiting + if (currentTime - lastGlobalRequestTime < GLOBAL_REQUEST_INTERVAL) + return false; + + if (lastRequestTimes.TryGetValue(endpoint, out float lastTime)) + { + if (currentTime - lastTime < MIN_REQUEST_INTERVAL) + return false; + } + + lastRequestTimes[endpoint] = currentTime; + lastGlobalRequestTime = currentTime; + return true; + } +} diff --git a/Editor/iTwinForUnity/Utils/ApiRateLimiter.cs.meta b/Editor/iTwinForUnity/Utils/ApiRateLimiter.cs.meta new file mode 100644 index 00000000..9ca47a5d --- /dev/null +++ b/Editor/iTwinForUnity/Utils/ApiRateLimiter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 398af6e7182eda843ad36c08be21f20a \ No newline at end of file diff --git a/Editor/iTwinForUnity/Utils/BentleyHelperMethods.cs b/Editor/iTwinForUnity/Utils/BentleyHelperMethods.cs new file mode 100644 index 00000000..7285c3c4 --- /dev/null +++ b/Editor/iTwinForUnity/Utils/BentleyHelperMethods.cs @@ -0,0 +1,133 @@ +using UnityEngine; +using System.Collections; +using System; +using System.Linq; +using Unity.EditorCoroutines.Editor; +using CesiumForUnity; + +public class BentleyHelperMethods +{ + private BentleyWorkflowEditorWindow parentWindow; + + public BentleyHelperMethods(BentleyWorkflowEditorWindow parentWindow) + { + this.parentWindow = parentWindow; + } + + // Helper method for creating consistent fallback descriptions + public string GetDisplayDescription(IModel model) + { + // If we have a description, use it + if (!string.IsNullOrEmpty(model.description)) + return model.description; + + // Create a fallback description based on what we know + if (!string.IsNullOrEmpty(model.displayName)) + return $"iModel created for {model.displayName}"; + + // Last resort fallback + return $"iModel {model.id.Substring(0, Math.Min(8, model.id.Length))}..."; + } + + // Add this public method to the BentleyWorkflowEditorWindow class + public string GetAuthToken() + { + if (parentWindow.authManager != null) + { + string token = parentWindow.authManager.GetCurrentAccessToken(); + // Always save token to EditorPrefs when requested + if (!string.IsNullOrEmpty(token)) + { + UnityEditor.EditorPrefs.SetString("Bentley_Auth_Token", token); + return token; + } + } + return null; + } + + // Add this method to your BentleyWorkflowEditorWindow class + public void UpdateMetadataWithThumbnail(BentleyTilesetMetadata metadata, ITwin selectedITwin, IModel selectedIModel) + { + // Check if thumbnail is loaded now + if (selectedIModel != null && selectedIModel.thumbnailLoaded) + { + // Get changeset info + string changesetVersion = ""; + string changesetDescription = ""; + string changesetCreatedDate = ""; + + if (!string.IsNullOrEmpty(parentWindow.changesetId) && selectedIModel?.changesets != null) + { + var changeset = selectedIModel.changesets.FirstOrDefault(cs => cs.id == parentWindow.changesetId); + if (changeset != null) + { + changesetVersion = changeset.version; + changesetDescription = changeset.description; + changesetCreatedDate = changeset.createdDate.ToString("yyyy-MM-dd HH:mm:ss"); + } + } + + // Update the metadata now that we have thumbnails + UnityEditor.Undo.RecordObject(metadata, "Update Bentley Tileset Metadata"); + metadata.SetExtendedMetadata( + parentWindow.selectedITwinId, + selectedITwin?.displayName ?? "", + parentWindow.selectedIModelId, + selectedIModel?.displayName ?? "", + selectedIModel?.description ?? "", + parentWindow.changesetId, + changesetVersion, + changesetDescription, + changesetCreatedDate, + parentWindow.finalTilesetUrl, + selectedITwin?.thumbnail, + selectedIModel?.thumbnail + ); + + UnityEditor.EditorUtility.SetDirty(metadata); + } + else + { + // If still not loaded, try again later + UnityEditor.EditorApplication.delayCall += () => UpdateMetadataWithThumbnail(metadata, selectedITwin, selectedIModel); + } + } + + public void StopCurrentCoroutine() + { + if (parentWindow.currentCoroutineHandle != null) + { + EditorCoroutineUtility.StopCoroutine(parentWindow.currentCoroutineHandle); + parentWindow.currentCoroutineHandle = null; + if (parentWindow.currentState != WorkflowState.LoggedIn && parentWindow.currentState != WorkflowState.ExportComplete && parentWindow.currentState != WorkflowState.Error) + { + parentWindow.UpdateLoginState(); + parentWindow.statusMessage = "Operation cancelled by user."; + } + parentWindow.Repaint(); + } + } + + // Add this helper method to delay placing the origin + public IEnumerator DelayedPlaceOrigin(GameObject selectedObject) + { + // Wait for two frames to ensure SceneView has fully updated + yield return null; + yield return null; + + // Find CesiumGeoreference in parent hierarchy of the selected object + Transform current = selectedObject.transform; + CesiumForUnity.CesiumGeoreference georeference = null; + while (current != null && georeference == null) + { + georeference = current.GetComponent(); + if (georeference == null) + current = current.parent; + } + + if (georeference != null) + { + CesiumForUnity.CesiumEditorUtility.PlaceGeoreferenceAtCameraPosition(georeference); + } + } +} diff --git a/Editor/iTwinForUnity/Utils/BentleyHelperMethods.cs.meta b/Editor/iTwinForUnity/Utils/BentleyHelperMethods.cs.meta new file mode 100644 index 00000000..1e8e2795 --- /dev/null +++ b/Editor/iTwinForUnity/Utils/BentleyHelperMethods.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 328e42f2f0c235f4488a4135bd25018f \ No newline at end of file diff --git a/Editor/iTwinForUnity/Utils/BentleyUIUtils.cs b/Editor/iTwinForUnity/Utils/BentleyUIUtils.cs new file mode 100644 index 00000000..cb3fe4fe --- /dev/null +++ b/Editor/iTwinForUnity/Utils/BentleyUIUtils.cs @@ -0,0 +1,180 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; + +public static class BentleyUIStyles +{ + public static GUIStyle sectionHeaderStyle; + public static GUIStyle subheaderStyle; + public static GUIStyle foldoutStyle; + public static GUIStyle richTextLabelStyle; + public static GUIStyle helpBoxStyle; + public static GUIStyle cardStyle; + public static GUIStyle itemTitleStyle; + public static GUIStyle itemDescriptionStyle; + public static GUIStyle buttonRightAlignStyle; + public static GUIStyle selectionCardStyle; + public static GUIStyle headerDividerStyle; + public static GUIStyle stepIndicatorStyle; + public static GUIStyle searchBoxStyle; + + static BentleyUIStyles() + { + sectionHeaderStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 16, + margin = new RectOffset(0, 0, 10, 6), + fontStyle = FontStyle.Bold + }; + + subheaderStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14, + margin = new RectOffset(0, 0, 8, 4), + alignment = TextAnchor.MiddleCenter // Add this line + }; + + foldoutStyle = new GUIStyle(EditorStyles.foldout) + { + fontStyle = FontStyle.Bold, + fontSize = 13, + margin = new RectOffset(0, 0, 5, 5) + }; + + richTextLabelStyle = new GUIStyle(EditorStyles.label) + { + richText = true, + wordWrap = true, + padding = new RectOffset(5, 5, 5, 5) + }; + + helpBoxStyle = new GUIStyle(EditorStyles.helpBox) + { + padding = new RectOffset(15, 15, 15, 15), + margin = new RectOffset(0, 0, 10, 10) + }; + + cardStyle = new GUIStyle(EditorStyles.helpBox) + { + padding = new RectOffset(12, 12, 12, 12), + margin = new RectOffset(0, 0, 5, 5) + }; + + itemTitleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 13, + margin = new RectOffset(0, 0, 0, 2) + }; + + itemDescriptionStyle = new GUIStyle(EditorStyles.wordWrappedLabel) + { + fontSize = 11, + fontStyle = FontStyle.Normal, + padding = new RectOffset(2, 2, 0, 5) + }; + + buttonRightAlignStyle = new GUIStyle(GUI.skin.button) + { + alignment = TextAnchor.MiddleRight + }; + + selectionCardStyle = new GUIStyle(EditorStyles.helpBox) + { + padding = new RectOffset(12, 12, 12, 12), + margin = new RectOffset(0, 0, 8, 8) + }; + + headerDividerStyle = new GUIStyle() + { + normal = { background = EditorGUIUtility.whiteTexture }, + margin = new RectOffset(0, 0, 4, 12), + fixedHeight = 1 + }; + + stepIndicatorStyle = new GUIStyle(EditorStyles.label) + { + fontSize = 11, + alignment = TextAnchor.MiddleLeft, + normal = { textColor = new Color(0.5f, 0.5f, 0.5f) } + }; + + searchBoxStyle = new GUIStyle(EditorStyles.textField) + { + margin = new RectOffset(0, 0, 5, 5), + padding = new RectOffset(5, 20, 2, 2), // Extra padding for icon + }; + } +} + +public static class BentleyIconHelper +{ + // --- Icon Helper --- + public static GUIContent GetIconContent(string text, string iconName, string fallbackTooltip = null) + { + Texture icon = EditorGUIUtility.IconContent(iconName)?.image; + if (icon != null) + { + return new GUIContent(text, icon, fallbackTooltip ?? text); + } + // Fallback to text-only if icon not found + return new GUIContent(text, fallbackTooltip ?? text); + } + + public static GUIContent GetIconOnlyContent(string iconName, string tooltip) + { + Texture icon = EditorGUIUtility.IconContent(iconName)?.image; + if (icon != null) + { + return new GUIContent(icon, tooltip); + } + // Fallback to a simple text representation or empty if icon is crucial + return new GUIContent("?", tooltip); // Placeholder if icon fails + } +} + +public static class BentleyPaginationHelper +{ + // Returns list of page numbers to display (with -1 representing ellipsis) + public static List GetPaginationNumbers(int currentPage, int totalPages) + { + List pages = new List(); + + if (totalPages <= 5) + { + for (int i = 0; i < totalPages; i++) + pages.Add(i); + return pages; + } + + if (currentPage == 0) + { + pages.AddRange(new int[] { 0, 1, 2, -1, totalPages - 1 }); + } + else if (currentPage == 1) + { + pages.AddRange(new int[] { 0, 1, 2, 3, -1, totalPages - 1 }); + } + else if (currentPage == 2) + { + pages.AddRange(new int[] { 0, 1, 2, 3, 4, -1, totalPages - 1 }); + } + else if (currentPage > 2 && currentPage < totalPages - 3) + { + pages.AddRange(new int[] { 0, -1, currentPage - 1, currentPage, currentPage + 1, -1, totalPages - 1 }); + } + else if (currentPage == totalPages - 3) + { + pages.AddRange(new int[] { 0, -1, totalPages - 5, totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1 }); + } + else if (currentPage == totalPages - 2) + { + pages.AddRange(new int[] { 0, -1, totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1 }); + } + else if (currentPage == totalPages - 1) + { + pages.AddRange(new int[] { 0, -1, totalPages - 3, totalPages - 2, totalPages - 1 }); + } + + return pages; + } +} diff --git a/Editor/iTwinForUnity/Utils/BentleyUIUtils.cs.meta b/Editor/iTwinForUnity/Utils/BentleyUIUtils.cs.meta new file mode 100644 index 00000000..3b9b449d --- /dev/null +++ b/Editor/iTwinForUnity/Utils/BentleyUIUtils.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5381ffc95d16ad842b07cc10953b9231 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Utils/MainThreadSynchronizer.cs b/Editor/iTwinForUnity/Utils/MainThreadSynchronizer.cs new file mode 100644 index 00000000..73afe313 --- /dev/null +++ b/Editor/iTwinForUnity/Utils/MainThreadSynchronizer.cs @@ -0,0 +1,137 @@ +using System; +using System.Threading.Tasks; +using UnityEditor; + +/// +/// Handles synchronization of operations back to Unity's main thread +/// +public class MainThreadSynchronizer +{ + /// + /// Executes an action on Unity's main thread + /// + public async Task ExecuteOnMainThread(Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + + var completionSource = new TaskCompletionSource(); + + // Use Unity's EditorApplication.update to execute on main thread + EditorApplication.CallbackFunction callback = null; + callback = () => + { + try + { + action(); + completionSource.SetResult(true); + } + catch (Exception ex) + { + completionSource.SetException(ex); + } + finally + { + EditorApplication.update -= callback; + } + }; + + EditorApplication.update += callback; + + await completionSource.Task; + } + + /// + /// Executes a function on Unity's main thread and returns the result + /// + public async Task ExecuteOnMainThread(Func function) + { + if (function == null) + throw new ArgumentNullException(nameof(function)); + + var completionSource = new TaskCompletionSource(); + + EditorApplication.CallbackFunction callback = null; + callback = () => + { + try + { + T result = function(); + completionSource.SetResult(result); + } + catch (Exception ex) + { + completionSource.SetException(ex); + } + finally + { + EditorApplication.update -= callback; + } + }; + + EditorApplication.update += callback; + + return await completionSource.Task; + } + + /// + /// Schedules an action to be executed on the next main thread update + /// + public void ScheduleOnMainThread(Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + + EditorApplication.CallbackFunction callback = null; + callback = () => + { + try + { + action(); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"BentleyAuthManager_Editor: Error executing scheduled action: {ex.Message}"); + } + finally + { + EditorApplication.update -= callback; + } + }; + + EditorApplication.update += callback; + } + + /// + /// Checks if the current thread is Unity's main thread + /// + public bool IsMainThread() + { + return System.Threading.Thread.CurrentThread.ManagedThreadId == 1; + } + + /// + /// Executes an action immediately if on main thread, otherwise schedules it + /// + public void ExecuteOnMainThreadImmediate(Action action) + { + if (action == null) + throw new ArgumentNullException(nameof(action)); + + if (IsMainThread()) + { + try + { + action(); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError($"BentleyAuthManager_Editor: Error executing immediate action: {ex.Message}"); + } + } + else + { + ScheduleOnMainThread(action); + } + } +} diff --git a/Editor/iTwinForUnity/Utils/MainThreadSynchronizer.cs.meta b/Editor/iTwinForUnity/Utils/MainThreadSynchronizer.cs.meta new file mode 100644 index 00000000..ab125011 --- /dev/null +++ b/Editor/iTwinForUnity/Utils/MainThreadSynchronizer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e542594be31a4b94ea1c54f8fefa0778 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Utils/TilesetSceneOperations.cs b/Editor/iTwinForUnity/Utils/TilesetSceneOperations.cs new file mode 100644 index 00000000..b5ecd93a --- /dev/null +++ b/Editor/iTwinForUnity/Utils/TilesetSceneOperations.cs @@ -0,0 +1,80 @@ +using UnityEngine; +using UnityEditor; +using System.Collections; +using CesiumForUnity; +using Unity.EditorCoroutines.Editor; + +public static class TilesetSceneOperations +{ + /// + /// Selects and pings a GameObject in the hierarchy + /// + public static void SelectAndPingObject(GameObject gameObject) + { + Selection.activeGameObject = gameObject; + EditorGUIUtility.PingObject(gameObject); + } + + /// + /// Focuses on a GameObject in the scene view and places the origin + /// + public static void FocusOnObject(GameObject gameObject, EditorWindow editorWindow) + { + // FIRST STEP: Select the object in hierarchy and ensure the selection is processed + Selection.activeGameObject = gameObject; + EditorGUIUtility.PingObject(gameObject); + + // Give Unity a moment to process the selection, then handle the framing and origin placement + EditorApplication.delayCall += () => + { + // SECOND STEP: Frame the object in the scene view + SceneView sceneView = SceneView.lastActiveSceneView; + if (sceneView != null) + { + sceneView.FrameSelected(); + sceneView.Repaint(); + EditorWindow.FocusWindowIfItsOpen(); + } + else + { + SceneView.FrameLastActiveSceneView(); + } + + // THIRD STEP: Wait for framing to complete, then place origin + EditorCoroutineUtility.StartCoroutine(DelayedPlaceOrigin(gameObject), editorWindow); + }; + } + + /// + /// Opens the iTwin Platform web URL for the given metadata + /// + public static void OpenWebUrl(BentleyTilesetMetadata metadata) + { + string url = $"https://developer.bentley.com/my-itwins/{metadata.iTwinId}/{metadata.iModelId}/view"; + Application.OpenURL(url); + } + + /// + /// Delayed coroutine to place the origin after scene view framing + /// + private static IEnumerator DelayedPlaceOrigin(GameObject selectedObject) + { + // Wait for one frame to ensure SceneView has updated + yield return null; + + // Find CesiumGeoreference in parent hierarchy of the selected object + Transform current = selectedObject.transform; + CesiumGeoreference georeference = null; + while (current != null && georeference == null) + { + georeference = current.GetComponent(); + if (georeference == null) + current = current.parent; + } + + if (georeference != null) + { + CesiumEditorUtility.PlaceGeoreferenceAtCameraPosition(georeference); + } + } +} diff --git a/Editor/iTwinForUnity/Utils/TilesetSceneOperations.cs.meta b/Editor/iTwinForUnity/Utils/TilesetSceneOperations.cs.meta new file mode 100644 index 00000000..11fd97c5 --- /dev/null +++ b/Editor/iTwinForUnity/Utils/TilesetSceneOperations.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7253f204fa7df9e4d8375211b90d5691 \ No newline at end of file diff --git a/Editor/iTwinForUnity/Utils/TilesetsUIUtils.cs b/Editor/iTwinForUnity/Utils/TilesetsUIUtils.cs new file mode 100644 index 00000000..1267e89f --- /dev/null +++ b/Editor/iTwinForUnity/Utils/TilesetsUIUtils.cs @@ -0,0 +1,348 @@ +using UnityEngine; +using UnityEditor; +using System.Collections.Generic; + +public static class TilesetsUIStyles +{ + // UI Styles + public static GUIStyle headerStyle; + public static GUIStyle titleStyle; + public static GUIStyle descriptionStyle; + public static GUIStyle searchBoxStyle; + public static GUIStyle cardStyle; + public static GUIStyle buttonStyle; + public static GUIStyle iconButtonStyle; + public static GUIStyle emptyStateStyle; + public static GUIStyle spinnerStyle; + public static GUIStyle breadcrumbStyle; + public static GUIStyle infoLabelStyle; + public static GUIStyle backButtonStyle; + public static GUIStyle footerStyle; + public static GUIStyle dividerStyle; + public static GUIStyle selectionCardStyle; + public static GUIStyle itemTitleStyle; + public static GUIStyle itemDescriptionStyle; + + // Textures + private static Texture2D cardBackgroundTexture; + private static Texture2D cardBackgroundHoverTexture; + private static Texture2D noThumbnailTexture; + private static Texture2D dividerTexture; + + public static void InitializeStyles() + { + if (headerStyle != null) return; + + // Initialize textures first + InitializeTextures(); + + // Initialize icons as well + TilesetsIconHelper.InitializeIcons(); + + // Styles matching the BentleyWorkflowEditorWindow + selectionCardStyle = new GUIStyle(EditorStyles.helpBox) + { + padding = new RectOffset(12, 12, 12, 12), + margin = new RectOffset(0, 0, 8, 8) + }; + + itemTitleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 13, + margin = new RectOffset(0, 0, 0, 2) + }; + + itemDescriptionStyle = new GUIStyle(EditorStyles.wordWrappedLabel) + { + fontSize = 11, + fontStyle = FontStyle.Normal, + padding = new RectOffset(2, 2, 0, 5) + }; + + // Header styles + headerStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 16, + margin = new RectOffset(0, 0, 6, 6), + padding = new RectOffset(10, 10, 6, 6), + normal = { textColor = TilesetConstants.PrimaryTextColor } + }; + + breadcrumbStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14, + margin = new RectOffset(0, 0, 4, 12), + normal = { textColor = TilesetConstants.PrimaryTextColor } + }; + + // Content styles + titleStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 14, + margin = new RectOffset(0, 0, 4, 2), + normal = { textColor = TilesetConstants.PrimaryTextColor } + }; + + descriptionStyle = new GUIStyle(EditorStyles.wordWrappedLabel) + { + fontSize = 12, + wordWrap = true, + margin = new RectOffset(0, 0, 2, 8), + normal = { textColor = TilesetConstants.SecondaryTextColor } + }; + + // Container styles + cardStyle = new GUIStyle(EditorStyles.helpBox) + { + padding = new RectOffset(12, 12, 12, 12), + margin = new RectOffset(0, 0, 0, 12), + normal = { background = cardBackgroundTexture } + }; + + // Control styles + buttonStyle = new GUIStyle(GUI.skin.button) + { + fontSize = 12, + padding = new RectOffset(8, 8, 6, 6), + margin = new RectOffset(2, 2, 0, 0), + normal = { textColor = TilesetConstants.PrimaryTextColor } + }; + + iconButtonStyle = new GUIStyle(GUI.skin.button) + { + padding = new RectOffset(6, 6, 4, 4), + margin = new RectOffset(4, 0, 0, 0), + fixedWidth = 28, + fixedHeight = 24 + }; + + backButtonStyle = new GUIStyle(GUI.skin.button) + { + fontSize = 12, + padding = new RectOffset(8, 12, 6, 6), + margin = new RectOffset(0, 0, 0, 12), + fixedHeight = 26, + normal = { textColor = TilesetConstants.PrimaryTextColor } + }; + + // Increase search box height and padding for larger icon + searchBoxStyle = new GUIStyle(EditorStyles.toolbarSearchField) + { + padding = new RectOffset(30, 6, 4, 4), // Increased left padding for larger icon + margin = new RectOffset(0, 4, 0, 0), + fixedHeight = TilesetConstants.SEARCH_BOX_HEIGHT + }; + + // Status styles + emptyStateStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel) + { + fontSize = 14, + wordWrap = true, + alignment = TextAnchor.MiddleCenter, + normal = { textColor = TilesetConstants.SecondaryTextColor } + }; + + spinnerStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel) + { + fontSize = 24, + alignment = TextAnchor.MiddleCenter, + normal = { textColor = TilesetConstants.AccentColor } + }; + + infoLabelStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontSize = 11, + normal = { textColor = TilesetConstants.SecondaryTextColor } + }; + + footerStyle = new GUIStyle(EditorStyles.miniLabel) + { + fontSize = 10, + normal = { textColor = TilesetConstants.SecondaryTextColor } + }; + + dividerStyle = new GUIStyle() + { + normal = { background = dividerTexture }, + margin = new RectOffset(0, 0, 4, 4), + fixedHeight = 1 + }; + } + + private static void InitializeTextures() + { + if (cardBackgroundTexture == null) + { + cardBackgroundTexture = CreateColorTexture(TilesetConstants.CardBgColor); + cardBackgroundHoverTexture = CreateColorTexture(TilesetConstants.CardBgHoverColor); + noThumbnailTexture = CreateColorTexture(new Color(0.18f, 0.18f, 0.18f)); + dividerTexture = CreateColorTexture(new Color(0.3f, 0.3f, 0.3f)); + } + } + + private static Texture2D CreateColorTexture(Color color) + { + Texture2D tex = new Texture2D(1, 1); + tex.SetPixel(0, 0, color); + tex.Apply(); + return tex; + } + + public static Texture2D GetCardBackgroundTexture() => cardBackgroundTexture; + public static Texture2D GetCardBackgroundHoverTexture() => cardBackgroundHoverTexture; + public static Texture2D GetNoThumbnailTexture() => noThumbnailTexture; + + public static void CleanupTextures() + { + if (cardBackgroundTexture != null) Object.DestroyImmediate(cardBackgroundTexture); + if (cardBackgroundHoverTexture != null) Object.DestroyImmediate(cardBackgroundHoverTexture); + if (noThumbnailTexture != null) Object.DestroyImmediate(noThumbnailTexture); + if (dividerTexture != null) Object.DestroyImmediate(dividerTexture); + + cardBackgroundTexture = null; + cardBackgroundHoverTexture = null; + noThumbnailTexture = null; + dividerTexture = null; + } +} + +public static class TilesetsIconHelper +{ + // Icon cache + private static GUIContent searchIcon; + private static GUIContent clearIcon; + private static GUIContent backIcon; + private static GUIContent hierarchyIcon; + private static GUIContent focusIcon; + private static GUIContent webIcon; + + public static void InitializeIcons() + { + if (searchIcon == null) + { + clearIcon = EditorGUIUtility.IconContent("clear"); + searchIcon = EditorGUIUtility.IconContent("Search Icon"); + backIcon = EditorGUIUtility.IconContent("back"); + hierarchyIcon = EditorGUIUtility.IconContent("UnityEditor.SceneHierarchyWindow"); + focusIcon = EditorGUIUtility.IconContent("SceneViewCamera"); + webIcon = EditorGUIUtility.IconContent("BuildSettings.Web.Small"); + } + } + + public static GUIContent GetSearchIcon() + { + InitializeIcons(); + return searchIcon; + } + + public static GUIContent GetClearIcon() + { + InitializeIcons(); + return clearIcon; + } + + public static GUIContent GetBackIcon() + { + InitializeIcons(); + return backIcon; + } + + public static GUIContent GetHierarchyIcon() + { + InitializeIcons(); + return hierarchyIcon; + } + + public static GUIContent GetFocusIcon() + { + InitializeIcons(); + return focusIcon; + } + + public static GUIContent GetWebIcon() + { + InitializeIcons(); + return webIcon; + } +} + +public static class TilesetsPaginationHelper +{ + // Returns list of page numbers to display (with -1 representing ellipsis) + public static List GetPaginationNumbers(int currentPage, int totalPages) + { + List pages = new List(); + + // For small number of pages, show all pages + if (totalPages <= 5) + { + for (int i = 0; i < totalPages; i++) + pages.Add(i); + return pages; + } + + // Always show first page + pages.Add(0); + + // For first page + if (currentPage == 0) + { + pages.Add(1); // Show page 2 + pages.Add(-1); // Ellipsis + pages.Add(totalPages - 1); // Last page + } + // For second page + else if (currentPage == 1) + { + pages.Add(1); // Page 2 (current) + pages.Add(2); // Page 3 + pages.Add(-1); // Ellipsis + pages.Add(totalPages - 1); // Last page + } + // For third page + else if (currentPage == 2) + { + pages.Add(1); // Page 2 + pages.Add(2); // Page 3 (current) + pages.Add(3); // Page 4 + pages.Add(-1); // Ellipsis + pages.Add(totalPages - 1); // Last page + } + // For middle pages + else if (currentPage > 2 && currentPage < totalPages - 3) + { + pages.Add(-1); // Ellipsis + pages.Add(currentPage - 1); // Previous page + pages.Add(currentPage); // Current page + pages.Add(currentPage + 1); // Next page + pages.Add(-1); // Ellipsis + pages.Add(totalPages - 1); // Last page + } + // For third-to-last page + else if (currentPage == totalPages - 3) + { + pages.Add(-1); // Ellipsis + pages.Add(totalPages - 4); // Page before current + pages.Add(totalPages - 3); // Current page + pages.Add(totalPages - 2); // Page after current + pages.Add(totalPages - 1); // Last page + } + // For second-to-last page + else if (currentPage == totalPages - 2) + { + pages.Add(-1); // Ellipsis + pages.Add(totalPages - 3); // Page before current + pages.Add(totalPages - 2); // Current page + pages.Add(totalPages - 1); // Last page + } + // For last page + else if (currentPage == totalPages - 1) + { + pages.Add(-1); // Ellipsis + pages.Add(totalPages - 2); // Page before current + pages.Add(totalPages - 1); // Current page (last page) + } + + return pages; + } +} diff --git a/Editor/iTwinForUnity/Utils/TilesetsUIUtils.cs.meta b/Editor/iTwinForUnity/Utils/TilesetsUIUtils.cs.meta new file mode 100644 index 00000000..11a81ab2 --- /dev/null +++ b/Editor/iTwinForUnity/Utils/TilesetsUIUtils.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 03e65a19eb8f9ad45ade377ecf0b8084 \ No newline at end of file diff --git a/Runtime/iTwinForUnity.meta b/Runtime/iTwinForUnity.meta new file mode 100644 index 00000000..4ba62339 --- /dev/null +++ b/Runtime/iTwinForUnity.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fbb8948436d4df64bbf39f62c002c1cf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/iTwinForUnity/BentleyTilesetMetadata.cs b/Runtime/iTwinForUnity/BentleyTilesetMetadata.cs new file mode 100644 index 00000000..a873169e --- /dev/null +++ b/Runtime/iTwinForUnity/BentleyTilesetMetadata.cs @@ -0,0 +1,133 @@ +using UnityEngine; +using System; + +/// +/// Stores Bentley iTwin metadata associated with a Cesium3DTileset +/// +[DisallowMultipleComponent] +[RequireComponent(typeof(CesiumForUnity.Cesium3DTileset))] +public class BentleyTilesetMetadata : MonoBehaviour +{ + // Public accessible fields (visible in Inspector) + [SerializeField] private string _iTwinId = string.Empty; + [SerializeField] private string _iModelId = string.Empty; + [SerializeField] private string _iModelName = string.Empty; + [SerializeField] private string _iModelDescription = string.Empty; + [SerializeField] private string _exportDate = string.Empty; + + // Private fields (for internal use) + [SerializeField] private string _changesetId = string.Empty; + [SerializeField] private string _iTwinName = string.Empty; + [SerializeField] private string _changesetVersion = string.Empty; + [SerializeField] private string _changesetDescription = string.Empty; + [SerializeField] private string _changesetCreatedDate = string.Empty; + [SerializeField] private string _exportUrl = string.Empty; + // Thumbnail storage + [SerializeField] private byte[] _iTwinThumbnailBytes = null; + [SerializeField] private byte[] _iModelThumbnailBytes = null; + + // Public properties (accessible to scripts) + /// + /// The ID of the iTwin project this tileset was exported from + /// + public string iTwinId => _iTwinId; + + /// + /// The ID of the iModel this tileset was exported from + /// + public string iModelId => _iModelId; + + /// + /// The name of the iModel this tileset was exported from + /// + public string iModelName => _iModelName; + + /// + /// The description of the iModel this tileset was exported from + /// + public string iModelDescription => _iModelDescription; + + /// + /// The date when this tileset was exported + /// + public string exportDate => _exportDate; + + /// + /// The ID of the changeset used for this export (empty string if latest) + /// + public string changesetId => _changesetId; + + /// + /// Sets basic metadata values + /// + public void SetMetadata(string iTwinId, string iModelId, string iModelName, string iModelDescription, string changesetId = "") + { + _iTwinId = iTwinId; + _iModelId = iModelId; + _iModelName = iModelName; + _iModelDescription = iModelDescription; + _changesetId = changesetId; + _exportDate = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + } + + /// + /// Sets extended metadata including thumbnails and additional details + /// + public void SetExtendedMetadata( + string iTwinId, + string iTwinName, + string iModelId, + string iModelName, + string iModelDescription, + string changesetId, + string changesetVersion, + string changesetDescription, + string changesetCreatedDate, + string exportUrl, + Texture2D iTwinThumbnail = null, + Texture2D iModelThumbnail = null) + { + // Set basic metadata + SetMetadata(iTwinId, iModelId, iModelName, iModelDescription, changesetId); + + // Set extended metadata + _iTwinName = iTwinName ?? string.Empty; + _changesetVersion = changesetVersion ?? string.Empty; + _changesetDescription = changesetDescription ?? string.Empty; + _changesetCreatedDate = changesetCreatedDate ?? string.Empty; + _exportUrl = exportUrl ?? string.Empty; + + // Store thumbnails + if (iTwinThumbnail != null) + _iTwinThumbnailBytes = iTwinThumbnail.EncodeToPNG(); + + if (iModelThumbnail != null) + _iModelThumbnailBytes = iModelThumbnail.EncodeToPNG(); + } + + /// + /// Gets the iTwin thumbnail if available + /// + public Texture2D GetITwinThumbnail() + { + if (_iTwinThumbnailBytes == null || _iTwinThumbnailBytes.Length == 0) + return null; + + var tex = new Texture2D(2, 2); + tex.LoadImage(_iTwinThumbnailBytes); + return tex; + } + + /// + /// Gets the iModel thumbnail if available + /// + public Texture2D GetIModelThumbnail() + { + if (_iModelThumbnailBytes == null || _iModelThumbnailBytes.Length == 0) + return null; + + var tex = new Texture2D(2, 2); + tex.LoadImage(_iModelThumbnailBytes); + return tex; + } +} \ No newline at end of file diff --git a/Runtime/iTwinForUnity/BentleyTilesetMetadata.cs.meta b/Runtime/iTwinForUnity/BentleyTilesetMetadata.cs.meta new file mode 100644 index 00000000..288ae8ac --- /dev/null +++ b/Runtime/iTwinForUnity/BentleyTilesetMetadata.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75101e59a3a44ee4f92cf8111ef5bf0c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 9d9689eab72c8480c90679f4dcf18820, type: 3} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/iTwinForUnity/Constants.meta b/Runtime/iTwinForUnity/Constants.meta new file mode 100644 index 00000000..434c5566 --- /dev/null +++ b/Runtime/iTwinForUnity/Constants.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e6d5fd93768206440b070be5ff049aa3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/iTwinForUnity/Constants/AuthConstants.cs b/Runtime/iTwinForUnity/Constants/AuthConstants.cs new file mode 100644 index 00000000..e8a9ffc3 --- /dev/null +++ b/Runtime/iTwinForUnity/Constants/AuthConstants.cs @@ -0,0 +1,30 @@ +using UnityEngine; + +/// +/// Centralized constants and configuration for Bentley authentication system +/// +public static class AuthConstants +{ + // OAuth Configuration + public const string DEFAULT_REDIRECT_URI = "http://localhost:58789/"; + public const string DEFAULT_SCOPES = "itwin-platform"; + public const string MESH_EXPORT_SCOPE = "mesh-export"; + public const string GENERAL_SCOPE = "itwin-platform"; + public const string AUTHORIZATION_URL = "https://ims.bentley.com/as/authorization.oauth2"; + public const string TOKEN_URL = "https://ims.bentley.com/as/token.oauth2"; + + // EditorPrefs Keys + public const string PREF_ACCESS_TOKEN = "Bentley_Editor_AccessToken"; + public const string PREF_REFRESH_TOKEN = "Bentley_Editor_RefreshToken"; + public const string PREF_EXPIRY = "Bentley_Editor_TokenExpiry"; + public const string PREF_CLIENT_ID = "Bentley_Editor_ClientId"; + public const string PREF_REDIRECT_URI = "Bentley_Editor_RedirectUri"; + + // Timing Constants + public const int TOKEN_EXPIRY_BUFFER_MINUTES = 1; + + // HTTP Response Messages + public const string SUCCESS_RESPONSE_HTML = @"

Authentication Successful

You can close this window now.

"; + + public const string ERROR_RESPONSE_HTML = @"

Authentication Failed

Please close this window and try again.

"; +} diff --git a/Runtime/iTwinForUnity/Constants/AuthConstants.cs.meta b/Runtime/iTwinForUnity/Constants/AuthConstants.cs.meta new file mode 100644 index 00000000..403ae07a --- /dev/null +++ b/Runtime/iTwinForUnity/Constants/AuthConstants.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ec2fe8e0512c8c9418a8be55e3395d07 \ No newline at end of file diff --git a/Runtime/iTwinForUnity/Constants/TilesetConstants.cs b/Runtime/iTwinForUnity/Constants/TilesetConstants.cs new file mode 100644 index 00000000..ec556577 --- /dev/null +++ b/Runtime/iTwinForUnity/Constants/TilesetConstants.cs @@ -0,0 +1,30 @@ +using UnityEngine; + +public static class TilesetConstants +{ + // Color definitions + public static readonly Color HeaderBgColor = new Color(0.15f, 0.15f, 0.15f); + public static readonly Color CardBgColor = new Color(0.22f, 0.22f, 0.22f); + public static readonly Color CardBgHoverColor = new Color(0.26f, 0.26f, 0.26f); + public static readonly Color AccentColor = new Color(0.2f, 0.6f, 0.9f); + public static readonly Color PrimaryTextColor = new Color(0.9f, 0.9f, 0.9f); + public static readonly Color SecondaryTextColor = new Color(0.7f, 0.7f, 0.7f); + + // UI dimensions + public const int ITEMS_PER_PAGE = 3; + public const int THUMBNAIL_SIZE = 80; + public const int CARD_PADDING = 12; + public const int SEARCH_BOX_HEIGHT = 28; + public const int BUTTON_HEIGHT = 24; + + // Animation timing + public const float SPINNER_FRAME_RATE = 0.15f; + public const int MAX_DESCRIPTION_LENGTH = 200; + public const int MAX_ID_DISPLAY_LENGTH = 12; + + // Spinner frames + public static readonly string[] SpinnerFrames = new string[] { "◐", "◓", "◑", "◒" }; + + // Search and keyboard shortcuts + public const string SEARCH_CONTROL_NAME = "TilesetSearchField"; +} diff --git a/Runtime/iTwinForUnity/Constants/TilesetConstants.cs.meta b/Runtime/iTwinForUnity/Constants/TilesetConstants.cs.meta new file mode 100644 index 00000000..d9770948 --- /dev/null +++ b/Runtime/iTwinForUnity/Constants/TilesetConstants.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 260ad3528f808b34293c0b69618e7391 \ No newline at end of file diff --git a/Runtime/iTwinForUnity/DataModels.meta b/Runtime/iTwinForUnity/DataModels.meta new file mode 100644 index 00000000..ebd8752d --- /dev/null +++ b/Runtime/iTwinForUnity/DataModels.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2865ce9ce90123f459c12f6db664497f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/iTwinForUnity/DataModels/BentleyDataModels.cs b/Runtime/iTwinForUnity/DataModels/BentleyDataModels.cs new file mode 100644 index 00000000..8c37c851 --- /dev/null +++ b/Runtime/iTwinForUnity/DataModels/BentleyDataModels.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +/// +/// Represents an iTwin project from Bentley's iTwin platform. +/// An iTwin is a digital twin infrastructure project that can contain multiple iModels. +/// +[Serializable] +public class ITwin +{ + /// + /// Unique identifier for the iTwin project + /// + public string id; + + /// + /// Human-readable display name of the iTwin project + /// + public string displayName; + + /// + /// Thumbnail image for the iTwin project (loaded asynchronously) + /// + [NonSerialized] public Texture2D thumbnail; + + /// + /// Indicates whether the thumbnail is currently being loaded + /// + [NonSerialized] public bool loadingThumbnail; + + /// + /// Flag to track if thumbnail loading has been attempted (regardless of success) + /// + [NonSerialized] public bool thumbnailLoaded = false; +} + +/// +/// Represents an iModel within an iTwin project. +/// An iModel is a specific data model that contains 3D data and can have multiple changesets. +/// +[Serializable] +public class IModel +{ + /// + /// Unique identifier for the iModel + /// + public string id; + + /// + /// Human-readable display name of the iModel + /// + public string displayName; + + /// + /// Detailed description of the iModel (loaded asynchronously) + /// + public string description; + + /// + /// Thumbnail image for the iModel (loaded asynchronously) + /// + [NonSerialized] public Texture2D thumbnail; + + /// + /// Indicates whether the thumbnail is currently being loaded + /// + [NonSerialized] public bool loadingThumbnail; + + /// + /// Indicates whether the detailed information is currently being loaded + /// + [NonSerialized] public bool loadingDetails; + + /// + /// Flag to track if detail loading has been attempted (regardless of success) + /// + [NonSerialized] public bool detailsLoaded = false; + + /// + /// Flag to track if thumbnail loading has been attempted (regardless of success) + /// + [NonSerialized] public bool thumbnailLoaded = false; + + /// + /// Indicates whether the changesets are currently being loaded + /// + [NonSerialized] public bool loadingChangesets; + + /// + /// Collection of changesets associated with this iModel (loaded asynchronously) + /// + [NonSerialized] public List changesets = new List(); + + /// + /// Index of the currently selected changeset (0 = latest/newest) + /// + [NonSerialized] public int selectedChangesetIndex = 0; +} + +/// +/// Represents a changeset within an iModel. +/// A changeset is a version of the iModel data at a specific point in time. +/// +[Serializable] +public class ChangeSet +{ + /// + /// Unique identifier for the changeset + /// + public string id; + + /// + /// Description of the changes made in this changeset + /// + public string description; + + /// + /// Version string identifying this changeset + /// + public string version; + + /// + /// Date and time when this changeset was created + /// + public DateTime createdDate; +} + +/// +/// Response wrapper for API calls that return multiple iTwin projects +/// +[Serializable] +public class ITwinsResponse +{ + /// + /// Collection of iTwin projects returned by the API + /// + public List iTwins; +} + +/// +/// Response wrapper for API calls that return multiple iModels +/// +[Serializable] +public class IModelsResponse +{ + /// + /// Collection of iModels returned by the API + /// + public List iModels; +} + +/// +/// Response wrapper for API calls that return detailed information about a single iModel +/// +[Serializable] +public class IModelDetailsResponse +{ + /// + /// Detailed iModel information + /// + public IModelDetail iModel; + + /// + /// Detailed iModel information structure matching the API response format + /// + [Serializable] + public class IModelDetail + { + /// + /// Unique identifier for the iModel + /// + public string id; + + /// + /// Human-readable display name of the iModel + /// + public string displayName; + + /// + /// Detailed description of the iModel + /// + public string description; + } +} + +/// +/// Response wrapper for API calls that return multiple changesets +/// +[Serializable] +public class ChangeSetsResponse +{ + /// + /// Collection of changesets returned by the API + /// + public List changesets; +} diff --git a/Runtime/iTwinForUnity/DataModels/BentleyDataModels.cs.meta b/Runtime/iTwinForUnity/DataModels/BentleyDataModels.cs.meta new file mode 100644 index 00000000..dfdb7ce1 --- /dev/null +++ b/Runtime/iTwinForUnity/DataModels/BentleyDataModels.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 278b8aa05a867c54b98d9414e6af0b2a \ No newline at end of file diff --git a/Runtime/iTwinForUnity/MeshExport.meta b/Runtime/iTwinForUnity/MeshExport.meta new file mode 100644 index 00000000..5490638d --- /dev/null +++ b/Runtime/iTwinForUnity/MeshExport.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a3d751d80f574f143b4e5db03cdb934c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/iTwinForUnity/MeshExport/MeshExportClient.cs b/Runtime/iTwinForUnity/MeshExport/MeshExportClient.cs new file mode 100644 index 00000000..078bdcdb --- /dev/null +++ b/Runtime/iTwinForUnity/MeshExport/MeshExportClient.cs @@ -0,0 +1,353 @@ +using UnityEngine; +using UnityEngine.Networking; +using System; +using System.Collections; +using System.Text; +using Newtonsoft.Json; + +/// +/// Primary client for orchestrating mesh export workflows from Bentley's iTwin platform. +/// Handles export initiation, status monitoring, progress tracking, and file download coordination. +/// All methods return Unity coroutines for asynchronous execution in the Editor environment. +/// +public class MeshExportClient +{ + /// + /// Base URL for Bentley's mesh export API endpoints + /// + private const string BaseUrl = "https://api.bentley.com/mesh-export/"; + + /// + /// Request payload for initiating a new mesh export operation + /// + [Serializable] + public class StartExportRequest + { + /// + /// Unique identifier of the iModel to export + /// + public string iModelId; + + /// + /// Unique identifier of the specific changeset to export + /// + public string changesetId; + + /// + /// Type of export format requested (e.g., "3DTILES", "GLTF") + /// + public string exportType; + } + + /// + /// Response wrapper for export initiation API calls + /// + public class StartExportResponse + { + /// + /// The created export job details + /// + [JsonProperty("export")] public ExportWrapper Export { get; set; } + } + + /// + /// Response wrapper for export status retrieval API calls + /// + public class GetExportResponse + { + /// + /// The current export job details and status + /// + [JsonProperty("export")] public ExportWrapper Export { get; set; } + } + + /// + /// Represents a mesh export job with its current status and download information + /// + public class ExportWrapper + { + /// + /// Unique identifier for this export job + /// + [JsonProperty("id")] public string Id { get; set; } + + /// + /// Current status of the export (e.g., "Queued", "Processing", "Complete", "Failed") + /// + [JsonProperty("status")] public string Status { get; set; } + + /// + /// Human-readable name for this export job + /// + [JsonProperty("displayName")] public string DisplayName { get; set; } + + /// + /// Navigation links for accessing export resources + /// + [JsonProperty("_links")] public ExportLinks Links { get; set; } + + /// + /// Convenience property to access the mesh download URL + /// + public string Href => Links?.Mesh?.Href; + } + + /// + /// Contains navigation links for export-related resources + /// + public class ExportLinks + { + /// + /// Link to the exported mesh data + /// + [JsonProperty("mesh")] public MeshLink Mesh { get; set; } + } + + /// + /// Contains the URL for downloading mesh data + /// + public class MeshLink + { + /// + /// Direct URL to the mesh file download + /// + [JsonProperty("href")] public string Href { get; set; } + } + + /// + /// Response wrapper for listing existing exports for an iModel + /// + public class ListExportsResponse + { + /// + /// Array of existing export jobs for the requested iModel + /// + [JsonProperty("exports")] + public ExportWrapper[] Exports { get; set; } + } + + /// + /// Initiates a new mesh export job for the specified iModel and changeset. + /// This coroutine sends the export request to Bentley's API and returns the export job details. + /// + /// Valid OAuth access token for API authentication + /// Unique identifier of the iModel to export + /// Unique identifier of the changeset to export + /// Desired export format type + /// Callback invoked with export result or error message + /// Unity coroutine for asynchronous execution + public IEnumerator StartExportCoroutine( + string accessToken, + string iModelId, + string changesetId, + string exportType, + Action callback) + { + var reqObj = new StartExportRequest + { + iModelId = iModelId, + changesetId = changesetId, + exportType = exportType + }; + string json = JsonConvert.SerializeObject(reqObj); + byte[] body = Encoding.UTF8.GetBytes(json); + + using var request = new UnityWebRequest(BaseUrl, "POST") + { + uploadHandler = new UploadHandlerRaw(body), + downloadHandler = new DownloadHandlerBuffer() + }; + + request.SetRequestHeader("Authorization", $"Bearer {accessToken}"); + request.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v1+json"); + request.SetRequestHeader("Content-Type", "application/json"); + + yield return request.SendWebRequest(); // Works in Editor Coroutine + + if (request.result != UnityWebRequest.Result.Success) + { + string error = $"StartExport failed ({request.responseCode}): {request.error}\n{request.downloadHandler?.text}"; + Debug.LogError("MeshExportClient_Editor: " + error); + callback?.Invoke(null, error); + } + else + { + try + { + string responseText = request.downloadHandler.text; + var resp = JsonConvert.DeserializeObject(responseText); + if (resp?.Export == null) { + throw new JsonException("Parsed response or 'export' field is null."); + } + callback?.Invoke(resp.Export, null); + } + catch (Exception ex) + { + string error = $"Failed to parse StartExport response: {ex.Message}\nResponse JSON: {request.downloadHandler?.text}"; + Debug.LogError("MeshExportClient_Editor: " + error); + callback?.Invoke(null, error); + } + } + } + + /// + /// Coroutine to poll the Mesh‑Export API until the export is complete. + /// Called by the EditorWindow using EditorCoroutineUtility. + /// + public IEnumerator GetExportCoroutine( + string accessToken, + string exportId, + Action callback, // Callback parameters: ExportWrapper result, string error + int pollIntervalSeconds = 5) // Increased default interval +{ + string url = BaseUrl + exportId; + int attempts = 0; + + while (true) + { + attempts++; + using var request = UnityWebRequest.Get(url); + request.SetRequestHeader("Authorization", $"Bearer {accessToken}"); + request.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v1+json"); + + yield return request.SendWebRequest(); // Works in Editor Coroutine + + if (request.result != UnityWebRequest.Result.Success) + { + string error = $"GetExport failed ({request.responseCode}): {request.error}\n{request.downloadHandler?.text}"; + Debug.LogError("MeshExportClient_Editor: " + error); + callback?.Invoke(null, error); + yield break; // Exit coroutine on error + } + + try + { + string responseText = request.downloadHandler.text; + + var resp = JsonConvert.DeserializeObject(responseText); + if (resp?.Export == null) { + throw new JsonException("Parsed response or 'export' field is null."); + } + var export = resp.Export; + + if (export.Status.Equals("complete", StringComparison.OrdinalIgnoreCase) || + export.Status.Equals("succeeded", StringComparison.OrdinalIgnoreCase)) // Handle 'succeeded' as well + { + if (string.IsNullOrEmpty(export.Href)) { + string error = "Export completed/succeeded but download href is missing."; + Debug.LogError("MeshExportClient_Editor: " + error + "\nResponse JSON: " + responseText); + callback?.Invoke(null, error); + } else { + callback?.Invoke(export, null); + } + yield break; // Exit coroutine on completion + } + else if (export.Status.Equals("failed", StringComparison.OrdinalIgnoreCase) || + export.Status.Equals("cancelled", StringComparison.OrdinalIgnoreCase)) + { + string error = $"Export job ended with status: {export.Status}"; + Debug.LogError("MeshExportClient_Editor: " + error + "\nResponse JSON: " + responseText); + callback?.Invoke(null, error); + yield break; // Exit coroutine on failure/cancellation + } + // Continue polling for other statuses like 'running', 'created', 'queued' + } + catch (Exception ex) + { + string error = $"Failed to parse GetExport response: {ex.Message}\nResponse JSON: {request.downloadHandler?.text}"; + Debug.LogError("MeshExportClient_Editor: " + error); + callback?.Invoke(null, error); + yield break; // Exit coroutine on parsing error + } + + // Use WaitForSecondsRealtime in Editor Coroutines if precise timing is needed, + // but WaitForSeconds is generally fine here. + yield return new WaitForSeconds(pollIntervalSeconds); + } +} + + /// + /// Coroutine to get or start a mesh export job. + /// Called by the EditorWindow using EditorCoroutineUtility. + /// + public IEnumerator GetOrStartExportCoroutine( + string accessToken, + string iModelId, + string changesetId, + string exportType, + Action callback) + { + // 1. Try to get existing export + string url = $"{BaseUrl}?iModelId={iModelId}&exportType={exportType}"; + if (!string.IsNullOrEmpty(changesetId)) + url += $"&changesetId={changesetId}"; + + bool shouldStartExport = false; + ExportWrapper existingExport = null; + + using (var getRequest = UnityWebRequest.Get(url)) + { + getRequest.SetRequestHeader("Authorization", $"Bearer {accessToken}"); + getRequest.SetRequestHeader("Accept", "application/vnd.bentley.itwin-platform.v1+json"); + + yield return getRequest.SendWebRequest(); + + if (getRequest.result == UnityWebRequest.Result.Success) + { + try + { + var responseText = getRequest.downloadHandler.text; + var listResp = JsonConvert.DeserializeObject(responseText); + if (listResp?.Exports != null && listResp.Exports.Length > 0) + { + existingExport = listResp.Exports[0]; + // If already complete and has href, return it + if ((existingExport.Status.Equals("complete", StringComparison.OrdinalIgnoreCase) || + existingExport.Status.Equals("succeeded", StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrEmpty(existingExport.Href)) + { + callback?.Invoke(existingExport, null); + yield break; + } + } + } + catch (Exception ex) + { + Debug.LogWarning("Failed to parse existing exports: " + ex.Message); + shouldStartExport = true; // Continue to start export + } + } + else + { + shouldStartExport = true; // Continue to start export + } + } + + if (existingExport != null && !shouldStartExport) + { + // Poll until complete + yield return GetExportCoroutine(accessToken, existingExport.Id, callback); + yield break; + } + + // 2. Start new export +#pragma warning disable CS0219 // Variable is assigned but its value is never used + bool done = false; +#pragma warning restore CS0219 // Variable is assigned but its value is never used + ExportWrapper startedExport = null; + string startError = null; + yield return StartExportCoroutine(accessToken, iModelId, changesetId, exportType, (result, error) => + { + startedExport = result; + startError = error; + done = true; + }); + if (!string.IsNullOrEmpty(startError) || startedExport == null) + { + callback?.Invoke(null, startError ?? "Failed to start export."); + yield break; + } + // 3. Poll until complete + yield return GetExportCoroutine(accessToken, startedExport.Id, callback); + } +} \ No newline at end of file diff --git a/Runtime/iTwinForUnity/MeshExport/MeshExportClient.cs.meta b/Runtime/iTwinForUnity/MeshExport/MeshExportClient.cs.meta new file mode 100644 index 00000000..7c304a38 --- /dev/null +++ b/Runtime/iTwinForUnity/MeshExport/MeshExportClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 934e4e0a2ca2b1a46951f83d8b980ca0 \ No newline at end of file diff --git a/Runtime/iTwinForUnity/MeshExport/README.md b/Runtime/iTwinForUnity/MeshExport/README.md new file mode 100644 index 00000000..8bbee844 --- /dev/null +++ b/Runtime/iTwinForUnity/MeshExport/README.md @@ -0,0 +1,295 @@ +# Mesh Export System + +This directory contains components responsible for exporting mesh data from Bentley's iTwin platform, including progress tracking, file management, and integration with the main workflow system. + +## Overview + +The Mesh Export System provides: +- Asynchronous mesh export from iTwin iModels +- Progress tracking and status monitoring +- Error handling and retry capabilities +- File format management and conversion +- Integration with Bentley's mesh export APIs + +## Architecture + +The mesh export system follows an asynchronous, status-based architecture: +- **Export Clients**: Handle communication with Bentley's export APIs +- **Progress Tracking**: Monitor export status and provide user feedback +- **File Management**: Handle downloaded files and format conversion +- **Error Handling**: Robust error recovery and user notification + +## Directory Structure + +``` +MeshExport/ +├── README.md # This file - Mesh export system overview +└── MeshExportClient.cs # Primary mesh export client and workflow orchestrator +``` + +## Core Components + +### MeshExportClient.cs +**Purpose**: Primary client for orchestrating the complete mesh export workflow from initiation to final file download. + +**Key Responsibilities:** +- Initiating mesh export requests with Bentley's APIs +- Monitoring export progress with polling mechanism +- Handling export status transitions (queued, processing, completed, failed) +- Downloading completed export files +- Managing export timeouts and error conditions +- Providing progress callbacks to UI components + +**Export Workflow:** +1. **Export Initiation**: Start export request for specific iModel and changeset +2. **Status Polling**: Regularly check export progress and status +3. **Progress Reporting**: Update UI with current export status and progress +4. **Completion Handling**: Download files when export completes successfully +5. **Error Recovery**: Handle failures with appropriate retry logic + +**Key Methods:** +- `GetOrStartExportCoroutine()`: Checks for existing exports or starts new ones +- `PollExportStatusCoroutine()`: Monitors export progress with configurable intervals +- `HandleExportComplete()`: Processes successful export completion +- `HandleExportError()`: Manages error states and recovery options + +## Export Process Flow + +### 1. Export Request +``` +User initiates export -> Validate parameters -> Send export request -> Receive export ID +``` + +### 2. Status Monitoring +``` +Poll export status -> Check progress -> Update UI -> Continue until complete/failed +``` + +### 3. Completion Handling +``` +Export complete -> Download files -> Validate downloads -> Notify user -> Cleanup +``` + +## Data Models + +### Export Status Types +The system handles various export states: +- **Queued**: Export request accepted and waiting in queue +- **Processing**: Export is actively being processed +- **Completed**: Export finished successfully, files ready for download +- **Failed**: Export encountered an error and could not complete +- **Cancelled**: Export was cancelled by user or system +- **Timeout**: Export exceeded maximum processing time + +### Export Response Models +```csharp +[Serializable] +public class ExportResponse +{ + public string exportId; // Unique identifier for this export + public string status; // Current export status + public int progressPercentage; // Completion percentage (0-100) + public string downloadUrl; // URL for downloading completed files + public DateTime createdDate; // When export was initiated + public DateTime? completedDate; // When export finished (if applicable) + public string errorMessage; // Error description if failed +} +``` + +## API Integration + +### Bentley Mesh Export API +The system integrates with Bentley's mesh export APIs: +- **Export Endpoint**: Initiates new mesh export requests +- **Status Endpoint**: Retrieves current export status and progress +- **Download Endpoint**: Provides access to completed export files +- **List Endpoint**: Retrieves existing exports for an iModel + +### Authentication Requirements +All API calls require proper authentication: +- Bearer token authentication using OAuth 2.0 access tokens +- Automatic token refresh when tokens expire +- Graceful handling of authentication errors +- Secure token storage and transmission + +### Rate Limiting +The system respects API rate limits: +- Configurable polling intervals to avoid excessive requests +- Exponential backoff on rate limit responses (HTTP 429) +- Queue management for multiple concurrent exports +- Priority handling for different export types + +## Error Handling + +### Network Errors +- Connection timeout handling with configurable retry attempts +- HTTP error code interpretation and user-friendly messaging +- Network availability detection and offline graceful degradation + +### Export Errors +- Detailed error message parsing from API responses +- Export failure categorization (temporary vs permanent failures) +- Automatic retry for transient failures +- User notification with actionable error information + +### File Download Errors +- Verification of downloaded file integrity +- Partial download recovery and resume capability +- Disk space validation before download initiation +- File permission and access error handling + +## Performance Optimization + +### Polling Strategy +- Adaptive polling intervals based on export complexity +- Reduced polling frequency for long-running exports +- Immediate status checks for recently initiated exports +- Background polling to avoid blocking UI interactions + +### Memory Management +- Efficient handling of large export files +- Streaming downloads for large datasets +- Proper disposal of temporary resources +- Memory usage monitoring during export operations + +### Concurrency +- Support for multiple simultaneous exports +- Thread-safe status tracking and updates +- Coordinated UI updates from background threads +- Resource sharing and conflict resolution + +## Configuration + +### Timeout Settings +```csharp +public static class ExportConstants +{ + public const int DEFAULT_POLL_INTERVAL_SECONDS = 30; + public const int MAX_EXPORT_TIMEOUT_MINUTES = 60; + public const int RETRY_ATTEMPTS_ON_FAILURE = 3; + public const int EXPONENTIAL_BACKOFF_BASE_SECONDS = 2; +} +``` + +### File Format Support +The system supports various export formats: +- **3D Tiles**: Cesium 3D Tiles format for web visualization +- **glTF**: Standard 3D format for general use +- **OBJ**: Wavefront OBJ format for compatibility +- **Custom**: Bentley-specific formats as needed + +## Integration Points + +### With Authentication System +- Seamless integration with Bentley authentication +- Automatic token refresh during long-running exports +- Proper error handling for authentication failures +- Secure credential management throughout export process + +### With UI Components +- Real-time progress updates to export UI components +- Status change notifications for workflow coordination +- Error reporting with user-friendly messages +- Completion callbacks for UI state updates + +### With File System +- Managed download location selection +- File organization and naming conventions +- Integration with Unity's asset import system +- Cleanup of temporary files and resources + +## Development Guidelines + +### Adding New Export Types +1. Define export type constants and parameters +2. Implement API request formatting for the new type +3. Add status polling logic specific to the export type +4. Update error handling for type-specific failures +5. Test with various iModel sizes and complexity levels + +### Extending Progress Tracking +1. Identify additional progress metrics to track +2. Update API response parsing for new progress data +3. Enhance UI feedback with additional progress information +4. Consider performance impact of increased polling detail + +### Error Recovery Enhancement +1. Analyze common failure patterns +2. Implement specific recovery strategies for each failure type +3. Add user options for manual recovery actions +4. Update error messaging with clear resolution steps + +## Common Patterns + +### Export Initiation Pattern +```csharp +public IEnumerator StartExportCoroutine(string iModelId, string changesetId, Action onComplete) +{ + var exportRequest = new ExportRequest + { + iModelId = iModelId, + changesetId = changesetId, + format = ExportFormat.CesiumTiles + }; + + yield return SendExportRequestCoroutine(exportRequest); + yield return PollExportStatusCoroutine(exportRequest.exportId, onComplete); +} +``` + +### Status Polling Pattern +```csharp +private IEnumerator PollExportStatusCoroutine(string exportId, Action onComplete) +{ + var startTime = DateTime.Now; + + while (DateTime.Now - startTime < TimeSpan.FromMinutes(MAX_EXPORT_TIMEOUT_MINUTES)) + { + var status = yield return GetExportStatusCoroutine(exportId); + + if (status.IsComplete) + { + onComplete?.Invoke(status); + yield break; + } + + yield return new WaitForSeconds(GetPollInterval(status)); + } + + // Handle timeout + onComplete?.Invoke(ExportResult.Timeout); +} +``` + +### Error Handling Pattern +```csharp +private void HandleExportError(ExportError error) +{ + switch (error.Type) + { + case ExportErrorType.Transient: + // Retry with exponential backoff + ScheduleRetry(error.RetryCount); + break; + + case ExportErrorType.Authentication: + // Refresh token and retry + RefreshTokenAndRetry(); + break; + + case ExportErrorType.Permanent: + // Notify user and stop + NotifyUserOfPermanentFailure(error.Message); + break; + } +} +``` + +## Future Enhancements + +The mesh export system is designed for extensibility: +- Support for additional export formats and parameters +- Advanced progress visualization and analytics +- Batch export capabilities for multiple iModels +- Integration with cloud storage services +- Custom post-processing workflows for exported data diff --git a/Runtime/iTwinForUnity/MeshExport/README.md.meta b/Runtime/iTwinForUnity/MeshExport/README.md.meta new file mode 100644 index 00000000..37cc2970 --- /dev/null +++ b/Runtime/iTwinForUnity/MeshExport/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a6169308122490c48802e9260587490a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/iTwinForUnity/README.md b/Runtime/iTwinForUnity/README.md new file mode 100644 index 00000000..9bd4c8c0 --- /dev/null +++ b/Runtime/iTwinForUnity/README.md @@ -0,0 +1,111 @@ +# iTwin Unity Runtime Components + +This document provides a comprehensive overview of the iTwin Unity plugin's runtime architecture and components. + +## Overview + +The iTwin Unity Runtime contains all components that can be used at runtime in Unity, including MonoBehaviours, data models, and utility classes that don't depend on UnityEditor APIs. This assembly is designed to work in both the Unity Editor and in built Unity applications. + +## Architecture Principles + +The Runtime assembly follows several key architectural principles: + +### Runtime-Safe Design +- No dependencies on UnityEditor APIs +- Components work in both Editor and built applications +- Compatible with all Unity build targets + +### Component-Based Design +- Classes are broken down into focused, single-responsibility components +- Each component handles a specific aspect of functionality +- Components communicate through well-defined interfaces + +### Separation of Concerns +- Data models are separate from business logic +- Scene components are separate from utility functions +- Clear boundaries between different functional areas + +### Architectural Layers +- **Data Layer**: Serializable data models and constants +- **Component Layer**: Unity MonoBehaviours and scene components +- **Service Layer**: Runtime-safe utilities and mesh export functionality +- **Infrastructure Layer**: Constants, configurations, and cross-cutting concerns + +## Assembly Structure + +``` +Runtime/ +├── Runtime.asmdef # Assembly definition (references: CesiumRuntime) +├── README.md # This documentation +├── BentleyTilesetMetadata.cs # Runtime MonoBehaviour component +├── Constants/ # Application constants and configurations +│ ├── TilesetConstants.cs # Tileset-related constants +│ └── AuthConstants.cs # Authentication constants +├── DataModels/ # Data transfer objects and models +│ └── BentleyDataModels.cs # Core data models (iTwin, iModel, etc.) +├── MeshExport/ # Runtime mesh export functionality +│ ├── README.md # Mesh export documentation +│ └── MeshExportClient.cs # Runtime-compatible export client +└── Utils/ # Runtime-safe utility classes + └── (Runtime utility methods) # Helper methods for runtime use +``` + +## Key Components + +### BentleyTilesetMetadata +**File**: `BentleyTilesetMetadata.cs` +**Type**: MonoBehaviour Component + +A Unity component that stores iTwin metadata associated with Cesium3DTileset objects. This component: +- Stores iTwin project and iModel information +- Maintains changeset details and export metadata +- Provides inspector-friendly serialized fields +- Integrates with Cesium for Unity runtime + +### Data Models (`/DataModels`) +**Type**: Serializable Data Classes + +Core data structures that represent iTwin platform entities: +- `ITwin`: Represents an iTwin project +- `IModel`: Represents an iModel within an iTwin +- `Changeset`: Represents a version/changeset of an iModel +- Response models for API communication + +### Constants (`/Constants`) +**Type**: Static Configuration Classes + +Application-wide constants and configuration values: +- Authentication endpoints and parameters +- Tileset management constants +- API URLs and default values + +### Mesh Export (`/MeshExport`) +**Type**: Service Classes + +Runtime-compatible mesh export functionality: +- API communication for mesh export requests +- Progress tracking and status monitoring +- URL generation for exported tilesets + +## Assembly Dependencies + +### External Dependencies +- **CesiumRuntime**: Integration with Cesium for Unity's runtime components +- **Unity.Mathematics**: For mathematical operations +- **Newtonsoft.Json**: JSON serialization/deserialization + +### Usage Guidelines +- All classes in this assembly must be runtime-safe +- No `using UnityEditor;` statements allowed +- Components should work in both Editor and built applications +- Use `#if UNITY_EDITOR` guards only for non-essential Editor-specific optimizations + +## Integration with Editor Assembly + +The Runtime assembly is referenced by the Editor assembly (`iTwinForUnity.Editor`) which provides: +- Custom inspectors for Runtime components +- Editor windows and UI functionality +- Authentication and project browsing +- Advanced tileset management tools + +For questions about the runtime architecture or implementation details, refer to the XML documentation comments in the code or consult the Editor assembly documentation for UI-related functionality. diff --git a/Runtime/iTwinForUnity/README.md.meta b/Runtime/iTwinForUnity/README.md.meta new file mode 100644 index 00000000..ea96395c --- /dev/null +++ b/Runtime/iTwinForUnity/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b35d8a502c712644793b31123d9dfdc3 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/package.json b/package.json index 7ab5e15f..18160df2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "com.unity.mathematics": "1.2.0", "com.unity.test-framework": "1.1.31", "com.unity.shadergraph": "12.1.6", - "com.unity.inputsystem": "1.4.2" + "com.unity.inputsystem": "1.4.2", + "com.unity.editorcoroutines": "1.0.0", + "com.unity.nuget.newtonsoft-json": "3.2.1" } } \ No newline at end of file