/// <summary> /// Checks if the asset has the contents of the format to import, fetching them if need be; then imports /// the asset. /// </summary> /// <param name="asset">The asset who's format is being imported.</param> /// <param name="format">The format to import.</param> /// <param name="options">The import options for this asset.</param> /// <param name="callback">The callback to call when this is finished.</param> private void FetchAndImportFormat(PolyAsset asset, PolyFormat format, PolyImportOptions options, PolyApi.ImportCallback callback = null) { if (format.root.contents != null) { // If asset already has the gltf package, proceed directly to importing it. ImportFormat(asset, format, options, callback); } else { // Otherwise, first fetch the package and then import the model. FetchFormatFiles(asset, format.formatType, (PolyAsset resultAsset, PolyStatus status) => { PolyFormat fetchedFormat = resultAsset.GetFormatIfExists(format.formatType); if (fetchedFormat != null) { ImportFormat(asset, fetchedFormat, options, callback); } else { if (callback != null) { callback(asset, new PolyStatusOr <PolyImportResult>( PolyStatus.Error("Could not fetch format files for asset"))); } } }); } }
/// <summary> /// Processes the result of fetching an individual file. /// </summary> /// <param name="state">Indicates the state of the ongoing fetch operation (as set up in FetchObj).</param> /// <param name="index">If ROOT_FILE_INDEX, then this is a result for the main file; else this is a result for /// the resource file with that index.</param> /// <param name="status">The status indicating if the download succeed</param> /// <param name="data">The data that was downloaded.</param> private void ProcessFileFetchResult(FetchOperationState state, int index, PolyStatus status, byte[] data) { if (state.pendingFiles == 0) { // Another request for this format failed, so we ignore any further responses. return; } if (!status.ok) { // This request failed, so we set pendingFiles to 0 so we ignore any further responses, and callback with // an error message. state.pendingFiles = 0; state.completionCallback(state.asset, PolyStatus.Error(status, "Failed to fetch file #{0}", index)); return; } PolyFormat package = state.packageBeingFetched; PolyFile file = index == ROOT_FILE_INDEX ? package.root : package.resources[index]; file.contents = data; --state.pendingFiles; if (state.progressCallback != null) { state.progressCallback(state.asset, 1.0f - ((float)state.pendingFiles / state.totalFiles)); } if (state.pendingFiles <= 0) { // All files done, call callback indicating success. state.completionCallback(state.asset, PolyStatus.Success()); } }
/// <summary> /// Attempts to authenticate using the provided tokens. /// This will NOT launch a sign-in flow. It will use the given tokens directly. /// </summary> /// <param name="accessToken">The access token to use.</param> /// <param name="refreshToken">The refresh token to use.</param> /// <param name="callback">The callback to call when authentication completes.</param> public void Authenticate(string accessToken, string refreshToken, Action <PolyStatus> callback) { if (!instance.IsAuthenticationSupported) { callback(PolyStatus.Error("Authentication is not supported on this platform.")); } oauth2Identity.LoginWithTokens( () => { callback(PolyStatus.Success()); }, () => { callback(PolyStatus.Error("Authentication failed (with tokens).")); }, accessToken, refreshToken); }
/// <summary> /// Attempts to authenticate. /// </summary> /// <param name="interactive">If true, launch the sign in flow (browser) if necessary. If false, /// attempt to authenticate silently.</param> /// <param name="callback">Callback to call when authentication completes.</param> public void Authenticate(bool interactive, Action <PolyStatus> callback) { if (!instance.IsAuthenticationSupported) { callback(PolyStatus.Error("Authentication is not supported on this platform.")); } oauth2Identity.Login( () => { callback(PolyStatus.Success()); }, () => { callback(PolyStatus.Error("Authentication failed.")); }, interactive); }
/// <summary> /// Refreshes an access token, if a given refresh token is valid, and then calls one of the given callbacks. /// </summary> public void Reauthorize(Action <PolyStatus> callback) { if (!instance.IsAuthenticationSupported) { callback(PolyStatus.Error("Authentication is not supported on this platform.")); } CoroutineRunner.StartCoroutine(this, oauth2Identity.Reauthorize( successCallback: () => { callback(PolyStatus.Success()); }, failureCallback: (string error) => { callback(PolyStatus.Error(error)); } )); }
/// <summary> /// As documented in PolyClient.ListUserAssets. /// </summary> public void ListLikedAssets(PolyListLikedAssetsRequest listLikedAssetsRequest, PolyApi.ListAssetsCallback callback) { polyClient.SendRequest(listLikedAssetsRequest, (PolyStatus status, PolyListAssetsResult polyListResult) => { if (status.ok) { ProcessRequestResult(polyListResult, callback); } else { callback(new PolyStatusOr <PolyListAssetsResult>(PolyStatus.Error(status, "Request failed"))); } }, /*maxCacheAge*/ WebRequestManager.CACHE_NONE); }
/// <summary> /// Fetch a specific Poly asset. /// </summary> /// <param name="id">The ID of the sought asset.</param> /// <param name="callback">The callback.</param> public void GetAsset(string id, PolyApi.GetAssetCallback callback) { polyClient.GetAsset(id, (PolyStatus status, PolyAsset result) => { if (status.ok) { callback(new PolyStatusOr <PolyAsset>(result)); } else { callback(new PolyStatusOr <PolyAsset>(PolyStatus.Error(status, "Failed to get asset {0}", id))); } }); }
/// <summary> /// As documented in PolyClient.ListAssets. /// </summary> public void ListAssets(PolyListAssetsRequest listAssetsRequest, PolyApi.ListAssetsCallback callback) { polyClient.SendRequest(listAssetsRequest, (PolyStatus status, PolyListAssetsResult polyListResult) => { if (status.ok) { ProcessRequestResult(polyListResult, callback); } else { callback(new PolyStatusOr <PolyListAssetsResult>(PolyStatus.Error(status, "Request failed"))); } }); }
/// <summary> /// Fetch a specific asset. /// </summary> /// <param name="assetId">The asset to be fetched.</param> /// <param name="callback">A callback to call with the result of the operation.</param> /// <param name="isRecursion"> /// If true, this is a recursive call to this function, and no further retries should be attempted. /// </param> public void GetAsset(string assetId, Action <PolyStatus, PolyAsset> callback, bool isRecursion = false) { // If the user passed in a raw asset ID (no "assets/" prefix), fix it. if (!assetId.StartsWith("assets/")) { assetId = "assets/" + assetId; } PolyMainInternal.Instance.webRequestManager.EnqueueRequest( () => { string url = String.Format("{0}/v1/{1}?key={2}", BASE_URL, assetId, PolyMainInternal.Instance.apiKey); return(GetRequest(url, "text/text")); }, (PolyStatus status, int responseCode, byte[] response) => { if (responseCode == 401 || !status.ok) { if (isRecursion /* || !Authenticator.IsInitialized*/) { callback(PolyStatus.Error("Get asset error ({0})", responseCode), null); return; } //else //{ // Authenticator.Instance.Reauthorize((PolyStatus reauthStatus) => // { // if (reauthStatus.ok) // { // GetAsset(assetId, callback, isRecursion: true); // } // else // { // callback(PolyStatus.Error(reauthStatus, "Failed to reauthenticate to get asset {0}", assetId), null); // } // }); //} } else { string text = Encoding.UTF8.GetString(response); PolyStatus responseStatus = CheckResponseForError(text); if (!responseStatus.ok) { callback(responseStatus, null); return; } PolyMainInternal.Instance.DoBackgroundWork(new ParseAssetBackgroundWork(text, callback)); } }, DEFAULT_QUERY_CACHE_MAX_AGE_MILLIS); }
/// <summary> /// Parses a single asset. /// </summary> public static PolyStatus ParseAsset(JObject asset, out PolyAsset polyAsset) { polyAsset = new PolyAsset(); if (asset["visibility"] == null) { return(PolyStatus.Error("Asset has no visibility set.")); } polyAsset.name = asset["name"].ToString(); polyAsset.authorName = asset["authorName"].ToString(); if (asset["thumbnail"] != null) { var thumbnailElements = asset["thumbnail"].ToObject <JObject>(); //IJEnumerable<JToken> thumbnailElements = asset["thumbnail"].AsJEnumerable(); polyAsset.thumbnail = new PolyFile(thumbnailElements["relativePath"].ToString(), thumbnailElements["url"].ToString(), thumbnailElements["contentType"].ToString()); } if (asset["formats"] == null) { Debug.LogError("No formats found"); } else { foreach (var format in asset["formats"].ToObject <List <JObject> >()) //foreach (JToken format in asset["formats"]) { PolyFormat newFormat = ParseAssetsPackage(format); newFormat.formatType = ParsePolyFormatType(format["formatType"]); if (newFormat.formatType == PolyFormatType.UNKNOWN) { PtDebug.Log("Did not recognize format type: " + format["formatType"].ToString()); } polyAsset.formats.Add(newFormat); } } polyAsset.displayName = asset["displayName"].ToString(); polyAsset.createTime = DateTime.Parse(asset["createTime"].ToString()); polyAsset.updateTime = DateTime.Parse(asset["updateTime"].ToString()); polyAsset.visibility = ParsePolyVisibility(asset["visibility"]); polyAsset.license = ParsePolyAssetLicense(asset["license"]); if (asset["isCurated"] != null) { polyAsset.isCurated = bool.Parse(asset["isCurated"].ToString()); } return(PolyStatus.Success()); }
/// <summary> /// Gets raw file data from Poly given a data URL. /// </summary> /// <param name="dataUrl">Data URL to retrieve from.</param> /// <param name="accessToken">The access token to use for authentication.</param> /// <param name="callback">The callback to call when the download is complete.</param> /// <param name="maxCacheAgeMillis">Maximum age of the cached copy, in millis. See /// WebRequestManager for useful constants.</param> public static void GetRawFileBytes(string dataUrl, string accessToken, long maxCacheAgeMillis, GetRawFileDataBytesCallback callback) { PolyMainInternal.Instance.webRequestManager.EnqueueRequest( () => { return(MakeRawFileGetRequest(dataUrl, accessToken)); }, (PolyStatus status, int responseCode, byte[] response) => { if (!status.ok) { callback(PolyStatus.Error(status, "Failed to get raw file bytes for {0}", dataUrl), null); } else { callback(PolyStatus.Success(), response); } }, maxCacheAgeMillis); }
/// <summary> /// As documented in PolyClient.ListUserAssets. /// </summary> public void ListUserAssets(PolyListUserAssetsRequest listUserAssetsRequest, PolyApi.ListAssetsCallback callback) { // Users expect their own private assets to update quickly once they make a change (one use case // being: I go to Blocks or Tilt Brush, modify my asset, come back to PolyToolkit, I expect it to be updated). // So we don't use caching for these. polyClient.SendRequest(listUserAssetsRequest, (PolyStatus status, PolyListAssetsResult polyListResult) => { if (status.ok) { ProcessRequestResult(polyListResult, callback); } else { callback(new PolyStatusOr <PolyListAssetsResult>(PolyStatus.Error(status, "Request failed"))); } }, /*maxCacheAge*/ WebRequestManager.CACHE_NONE); }
public void Update() { // We process at most one import result per frame, to avoid doing too much work // in the main thread. ImportOperation operation; lock (finishedOperationsLock) { if (finishedOperations.Count == 0) { return; } operation = finishedOperations.Dequeue(); } if (!operation.status.ok) { // Import failed. operation.callback(operation.status, root: null, meshCreator: null); return; } try { IEnumerable meshCreator; ImportGltf.GltfImportResult result = ImportGltf.EndImport(operation.importState, operation.loader, out meshCreator); if (!operation.options.clientThrottledMainThread) { // If we're not in throttled mode, create all the meshes immediately by exhausting // the meshCreator enumeration. Otherwise, it's the caller's responsibility to // do this. foreach (var unused in meshCreator) /* empty */ } { meshCreator = null; } // Success. operation.callback(PolyStatus.Success(), result.root, meshCreator); } catch (Exception ex) { // Import failed. Debug.LogException(ex); operation.callback(PolyStatus.Error("Failed to convert import to Unity objects.", ex), root: null, meshCreator: null); } }
public void FetchFormatFiles(PolyAsset asset, PolyFormatType formatType, PolyApi.FetchFormatFilesCallback completionCallback, FetchProgressCallback progressCallback = null) { PolyUtils.AssertNotNull(asset, "Asset can't be null."); PolyUtils.AssertNotNull(formatType, "formatType can't be null."); PolyFormat packageToFetch = asset.GetFormatIfExists(formatType); if (packageToFetch == null) { if (completionCallback != null) { completionCallback(asset, PolyStatus.Error("Format type not present in asset")); } return; } PolyUtils.AssertNotNull(packageToFetch.root, "packageToFetch.root can't be null."); PolyUtils.AssertNotNull(packageToFetch.root.url, "packageToFetch.root.url can't be null."); PolyUtils.AssertNotNull(packageToFetch.resources, "packageToFetch.resources can't be null."); string accessToken = GetAccessToken(); FetchOperationState state = new FetchOperationState(); state.asset = asset; state.completionCallback = completionCallback; state.progressCallback = progressCallback; state.packageBeingFetched = packageToFetch; // Indicates how many files are pending download (1 for main file + 1 for each resource). state.totalFiles = state.pendingFiles = 1 + packageToFetch.resources.Count; // Note that the callbacks are asynchronous so they may complete in any order. What we do know is that they // will all be called on the main thread, so they won't be called concurrently. long maxCacheAge = asset.IsMutable ? MUTABLE_ASSET_MAX_CACHE_AGE : IMMUTABLE_ASSET_MAX_CACHE_AGE; PolyClientUtils.GetRawFileBytes(packageToFetch.root.url, accessToken, maxCacheAge, (PolyStatus status, byte[] data) => { ProcessFileFetchResult(state, ROOT_FILE_INDEX, status, data); }); for (int i = 0; i < packageToFetch.resources.Count; i++) { int thisIndex = i; // copy of variable, for closure below. PolyClientUtils.GetRawFileBytes(packageToFetch.resources[i].url, accessToken, maxCacheAge, (status, data) => { ProcessFileFetchResult(state, thisIndex, status, data); }); } }
private static void BackgroundThreadProc(object userData) { ImportOperation operation = (ImportOperation)userData; try { using (TextReader reader = new StreamReader(new MemoryStream(operation.format.root.contents), Encoding.UTF8)) { operation.importState = ImportGltf.BeginImport( operation.format.formatType == PolyFormatType.GLTF ? GltfSchemaVersion.GLTF1 : GltfSchemaVersion.GLTF2, reader, operation.loader, operation.options); } } catch (Exception ex) { Debug.LogException(ex); operation.status = PolyStatus.Error("Error importing asset.", ex); } // Done with background thread part, let's queue it so we can finish up on the main thread. operation.instance.EnqueueFinishedOperation(operation); }
/// <summary> /// Processes request results and delivers them to the callback. /// </summary> /// <param name="result">The result.</param> private void ProcessRequestResult(PolyListAssetsResult result, PolyApi.ListAssetsCallback callback) { if (result == null) { callback(new PolyStatusOr <PolyListAssetsResult>(PolyStatus.Error("No request result."))); return; } if (result.assets == null) { // Nothing wrong with the request, there were just no assets that matched those parameters. // Put an empty list in the result. result.assets = new List <PolyAsset>(); } callback(new PolyStatusOr <PolyListAssetsResult>(result)); }
/// <summary> /// Fetches a list of Poly assets together with metadata, using the given request params. /// </summary> /// <param name="request">The request to send; can be either a ListAssetsRequest, a ListUserAssetsRequest, or /// a ListLikedAssetsRequest.</param> /// <param name="callback">The callback to call when the request is complete.</param> /// <param name="maxCacheAge">The maximum cache age to use.</param> /// <param name="isRecursion"> If true, this is a recursive call to this function, and no /// further retries should be attempted.</param> public void SendRequest(PolyRequest request, Action <PolyStatus, PolyListAssetsResult> callback, long maxCacheAge = DEFAULT_QUERY_CACHE_MAX_AGE_MILLIS, bool isRecursion = false) { PolyMainInternal.Instance.webRequestManager.EnqueueRequest( () => { return(GetRequest(MakeSearchUrl(request), "text/text")); }, (PolyStatus status, int responseCode, byte[] response) => { // Retry the request if this was the first failure. The failure may be a server blip, or may indicate // an authentication token has become stale and must be refreshed. if (responseCode == 401 || !status.ok) { if (isRecursion /* || !Authenticator.IsInitialized || !Authenticator.Instance.IsAuthenticated*/) { callback(PolyStatus.Error(status, "Query error ({0})", responseCode), null); return; } //else //{ // Authenticator.Instance.Reauthorize((PolyStatus reauthStatus) => // { // if (reauthStatus.ok) // { // SendRequest(request, callback, maxCacheAge: maxCacheAge, isRecursion: true); // } // else // { // callback(PolyStatus.Error(reauthStatus, "Failed to reauthorize."), null); // } // }); //} } else { string text = Encoding.UTF8.GetString(response); PolyStatus responseStatus = CheckResponseForError(text); if (!responseStatus.ok) { callback(responseStatus, null); return; } PolyMainInternal.Instance.DoBackgroundWork(new ParseAssetsBackgroundWork( text, callback)); } }, maxCacheAge); }
/// <summary> /// Verify if the response can be parsed as json and, if so, that it contains no error token. /// If either conditions are false return a PolyStatusError with relevant information. /// </summary> private PolyStatus CheckResponseForError(string response) { JObject results = new JObject(); try { results = JObject.Parse(response); } catch (Exception ex) { return(PolyStatus.Error("Failed to parse Poly API response, encountered exception: {0}", ex.Message)); } IJEnumerable <JToken> error = results["error"].AsJEnumerable(); if (error == null) { return(PolyStatus.Success()); } return(PolyStatus.Error("{0}: {1}", error["code"] != null ? error["code"].ToString() : "(no error code)", error["message"] != null ? error["message"].ToString() : "(no error message)")); }
/// <summary> /// Starts fetching the thumbnail (in the background). /// </summary> public void Fetch() { if (asset.thumbnail == null || string.IsNullOrEmpty(asset.thumbnail.url)) { // Spoiler alert: if there's no thumbnail URL, our web request will fail, because // the URL is kind of an import part of a web request. // So fail early with a clear error message, rather than make a broken web request. if (callback != null) { callback(asset, PolyStatus.Error("Thumbnail URL not available for asset: {0}", asset)); } return; } // Only use cache if fetching the thumbnail for an immutable asset. long cacheAgeMaxMillis = asset.IsMutable ? 0 : CACHE_MAX_AGE_MILLIS; PolyMainInternal.Instance.webRequestManager.EnqueueRequest(MakeRequest, ProcessResponse, cacheAgeMaxMillis); }
public void Import(PolyAsset asset, PolyImportOptions options, PolyApi.ImportCallback callback = null) { PolyFormat gltfFormat = asset.GetFormatIfExists(PolyFormatType.GLTF); PolyFormat gltf2Format = asset.GetFormatIfExists(PolyFormatType.GLTF_2); if (gltf2Format != null && gltfFormat == null) { FetchAndImportFormat(asset, gltf2Format, options, callback); } else if (gltfFormat != null) { FetchAndImportFormat(asset, gltfFormat, options, callback); } else { callback(asset, new PolyStatusOr <PolyImportResult>( PolyStatus.Error("Neither glTF or glTF_2 format was present in asset"))); } }
public static PolyStatus ParseResponse(byte[] response, out JObject result) { try { result = JObject.Parse(Encoding.UTF8.GetString(response)); JToken errorToken = result["error"]; if (errorToken != null) { IJEnumerable <JToken> error = errorToken.AsJEnumerable(); return(PolyStatus.Error("{0}: {1}", error["code"] != null ? error["code"].ToString() : "(no error code)", error["message"] != null ? error["message"].ToString() : "(no error message)")); } else { return(PolyStatus.Success()); } } catch (Exception ex) { result = null; return(PolyStatus.Error("Failed to parse Poly API response, encountered exception: {0}", ex.Message)); } }
private void ProcessResponse(PolyStatus status, int responseCode, byte[] data) { if (data == null || data.Length <= 0) { status = PolyStatus.Error("Thumbnail data was null or empty."); } if (status.ok) { asset.thumbnailTexture = new Texture2D(1, 1); asset.thumbnailTexture.LoadImage(data); } else { Debug.LogWarningFormat("Failed to fetch thumbnail for asset {0} ({1}): {2}", asset.name, asset.displayName, status); } if (callback != null) { callback(asset, status); } }
// Searches directly by Poly URL. private void SearchByPolyUrl(string polyUrl, OnActorableSearchResult resultCallback, System.Action <bool> onComplete) { string[] parts = polyUrl.Split('/'); string assetId = parts[parts.Length - 1]; PolyApi.GetAsset(assetId, result => { PolyListAssetsResult assetsResult; List <PolyAsset> assetList = new List <PolyAsset>(); if (result.Ok) { // Successfully got the asset. This is good. // Is it acceptably licensed? if (result.Value.license == PolyAssetLicense.CREATIVE_COMMONS_BY) { // Good license. We can use it. assetList.Add(result.Value); assetsResult = new PolyListAssetsResult(PolyStatus.Success(), 1, assetList); } else { // Not CC-By. Can't use it. Debug.LogError("This asset (" + assetId + ") is not licensed by CC-By. Try another asset."); assetsResult = new PolyListAssetsResult(PolyStatus.Error("Asset " + assetId + " is not licensed as CC-By."), 0, assetList); } } else { // Failed to get the asset. This is bad. assetsResult = new PolyListAssetsResult(PolyStatus.Error("Failed to get asset " + assetId), 0, assetList); } PolySearchCallback( new PolyStatusOr <PolyListAssetsResult>(assetsResult), resultCallback, onComplete); }); return; }
/// <summary> /// Co-routine that services one PendingRequest. This method must be called with StartCoroutine. /// </summary> /// <param name="request">The request to service.</param> private IEnumerator HandleWebRequest(PendingRequest request, BufferHolder bufferHolder) { // NOTE: This method runs on the main thread, but never blocks -- the blocking part of the work is // done by yielding the UnityWebRequest, which releases the main thread for other tasks while we // are waiting for the web request to complete (by the miracle of coroutines). // Let the caller create the UnityWebRequest, configuring it as they want. The caller can set the URL, // method, headers, anything they want. The only thing they can't do is call Send(), as we're in charge // of doing that. UnityWebRequest webRequest = request.creationCallback(); PtDebug.LogVerboseFormat("Web request: {0} {1}", webRequest.method, webRequest.url); bool cacheAllowed = cache != null && webRequest.method == "GET" && request.maxAgeMillis != CACHE_NONE; // Check the cache (if it's a GET request and cache is enabled). if (cacheAllowed) { bool cacheHit = false; byte[] cacheData = null; bool cacheReadDone = false; cache.RequestRead(webRequest.url, request.maxAgeMillis, (bool success, byte[] data) => { cacheHit = success; cacheData = data; cacheReadDone = true; }); while (!cacheReadDone) { yield return(null); } if (cacheHit) { PtDebug.LogVerboseFormat("Web request CACHE HIT: {0}, response: {1} bytes", webRequest.url, cacheData.Length); request.completionCallback(PolyStatus.Success(), /* responseCode */ 200, cacheData); // Return the buffer to the pool for reuse. CleanUpAfterWebRequest(bufferHolder); yield break; } else { PtDebug.LogVerboseFormat("Web request CACHE MISS: {0}.", webRequest.url); } } DownloadHandlerBuffer handler = new DownloadHandlerBuffer(); webRequest.downloadHandler = handler; // We need to asset that we actually succeeded in setting the download handler, because this can fail // if, for example, the creation callback mistakenly called Send(). PolyUtils.AssertTrue(webRequest.downloadHandler == handler, "Couldn't set download handler. It's either disposed of, or the creation callback mistakenly called Send()."); // Start the web request. This will suspend this coroutine until the request is done. PtDebug.LogVerboseFormat("Sending web request: {0}", webRequest.url); yield return(UnityCompat.SendWebRequest(webRequest)); // Request is finished. Call user-supplied callback. PtDebug.LogVerboseFormat("Web request finished: {0}, HTTP response code {1}, response: {2}", webRequest.url, webRequest.responseCode, webRequest.downloadHandler.text); PolyStatus status = UnityCompat.IsNetworkError(webRequest) ? PolyStatus.Error(webRequest.error) : PolyStatus.Success(); request.completionCallback(status, (int)webRequest.responseCode, webRequest.downloadHandler.data); // Cache the result, if applicable. if (!UnityCompat.IsNetworkError(webRequest) && cacheAllowed) { byte[] data = webRequest.downloadHandler.data; if (data != null && data.Length > 0) { byte[] copy = new byte[data.Length]; Buffer.BlockCopy(data, 0, copy, 0, data.Length); cache.RequestWrite(webRequest.url, copy); } } // Clean up. webRequest.Dispose(); CleanUpAfterWebRequest(bufferHolder); }