#if UNITY_EDITOR using UnityEngine; using UnityEditor; using UnityEditor.Animations; using UnityEngine.Networking; using UnityEngine.Rendering; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Runtime.CompilerServices; namespace Free3DOnlinePlugin { #region JSON Models [Serializable] public class F3DSearchResp { public List results; public bool hasMore; public int offset; public int total; } [Serializable] public class F3DResult { public string guid; public string title; public string category; public string subcategory; public int type; public string typeLabel; public string previewSmallUrl; public string previewMediumUrl; public string previewLargeUrl; public string modelPageUrl; public float score; } [Serializable] public class F3DFilters { public List categories; public List types; public string taxonomyUrl; } [Serializable] public class F3DFilter { public string label; public string value; public int count; } [Serializable] public class F3DAuthStartReq { public string appSlug; public string appName; public string appVersion; public string pluginFamily; public string deviceName; public string[] requestedScopes; } [Serializable] public class F3DAuthStartResp { public bool ok; public string authSessionId; public string authUrl; public string pollUrl; public string status; } [Serializable] public class F3DAuthPollResp { public bool approved; public string token; public string displayName; public string display_name; public float balanceUsd; public float balance_usd; } [Serializable] public class F3DFormatsResp { public List glbVariants; public List animationGlbVariants; } [Serializable] public class F3DGlbVariant { public string format; public string lod; public string relativePath; public F3DDownloadReq downloadRequest; } [Serializable] public class F3DDownloadReq { public string method; public string url; public F3DDLBody body; } [Serializable] public class F3DDLBody { public string guid; public string format; public string lod; public string variant; public string relativePath; } [Serializable] public class F3DDirectResp { public bool ok; public string downloadUrl; public string fileName; public string relativePath; public float chargedUsd; public float balanceRemainingUsd; } [Serializable] public class F3DUpdateFeed { public string plugin; public string version; public string downloadUrl; public string scriptUrl; public string updateFeedUrl; public string manifestUrl; public string releasedAt; public string[] notes; } #endregion class PendingReq { public UnityWebRequest www; public Action callback; } public class Free3DOnline : EditorWindow { const string VERSION = "v023"; const string BASE = "https://free3d.online"; const string URL_UPDATE_FEED = BASE + "/assets/plugins/unity/update-feed.json"; const string URL_SCRIPT = BASE + "/assets/plugins/unity/free3d_online.cs"; const string URL_SEARCH = BASE + "/api-embeddings/"; const string URL_BROWSE = BASE + "/api-embeddings/browse"; const string URL_FILTERS = BASE + "/api-embeddings/filters"; const string URL_AUTH_START = BASE + "/api/apps/auth/start"; const string URL_AUTH_POLL = BASE + "/api/apps/auth/poll/"; const string URL_FORMATS = BASE + "/api/plugin/download/formats/"; const string URL_DIRECT = BASE + "/api/plugin/download/direct"; const string ROOT = "Assets/free3d_models"; const string D_GLB = ROOT + "/glb"; const string D_TEX = ROOT + "/textures"; const string D_MAT = ROOT + "/materials"; const string D_CTRL = ROOT + "/controllers"; const int NAME_MAX = 50; const int CACHE_MAX = 100; const int PAGE_SIZE = 24; const float DEBOUNCE = 0.3f; const int MAX_PREVIEW_DL = 6; const string PK_TOKEN = "F3D_Token"; const string PK_BAL = "F3D_Balance"; const string PK_SIZE = "F3D_PreviewSize"; const string PK_SCALE = "F3D_Scale"; const string PK_LOD = "F3D_AutoLOD"; const string PK_LOD0 = "F3D_LOD0"; const string PK_LOD1 = "F3D_LOD1"; const string PK_LOD2 = "F3D_LOD2"; // ── state ── string _query = ""; double _lastKey; bool _pendingSearch; Vector2 _scroll; float _prevSize = 128; int _offset; bool _hasMore; int _total; List _results = new(); Dictionary _texCache = new(); LinkedList _cacheOrder = new(); HashSet _loading = new(); int _activePrevDL; Queue _prevQueue = new(); F3DResult _selected; bool _showOpts; string _token; string _authSid; string _authPoll; bool _polling; double _lastPoll; float _balance = -1; float _scale = 1f; bool _autoLOD = true; float _lod0 = 0.5f; float _lod1 = 0.15f; float _lod2 = 0.01f; List _reqs = new(); string _status = ""; bool _importing; float _impProgress; string _impStatus = ""; List _cats = new(); List _types = new(); string[] _catNames; string[] _typeNames; int _selCat; int _selType; string _taxonomyUrl = ""; GUIStyle _sCard, _sTitle, _sBadge; bool _stylesOk; static bool _updateDone; static bool _gltfastChecked; static UnityEditor.PackageManager.Requests.AddRequest _gltfastReq; // ── static ── [MenuItem("Window/Free3D Online")] static void Open() { var w = GetWindow("Free3D Online"); w.minSize = new Vector2(420, 300); w.Show(); } [InitializeOnLoadMethod] static void Boot() { if (_updateDone) return; _updateDone = true; RunUpdateCheck(false); EnsureGltfast(); } static bool IsGltfastAvailable() { foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { string n = asm.GetName().Name; if (n == "glTFast" || n == "GLTFast" || n == "Unity.Cloud.Gltfast") return true; } return false; } static void EnsureGltfast() { if (_gltfastChecked || IsGltfastAvailable()) return; _gltfastChecked = true; Debug.Log("[Free3D] GLTFast not found. Installing com.unity.cloud.gltfast..."); _gltfastReq = UnityEditor.PackageManager.Client.Add("com.unity.cloud.gltfast"); EditorApplication.update += PollGltfastInstall; } static void PollGltfastInstall() { if (_gltfastReq == null || !_gltfastReq.IsCompleted) return; EditorApplication.update -= PollGltfastInstall; if (_gltfastReq.Status == UnityEditor.PackageManager.StatusCode.Success) { Debug.Log("[Free3D] GLTFast installed. Unity will recompile scripts."); EditorUtility.DisplayDialog("Free3D Online", "GLTFast package installed successfully.\nUnity is recompiling. After compilation finishes, you can import models.", "OK"); } else { string err = _gltfastReq.Error?.message ?? "unknown error"; Debug.LogError($"[Free3D] GLTFast install failed: {err}. Trying alternate package name..."); _gltfastReq = UnityEditor.PackageManager.Client.Add("com.atteneder.gltfast"); EditorApplication.update += PollGltfastFallbackInstall; } _gltfastReq = null; } static void PollGltfastFallbackInstall() { if (_gltfastReq == null || !_gltfastReq.IsCompleted) return; EditorApplication.update -= PollGltfastFallbackInstall; if (_gltfastReq.Status == UnityEditor.PackageManager.StatusCode.Success) { Debug.Log("[Free3D] GLTFast (atteneder) installed. Unity will recompile scripts."); EditorUtility.DisplayDialog("Free3D Online", "GLTFast package installed successfully.\nUnity is recompiling. After compilation finishes, you can import models.", "OK"); } else { string err = _gltfastReq.Error?.message ?? "unknown error"; Debug.LogError($"[Free3D] GLTFast install failed: {err}. Please install manually via Package Manager: com.unity.cloud.gltfast"); } _gltfastReq = null; } static int VersionOrdinal(string version) { if (string.IsNullOrEmpty(version)) return 0; var m = Regex.Match(version, @"(\d+)"); return m.Success && int.TryParse(m.Groups[1].Value, out int value) ? value : 0; } static int CompareVersionLabels(string a, string b) => VersionOrdinal(a).CompareTo(VersionOrdinal(b)); static void RunUpdateCheck(bool interactive) { var r = UnityWebRequest.Get(URL_UPDATE_FEED); r.SendWebRequest(); void Poll() { if (!r.isDone) return; EditorApplication.update -= Poll; if (r.result == UnityWebRequest.Result.Success) { try { var f = JsonUtility.FromJson(r.downloadHandler.text); string pluginId = f?.plugin ?? ""; string nextVersion = f?.version ?? ""; string downloadUrl = f?.scriptUrl ?? f?.downloadUrl ?? URL_SCRIPT; if (pluginId != "free3d-unity") { Debug.LogWarning("[Free3D] Update check ignored foreign feed plugin: " + pluginId); if (interactive) EditorUtility.DisplayDialog("Free3D Online", "Update feed does not describe the Unity plugin.", "OK"); } else if (!string.IsNullOrEmpty(nextVersion) && CompareVersionLabels(nextVersion, VERSION) > 0) { if (EditorUtility.DisplayDialog("Free3D Online", $"Update {nextVersion} available (current {VERSION}). Update now?", "Update", "Later")) DownloadSelfUpdate(downloadUrl, nextVersion); } else { Debug.Log($"[Free3D] Update check: already on {VERSION}."); if (interactive) EditorUtility.DisplayDialog("Free3D Online", $"Plugin is already up to date ({VERSION}).", "OK"); } } catch (Exception e) { Debug.LogWarning("[Free3D] Update check: " + e.Message); if (interactive) EditorUtility.DisplayDialog("Free3D Online", "Update feed is invalid. Check Console for details.", "OK"); } } else if (interactive) EditorUtility.DisplayDialog("Free3D Online", $"Update check failed: {r.error}", "OK"); r.Dispose(); } EditorApplication.update += Poll; } static void DownloadSelfUpdate(string sourceUrl, string targetVersion) { string targetUrl = string.IsNullOrEmpty(sourceUrl) ? URL_SCRIPT : sourceUrl; var r = UnityWebRequest.Get(targetUrl); r.SendWebRequest(); void Poll() { if (!r.isDone) return; EditorApplication.update -= Poll; if (r.result == UnityWebRequest.Result.Success) { string scriptPath = ScriptPath(); string tempPath = scriptPath + ".tmp"; string backupPath = scriptPath + ".bak"; File.WriteAllText(tempPath, r.downloadHandler.text, Encoding.UTF8); if (File.Exists(scriptPath)) { File.Copy(scriptPath, backupPath, true); File.Copy(tempPath, scriptPath, true); File.Delete(tempPath); } else { File.Move(tempPath, scriptPath); } AssetDatabase.Refresh(); Debug.Log($"[Free3D] Plugin updated to {targetVersion}. Backup: {backupPath}"); } else Debug.LogError("[Free3D] Update download failed: " + r.error); r.Dispose(); } EditorApplication.update += Poll; } static string ScriptPath([CallerFilePath] string p = "") => p; // ── lifecycle ── void OnEnable() { _token = EditorPrefs.GetString(PK_TOKEN, ""); _balance = EditorPrefs.GetFloat(PK_BAL, -1); _prevSize = EditorPrefs.GetFloat(PK_SIZE, 128); _scale = EditorPrefs.GetFloat(PK_SCALE, 1f); _autoLOD = EditorPrefs.GetBool(PK_LOD, true); _lod0 = EditorPrefs.GetFloat(PK_LOD0, 0.5f); _lod1 = EditorPrefs.GetFloat(PK_LOD1, 0.15f); _lod2 = EditorPrefs.GetFloat(PK_LOD2, 0.01f); EditorApplication.update -= Tick; EditorApplication.update += Tick; LoadFilters(); DoBrowse(); } void OnDisable() { EditorApplication.update -= Tick; foreach (var q in _reqs) { try { q.www?.Abort(); q.www?.Dispose(); } catch {} } _reqs.Clear(); } void Tick() { PumpReqs(); PumpPreviews(); if (_pendingSearch && EditorApplication.timeSinceStartup - _lastKey >= DEBOUNCE) { _pendingSearch = false; DoSearch(); } if (_polling && EditorApplication.timeSinceStartup - _lastPoll >= 2.0) { _lastPoll = EditorApplication.timeSinceStartup; PollAuth(); } } // ── GUI ── void MkStyles() { if (_stylesOk) return; _stylesOk = true; _sCard = new GUIStyle("box") { padding = new RectOffset(4, 4, 4, 4), margin = new RectOffset(2, 2, 2, 2) }; _sTitle = new GUIStyle(EditorStyles.miniLabel) { wordWrap = true, fontSize = 10, alignment = TextAnchor.UpperLeft }; _sBadge = new GUIStyle(EditorStyles.miniLabel) { fontSize = 9, alignment = TextAnchor.MiddleLeft, normal = { textColor = new Color(0.6f, 0.6f, 0.6f) } }; } void OnGUI() { MkStyles(); DrawTopBar(); if (_importing) { DrawProgress(); return; } if (_selected != null) DrawDetail(); else DrawGallery(); } void DrawTopBar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); if (_selected != null && GUILayout.Button("\u25C0 Back", EditorStyles.toolbarButton, GUILayout.Width(56))) { _selected = null; Repaint(); } EditorGUI.BeginChangeCheck(); _query = EditorGUILayout.TextField(_query, EditorStyles.toolbarSearchField); if (EditorGUI.EndChangeCheck()) { _lastKey = EditorApplication.timeSinceStartup; _pendingSearch = true; } if (GUILayout.Button("\u2715", EditorStyles.toolbarButton, GUILayout.Width(22))) { _query = ""; _pendingSearch = false; DoBrowse(); } GUILayout.FlexibleSpace(); if (!string.IsNullOrEmpty(_token)) { if (_balance >= 0) GUILayout.Label($"${_balance:F2}", EditorStyles.toolbarButton); if (GUILayout.Button("Sign Out", EditorStyles.toolbarButton, GUILayout.Width(60))) { _token = ""; EditorPrefs.DeleteKey(PK_TOKEN); EditorPrefs.DeleteKey(PK_BAL); _balance = -1; _status = "Signed out"; Repaint(); } } else { if (GUILayout.Button("Sign In", EditorStyles.toolbarButton, GUILayout.Width(56))) StartAuth(); } if (GUILayout.Button("\u2699", EditorStyles.toolbarButton, GUILayout.Width(24))) _showOpts = !_showOpts; if (GUILayout.Button("\u21BB", EditorStyles.toolbarButton, GUILayout.Width(24))) RunUpdateCheck(true); EditorGUILayout.EndHorizontal(); if (_showOpts) DrawOptions(); if (!string.IsNullOrEmpty(_status)) EditorGUILayout.HelpBox(_status, MessageType.Info); } void DrawGallery() { float avail = EditorGUIUtility.currentViewWidth - 16; int cols = Mathf.Max(1, Mathf.FloorToInt(avail / (_prevSize + 8))); float cw = avail / cols; float th = cw - 10; float ch = th + 36; EditorGUILayout.BeginHorizontal(); GUILayout.Label("Size:", GUILayout.Width(32)); EditorGUI.BeginChangeCheck(); _prevSize = GUILayout.HorizontalSlider(_prevSize, 64, 300, GUILayout.Width(100)); if (EditorGUI.EndChangeCheck()) { EditorPrefs.SetFloat(PK_SIZE, _prevSize); Repaint(); } if (_typeNames != null && _typeNames.Length > 0) { EditorGUI.BeginChangeCheck(); _selType = EditorGUILayout.Popup(_selType, _typeNames, GUILayout.Width(120)); if (EditorGUI.EndChangeCheck()) DoSearch(); } if (_catNames != null && _catNames.Length > 0) { EditorGUI.BeginChangeCheck(); _selCat = EditorGUILayout.Popup(_selCat, _catNames, GUILayout.Width(170)); if (EditorGUI.EndChangeCheck()) DoSearch(); } GUILayout.FlexibleSpace(); GUILayout.Label($"{_total} results", EditorStyles.miniLabel, GUILayout.Width(80)); EditorGUILayout.EndHorizontal(); DrawPaginationBar(); if (string.IsNullOrEmpty(_query)) { EditorGUILayout.LabelField(" Most Popular", EditorStyles.boldLabel); } _scroll = EditorGUILayout.BeginScrollView(_scroll); for (int i = 0; i < _results.Count; i++) { if (i % cols == 0) EditorGUILayout.BeginHorizontal(); DrawCard(_results[i], cw, th, ch); if ((i + 1) % cols == 0 || i == _results.Count - 1) { if ((i + 1) % cols != 0) GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); } } GUILayout.Space(6); DrawPaginationBar(); EditorGUILayout.EndScrollView(); if (Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape) { _query = ""; DoBrowse(); Event.current.Use(); } } void DrawCard(F3DResult r, float w, float thumbH, float cardH) { var rect = GUILayoutUtility.GetRect(w, cardH); bool hover = rect.Contains(Event.current.mousePosition); GUI.Box(rect, GUIContent.none, _sCard); if (hover) EditorGUI.DrawRect(new Rect(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2), new Color(0.3f, 0.5f, 1f, 0.08f)); var tr = new Rect(rect.x + 5, rect.y + 5, rect.width - 10, thumbH - 6); var tex = GetPreview(r.previewMediumUrl); if (tex != null) GUI.DrawTexture(tr, tex, ScaleMode.ScaleToFit); else EditorGUI.DrawRect(tr, new Color(0.14f, 0.14f, 0.14f)); string title = r.title ?? ""; if (title.Length > 34) title = title[..31] + "..."; GUI.Label(new Rect(rect.x + 5, tr.yMax + 1, rect.width - 10, 18), title, _sTitle); GUI.Label(new Rect(rect.x + 5, tr.yMax + 17, rect.width - 10, 14), $"{r.category} \u00B7 {r.typeLabel}", _sBadge); if (Event.current.type == EventType.MouseDown && rect.Contains(Event.current.mousePosition)) { _selected = r; Event.current.Use(); Repaint(); } } void DrawDetail() { EditorGUILayout.Space(6); EditorGUILayout.LabelField(_selected.title, EditorStyles.boldLabel); EditorGUILayout.LabelField($"{_selected.category} \u00B7 {_selected.typeLabel}"); EditorGUILayout.Space(4); var tex = GetPreview(_selected.previewLargeUrl ?? _selected.previewMediumUrl); if (tex != null) { float mw = Mathf.Min(position.width - 32, 420); float mh = mw * tex.height / Mathf.Max(1, tex.width); var r = GUILayoutUtility.GetRect(mw, mh); GUI.DrawTexture(r, tex, ScaleMode.ScaleToFit); } EditorGUILayout.Space(6); if (GUILayout.Button("View on Free3D Online")) { string u = _selected.modelPageUrl ?? ""; if (!u.StartsWith("http")) u = BASE + u; Application.OpenURL(u); } EditorGUILayout.Space(4); bool hasToken = !string.IsNullOrEmpty(_token); GUI.enabled = hasToken && !_importing; if (GUILayout.Button("\u2B07 Import to Scene", GUILayout.Height(34))) StartImport(_selected); GUI.enabled = true; if (!hasToken) { EditorGUILayout.HelpBox("Sign in to download models.", MessageType.Warning); if (GUILayout.Button("Sign In")) StartAuth(); } } void DrawOptions() { EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUI.BeginChangeCheck(); _scale = EditorGUILayout.FloatField("Import Scale", _scale); _autoLOD = EditorGUILayout.Toggle("Auto-create LOD", _autoLOD); if (_autoLOD) { _lod0 = EditorGUILayout.Slider("LOD0 Threshold", _lod0, 0.1f, 1f); _lod1 = EditorGUILayout.Slider("LOD1 Threshold", _lod1, 0.01f, 0.5f); _lod2 = EditorGUILayout.Slider("LOD2 Threshold", _lod2, 0.001f, 0.15f); } if (EditorGUI.EndChangeCheck()) { EditorPrefs.SetFloat(PK_SCALE, _scale); EditorPrefs.SetBool(PK_LOD, _autoLOD); EditorPrefs.SetFloat(PK_LOD0, _lod0); EditorPrefs.SetFloat(PK_LOD1, _lod1); EditorPrefs.SetFloat(PK_LOD2, _lod2); } EditorGUILayout.EndVertical(); } void DrawProgress() { EditorGUILayout.Space(20); EditorGUILayout.LabelField("Importing...", EditorStyles.boldLabel); EditorGUILayout.LabelField(_impStatus); var r = GUILayoutUtility.GetRect(position.width - 32, 20); EditorGUI.ProgressBar(r, _impProgress, $"{Mathf.RoundToInt(_impProgress * 100)}%"); } // ── preview cache ── Texture2D GetPreview(string rawUrl) { if (string.IsNullOrEmpty(rawUrl)) return null; string url = Url(rawUrl); if (_texCache.TryGetValue(url, out var t)) return t; if (!_loading.Contains(url)) { _loading.Add(url); _prevQueue.Enqueue(url); } return null; } void PumpPreviews() { while (_activePrevDL < MAX_PREVIEW_DL && _prevQueue.Count > 0) { string url = _prevQueue.Dequeue(); if (_texCache.ContainsKey(url)) { _loading.Remove(url); continue; } _activePrevDL++; Send(UnityWebRequestTexture.GetTexture(url), r => { _activePrevDL--; _loading.Remove(url); if (r.result == UnityWebRequest.Result.Success) { var tx = DownloadHandlerTexture.GetContent(r); if (tx != null) { if (_texCache.Count >= CACHE_MAX && _cacheOrder.Count > 0) { var old = _cacheOrder.First.Value; _cacheOrder.RemoveFirst(); if (_texCache.TryGetValue(old, out var ot)) { DestroyImmediate(ot); _texCache.Remove(old); } } _texCache[url] = tx; _cacheOrder.AddLast(url); Repaint(); } } else { Debug.LogWarning($"[Free3D] Preview failed: {url} :: {r.error} (HTTP {r.responseCode})"); } }); } } // ── search ── int CurrentPageIndex() => Mathf.Max(0, _offset / PAGE_SIZE); int TotalPages() { if (_total <= 0) return 1; return Mathf.Max(1, Mathf.CeilToInt(_total / (float)PAGE_SIZE)); } void RequestResultsPage(int pageIndex) { int safePage = Mathf.Max(0, pageIndex); int targetOffset = safePage * PAGE_SIZE; _offset = targetOffset; _results.Clear(); _scroll = Vector2.zero; string b = string.IsNullOrWhiteSpace(_query) ? URL_BROWSE : URL_SEARCH; string url = string.IsNullOrWhiteSpace(_query) ? $"{b}?topK={PAGE_SIZE}&offset={targetOffset}" : $"{b}?q={UnityWebRequest.EscapeURL(_query)}&topK={PAGE_SIZE}&offset={targetOffset}"; url = AppendActiveFilters(url, includeCategory:true); _status = string.IsNullOrWhiteSpace(_query) ? $"Loading page {safePage + 1}..." : $"Searching page {safePage + 1}..."; Send(UnityWebRequest.Get(url), OnSearch); } void DoSearch() { if (string.IsNullOrWhiteSpace(_query)) { DoBrowse(); return; } RequestResultsPage(0); } void DoBrowse() { RequestResultsPage(0); } void DoLoadMore() { RequestResultsPage(CurrentPageIndex() + 1); } void OnSearch(UnityWebRequest r) => ParseSearch(r, false); void ParseSearch(UnityWebRequest r, bool append) { _status = ""; if (r.result != UnityWebRequest.Result.Success) { _status = $"Search error: {r.error} (HTTP {r.responseCode})"; Debug.LogError($"[Free3D] Search: {r.error}\n{r.downloadHandler?.text}"); Repaint(); return; } try { var d = JsonUtility.FromJson(r.downloadHandler.text); if (append && d.results != null) _results.AddRange(d.results); else _results = d.results ?? new List(); _hasMore = d.hasMore; _total = d.total; _offset = d.offset; } catch (Exception e) { _status = "Parse error: " + e.Message; Debug.LogError($"[Free3D] JSON: {e.Message}\n{r.downloadHandler?.text?[..Mathf.Min(500, r.downloadHandler.text.Length)]}"); } Repaint(); } void DrawPaginationBar() { int totalPages = TotalPages(); int currentPage = Mathf.Clamp(CurrentPageIndex(), 0, totalPages - 1); if (totalPages <= 1) return; EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUI.enabled = currentPage > 0; if (GUILayout.Button("Prev", EditorStyles.toolbarButton, GUILayout.Width(44))) RequestResultsPage(currentPage - 1); GUI.enabled = true; int start = Mathf.Max(0, currentPage - 2); int end = Mathf.Min(totalPages - 1, start + 4); start = Mathf.Max(0, end - 4); if (start > 0) { if (GUILayout.Button("1", EditorStyles.toolbarButton, GUILayout.Width(28))) RequestResultsPage(0); GUILayout.Label("...", EditorStyles.miniLabel, GUILayout.Width(12)); } for (int page = start; page <= end; page++) { bool isCurrent = page == currentPage; GUI.enabled = !isCurrent; if (GUILayout.Button((page + 1).ToString(), EditorStyles.toolbarButton, GUILayout.Width(32))) RequestResultsPage(page); GUI.enabled = true; } if (end < totalPages - 1) { GUILayout.Label("...", EditorStyles.miniLabel, GUILayout.Width(12)); if (GUILayout.Button(totalPages.ToString(), EditorStyles.toolbarButton, GUILayout.Width(36))) RequestResultsPage(totalPages - 1); } GUILayout.FlexibleSpace(); GUILayout.Label($"Page {currentPage + 1}/{totalPages}", EditorStyles.miniLabel, GUILayout.Width(72)); GUI.enabled = currentPage < totalPages - 1; if (GUILayout.Button("Next", EditorStyles.toolbarButton, GUILayout.Width(44))) RequestResultsPage(currentPage + 1); GUI.enabled = true; EditorGUILayout.EndHorizontal(); } void LoadFilters() { Send(UnityWebRequest.Get(URL_FILTERS), r => { if (r.result != UnityWebRequest.Result.Success) return; try { var d = JsonUtility.FromJson(r.downloadHandler.text); _cats = d.categories ?? new List(); _types = d.types ?? new List(); _taxonomyUrl = d.taxonomyUrl ?? ""; var cn = new List { "All Categories" }; cn.AddRange(_cats.Select(c => $"{c.label} ({c.count})")); _catNames = cn.ToArray(); var tn = new List { "All Types" }; tn.AddRange(_types.Select(t => $"{t.label} ({t.count})")); _typeNames = tn.ToArray(); } catch (Exception e) { Debug.LogWarning("[Free3D] Filters: " + e.Message); } Repaint(); }); } string SelectedCategoryValue() { if (_selCat > 0 && _cats != null && _cats.Count >= _selCat) return _cats[_selCat - 1].value ?? ""; return ""; } string AppendActiveFilters(string url, bool includeCategory) { if (_selType > 0 && _types.Count > 0) url += $"&type={UnityWebRequest.EscapeURL(_types[_selType - 1].value)}"; if (includeCategory) { string category = SelectedCategoryValue(); if (!string.IsNullOrEmpty(category)) url += $"&category={UnityWebRequest.EscapeURL(category)}"; } return url; } // ── auth ── void StartAuth() { var body = JsonUtility.ToJson(new F3DAuthStartReq { appSlug = "free3d-unity", appName = "Free3D Unity Plugin", appVersion = VERSION, pluginFamily = "unity", deviceName = SystemInfo.deviceName, requestedScopes = new[] { "account:read", "balance:read", "entitlements:read", "download:direct", "scene:import" } }); var req = new UnityWebRequest(URL_AUTH_START, "POST"); req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); _status = "Starting auth..."; Send(req, OnAuthStart); } void OnAuthStart(UnityWebRequest r) { if (r.result != UnityWebRequest.Result.Success) { _status = $"Auth failed: {r.error} (HTTP {r.responseCode})"; Debug.LogError($"[Free3D] Auth start: {r.error}\n{r.downloadHandler?.text}"); Repaint(); return; } string txt = r.downloadHandler.text; Debug.Log("[Free3D] Auth start resp: " + txt); try { var d = JsonUtility.FromJson(txt); string sid = d.authSessionId ?? JStr(txt, "authSessionId"); string authUrl = d.authUrl ?? JStr(txt, "authUrl"); string pollUrl = d.pollUrl ?? JStr(txt, "pollUrl"); if (string.IsNullOrEmpty(sid) || string.IsNullOrEmpty(authUrl)) { _status = "Auth response missing fields"; Debug.LogError("[Free3D] Auth resp fields missing: " + txt); Repaint(); return; } _authSid = sid; _authPoll = pollUrl ?? ""; Application.OpenURL(authUrl); _polling = true; _lastPoll = EditorApplication.timeSinceStartup; _status = "Waiting for browser approval..."; } catch (Exception e) { _status = "Auth parse: " + e.Message; } Repaint(); } void PollAuth() { string url = !string.IsNullOrEmpty(_authPoll) ? _authPoll : $"{URL_AUTH_POLL}{_authSid}"; Send(UnityWebRequest.Get(url), OnPollAuth); } void OnPollAuth(UnityWebRequest r) { if (r.result != UnityWebRequest.Result.Success) { if (r.responseCode == 202 || r.responseCode == 404) return; Debug.Log($"[Free3D] Poll: HTTP {r.responseCode} {r.downloadHandler?.text}"); return; } string txt = r.downloadHandler.text; Debug.Log("[Free3D] Poll resp: " + txt); string tok = JStr(txt, "token"); if (!string.IsNullOrEmpty(tok)) { _token = tok; EditorPrefs.SetString(PK_TOKEN, tok); _polling = false; _status = "Signed in!"; string bs = JStr(txt, "balanceRemainingUsd") ?? JStr(txt, "balanceUsd") ?? JStr(txt, "balance_usd") ?? JStr(txt, "balance"); if (bs != null && float.TryParse(bs, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out float b)) { _balance = b; EditorPrefs.SetFloat(PK_BAL, b); } Repaint(); } } // ── import pipeline (GLB-only) ── void StartImport(F3DResult model) { if (string.IsNullOrEmpty(_token)) { _status = "Sign in first."; return; } _importing = true; _impProgress = 0.05f; _impStatus = "Fetching formats..."; Repaint(); var req = UnityWebRequest.Get(URL_FORMATS + model.guid); req.SetRequestHeader("Authorization", "Bearer " + _token); Send(req, r => OnFormats(r, model)); } void OnFormats(UnityWebRequest r, F3DResult model) { if (r.result != UnityWebRequest.Result.Success) { Fail($"Formats error: {r.error} (HTTP {r.responseCode})"); Debug.LogError($"[Free3D] Formats: {r.error}\n{r.downloadHandler?.text}"); return; } string txt = r.downloadHandler.text; Debug.Log("[Free3D] Formats: " + txt[..Mathf.Min(2000, txt.Length)]); try { var d = JsonUtility.FromJson(txt); if (d.glbVariants == null || d.glbVariants.Count == 0) { Fail("No GLB variants available for this model."); return; } string baseName = Sanitize(model.title); EnsureDirs(); var toDownload = new List(); var seen = new HashSet(); foreach (var lod in new[] { "100k", "10k", "1k" }) { var match = d.glbVariants.FirstOrDefault(v => string.Equals(v.lod, lod, StringComparison.OrdinalIgnoreCase)); if (match != null && seen.Add(lod)) toDownload.Add(match); } if (toDownload.Count == 0) { Fail($"No 100k/10k/1k GLB LODs. Available: {string.Join(", ", d.glbVariants.Select(v => v.lod))}"); return; } F3DGlbVariant animVariant = null; if (model.type == 1 && d.animationGlbVariants != null && d.animationGlbVariants.Count > 0) { animVariant = d.animationGlbVariants.FirstOrDefault(v => string.Equals(v.lod, "100k", StringComparison.OrdinalIgnoreCase)) ?? d.animationGlbVariants[0]; Debug.Log($"[Free3D] Character model -> will also download animation GLB (lod={animVariant.lod})"); } Debug.Log($"[Free3D] Downloading {toDownload.Count} GLB LODs: {string.Join(", ", toDownload.Select(v => v.lod))}"); _impProgress = 0.1f; _impStatus = $"Downloading {toDownload.Count} GLB files..."; Repaint(); GlbChain(model, baseName, toDownload, 0, new Dictionary(), animVariant); } catch (Exception e) { Fail("Formats parse: " + e.Message); Debug.LogError("[Free3D] " + e); } } void GlbChain(F3DResult model, string baseName, List variants, int idx, Dictionary glbPaths, F3DGlbVariant animVariant, string animGlbPath = null) { if (idx >= variants.Count) { if (animVariant != null && animGlbPath == null) { DownloadAnimGlb(model, baseName, glbPaths, animVariant); return; } GlbFinalize(model, baseName, glbPaths, animGlbPath); return; } var v = variants[idx]; _impProgress = 0.1f + 0.7f * idx / variants.Count; _impStatus = $"Requesting {v.lod} GLB..."; Repaint(); F3DDLBody body; string directUrl; if (v.downloadRequest != null && v.downloadRequest.body != null) { body = v.downloadRequest.body; directUrl = v.downloadRequest.url ?? URL_DIRECT; } else { body = new F3DDLBody { guid = model.guid, format = "glb", lod = v.lod, }; directUrl = URL_DIRECT; } Debug.Log($"[Free3D] POST {directUrl} body: {JsonUtility.ToJson(body)}"); var req = new UnityWebRequest(directUrl, "POST"); req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(body))); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); req.SetRequestHeader("Authorization", "Bearer " + _token); Send(req, r => OnGlbDirect(r, model, baseName, variants, idx, v, glbPaths, animVariant)); } void OnGlbDirect(UnityWebRequest r, F3DResult model, string baseName, List variants, int idx, F3DGlbVariant v, Dictionary glbPaths, F3DGlbVariant animVariant) { if (r.result != UnityWebRequest.Result.Success) { Fail($"Direct DL {v.lod}: {r.error} (HTTP {r.responseCode})"); Debug.LogError($"[Free3D] Direct: {r.error}\n{r.downloadHandler?.text}"); return; } string txt = r.downloadHandler.text; Debug.Log($"[Free3D] Direct ({v.lod}): {txt[..Mathf.Min(600, txt.Length)]}"); try { var d = JsonUtility.FromJson(txt); if (!d.ok || string.IsNullOrEmpty(d.downloadUrl)) { Fail($"Direct response not OK for {v.lod}. Body: {txt[..Mathf.Min(300, txt.Length)]}"); return; } if (d.balanceRemainingUsd > 0) { _balance = d.balanceRemainingUsd; EditorPrefs.SetFloat(PK_BAL, _balance); } string glbFile = $"{baseName}_{v.lod}.glb"; string glbAsset = $"{D_GLB}/{glbFile}"; _impStatus = $"Downloading {v.lod} GLB..."; Repaint(); Send(UnityWebRequest.Get(d.downloadUrl), gr => { if (gr.result != UnityWebRequest.Result.Success) { Fail($"GLB download {v.lod}: {gr.error}"); return; } File.WriteAllBytes(ToAbs(glbAsset), gr.downloadHandler.data); glbPaths[v.lod] = glbAsset; Debug.Log($"[Free3D] GLB saved: {v.lod} -> {glbAsset} ({gr.downloadHandler.data.Length} bytes)"); GlbChain(model, baseName, variants, idx + 1, glbPaths, animVariant); }); } catch (Exception e) { Fail("Direct parse: " + e.Message); Debug.LogError("[Free3D] " + e); } } void DownloadAnimGlb(F3DResult model, string baseName, Dictionary glbPaths, F3DGlbVariant animVariant) { _impStatus = "Requesting animation GLB..."; Repaint(); F3DDLBody body; string directUrl; if (animVariant.downloadRequest != null && animVariant.downloadRequest.body != null) { body = animVariant.downloadRequest.body; directUrl = animVariant.downloadRequest.url ?? URL_DIRECT; } else { body = new F3DDLBody { guid = model.guid, format = "glb", lod = animVariant.lod, relativePath = animVariant.relativePath, }; directUrl = URL_DIRECT; } Debug.Log($"[Free3D] POST {directUrl} (animations) body: {JsonUtility.ToJson(body)}"); var req = new UnityWebRequest(directUrl, "POST"); req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(body))); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); req.SetRequestHeader("Authorization", "Bearer " + _token); Send(req, r => { if (r.result != UnityWebRequest.Result.Success) { Debug.LogWarning($"[Free3D] Animation GLB direct request failed: {r.error}. Continuing without animations."); GlbFinalize(model, baseName, glbPaths, null); return; } try { var d = JsonUtility.FromJson(r.downloadHandler.text); if (!d.ok || string.IsNullOrEmpty(d.downloadUrl)) { Debug.LogWarning("[Free3D] Animation GLB direct response not OK. Continuing without animations."); GlbFinalize(model, baseName, glbPaths, null); return; } if (d.balanceRemainingUsd > 0) { _balance = d.balanceRemainingUsd; EditorPrefs.SetFloat(PK_BAL, _balance); } _impStatus = "Downloading animation GLB..."; Repaint(); string animFile = $"{baseName}_animations.glb"; string animAsset = $"{D_GLB}/{animFile}"; Send(UnityWebRequest.Get(d.downloadUrl), gr => { if (gr.result != UnityWebRequest.Result.Success) { Debug.LogWarning($"[Free3D] Animation GLB download failed: {gr.error}. Continuing without animations."); GlbFinalize(model, baseName, glbPaths, null); return; } File.WriteAllBytes(ToAbs(animAsset), gr.downloadHandler.data); Debug.Log($"[Free3D] Animation GLB saved: {animAsset} ({gr.downloadHandler.data.Length} bytes)"); GlbFinalize(model, baseName, glbPaths, animAsset); }); } catch (Exception e) { Debug.LogWarning($"[Free3D] Animation GLB parse error: {e.Message}. Continuing without animations."); GlbFinalize(model, baseName, glbPaths, null); } }); } void GlbFinalize(F3DResult model, string baseName, Dictionary glbPaths, string animGlbPath) { _impProgress = 0.85f; _impStatus = "Importing assets..."; Repaint(); if (!IsGltfastAvailable()) { _impStatus = "Installing GLTFast package..."; Repaint(); EnsureGltfast(); Fail("GLTFast is being installed. Please retry import after Unity finishes recompiling."); return; } AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); EditorApplication.delayCall += () => { try { foreach (var kv in glbPaths) { var prefab = AssetDatabase.LoadAssetAtPath(kv.Value); if (prefab == null) { Debug.LogWarning($"[Free3D] Prefab null for {kv.Value}, trying re-import..."); AssetDatabase.ImportAsset(kv.Value, ImportAssetOptions.ForceUpdate); prefab = AssetDatabase.LoadAssetAtPath(kv.Value); } if (prefab == null) { Fail($"Failed to import {kv.Value}. Check that GLTFast is installed (Window > Package Manager > com.unity.cloud.gltfast)."); return; } } _impProgress = 0.88f; _impStatus = "Converting materials..."; Repaint(); var matMap = ConvertAllMaterials(baseName, glbPaths); Debug.Log($"[Free3D] Converted {matMap.Count} materials to {(PipeMode() == 1 ? "URP Lit" : PipeMode() == 2 ? "HDRP Lit" : "Standard")}"); AnimatorController animCtrl = null; if (model.type == 1) { _impStatus = "Building animation controller..."; Repaint(); animCtrl = BuildAnimatorController(baseName, glbPaths, animGlbPath); } _impProgress = 0.95f; _impStatus = "Building scene object..."; Repaint(); if (_autoLOD && glbPaths.Count > 1) BuildLOD(baseName, glbPaths, animCtrl, matMap); else PlaceSingle(baseName, glbPaths, animCtrl, matMap); _importing = false; _impProgress = 1f; _status = $"Imported: {model.title}"; Repaint(); } catch (Exception e) { Fail("Finalize: " + e.Message); Debug.LogError("[Free3D] Finalize:\n" + e); } }; } // ── animation ── static readonly string[] _lodOrder = { "100k", "10k", "1k" }; List LoadModelClips(string assetPath) { if (string.IsNullOrEmpty(assetPath)) return new List(); return AssetDatabase.LoadAllAssetsAtPath(assetPath) .OfType() .Where(clip => clip != null && !string.IsNullOrEmpty(clip.name) && !clip.name.StartsWith("__preview__", StringComparison.OrdinalIgnoreCase)) .OrderBy(clip => clip.name, StringComparer.OrdinalIgnoreCase) .ToList(); } AnimatorController BuildAnimatorController(string baseName, Dictionary assetPaths, string animGlbPath) { var clips = new List(); if (!string.IsNullOrEmpty(animGlbPath)) { clips = LoadModelClips(animGlbPath); Debug.Log($"[Free3D] Animation clips from animation GLB ({animGlbPath}): {clips.Count}"); } if (clips.Count == 0) { string sourcePath = null; foreach (var lod in _lodOrder) if (assetPaths.TryGetValue(lod, out sourcePath) && !string.IsNullOrEmpty(sourcePath)) break; if (!string.IsNullOrEmpty(sourcePath)) { clips = LoadModelClips(sourcePath); Debug.Log($"[Free3D] Animation clips from model GLB ({sourcePath}): {clips.Count}"); } } if (clips.Count == 0) return null; string controllerPath = $"{D_CTRL}/{baseName}.controller"; if (AssetDatabase.LoadAssetAtPath(controllerPath) != null) AssetDatabase.DeleteAsset(controllerPath); var controller = AnimatorController.CreateAnimatorControllerAtPath(controllerPath); if (controller == null) return null; var stateMachine = controller.layers[0].stateMachine; foreach (var state in stateMachine.states.ToArray()) stateMachine.RemoveState(state.state); AnimatorState defaultState = null; float x = 240f; foreach (var clip in clips) { var state = stateMachine.AddState(clip.name, new Vector3(x, 80f, 0f)); state.motion = clip; if (defaultState == null) defaultState = state; x += 220f; } if (defaultState != null) stateMachine.defaultState = defaultState; AssetDatabase.SaveAssets(); return controller; } void AssignAnimatorController(GameObject root, AnimatorController controller) { if (root == null || controller == null) return; var animators = root.GetComponentsInChildren(true) .Where(animator => animator != null) .ToList(); if (animators.Count == 0) { var animator = root.GetComponent(); if (animator == null) animator = root.AddComponent(); if (animator != null) animator.runtimeAnimatorController = controller; return; } foreach (var animator in animators) { if (animator == null) continue; animator.runtimeAnimatorController = controller; } } // ── material conversion ── int PipeMode() { var rp = GraphicsSettings.currentRenderPipeline; if (rp == null) return 3; string n = rp.GetType().Name; if (n.Contains("Universal")) return 1; if (n.Contains("HDRender")) return 2; return 3; } Shader GetPipelineShader() { int m = PipeMode(); Shader s = m switch { 1 => Shader.Find("Universal Render Pipeline/Lit"), 2 => Shader.Find("HDRP/Lit"), _ => Shader.Find("Standard") }; return s ?? Shader.Find("Standard"); } static readonly string[] _baseColorProps = { "baseColorTexture", "_BaseColorTex", "_BaseColor_Tex", "_MainTex", "_BaseMap" }; static readonly string[] _normalProps = { "normalTexture", "_NormalTex", "_Normal_Tex", "_BumpMap" }; static readonly string[] _metalRoughProps = { "metallicRoughnessTexture", "_MetallicRoughnessTex", "_MetallicRoughness_Tex" }; static readonly string[] _occlusionProps = { "occlusionTexture", "_OcclusionTex", "_Occlusion_Tex" }; static readonly string[] _emissiveProps = { "emissiveTexture", "_EmissiveTex", "_Emissive_Tex" }; Texture TryGetTex(Material mat, string[] propNames) { foreach (var n in propNames) { if (mat.HasProperty(n)) { var t = mat.GetTexture(n); if (t != null) return t; } } return null; } Texture2D RepackMetallicSmoothness(Texture source, string name) { int w = source.width, h = source.height; var rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Linear); Graphics.Blit(source, rt); var prev = RenderTexture.active; RenderTexture.active = rt; var tmp = new Texture2D(w, h, TextureFormat.RGBA32, false, true); tmp.ReadPixels(new Rect(0, 0, w, h), 0, 0); tmp.Apply(); RenderTexture.active = prev; RenderTexture.ReleaseTemporary(rt); var pixels = tmp.GetPixels(); for (int i = 0; i < pixels.Length; i++) { float metallic = pixels[i].b; float smoothness = 1f - pixels[i].g; pixels[i] = new Color(metallic, metallic, metallic, smoothness); } tmp.SetPixels(pixels); tmp.Apply(); string assetPath = $"{D_TEX}/{name}_metallic.png"; File.WriteAllBytes(ToAbs(assetPath), tmp.EncodeToPNG()); DestroyImmediate(tmp); AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport); if (AssetImporter.GetAtPath(assetPath) is TextureImporter ti) { bool changed = false; if (ti.sRGBTexture) { ti.sRGBTexture = false; changed = true; } if (!ti.isReadable) { ti.isReadable = false; changed = true; } if (changed) ti.SaveAndReimport(); } return AssetDatabase.LoadAssetAtPath(assetPath); } Material ConvertGltfMaterial(Material src, string matName, string matAsset) { int mode = PipeMode(); var shader = GetPipelineShader(); var existing = AssetDatabase.LoadAssetAtPath(matAsset); var mat = existing ?? new Material(shader); mat.shader = shader; mat.name = matName; var baseColor = TryGetTex(src, _baseColorProps); var normal = TryGetTex(src, _normalProps); var metalRough = TryGetTex(src, _metalRoughProps); var occlusion = TryGetTex(src, _occlusionProps); var emission = TryGetTex(src, _emissiveProps); Debug.Log($"[Free3D] Converting material '{src.name}' -> '{matName}'. " + $"baseColor={baseColor != null}, normal={normal != null}, metalRough={metalRough != null}, " + $"occlusion={occlusion != null}, emission={emission != null}"); if (baseColor != null) mat.SetTexture(mode == 2 ? "_BaseColorMap" : mode == 1 ? "_BaseMap" : "_MainTex", baseColor); if (normal != null) { mat.SetTexture(mode == 2 ? "_NormalMap" : "_BumpMap", normal); mat.EnableKeyword("_NORMALMAP"); } if (metalRough != null) { var packed = RepackMetallicSmoothness(metalRough, matName); if (packed != null) { if (mode == 2) { mat.SetTexture("_MaskMap", packed); if (mat.HasProperty("_Metallic")) mat.SetFloat("_Metallic", 1f); if (mat.HasProperty("_Smoothness")) mat.SetFloat("_Smoothness", 1f); } else { mat.SetTexture("_MetallicGlossMap", packed); if (mat.HasProperty("_Metallic")) mat.SetFloat("_Metallic", 1f); if (mat.HasProperty("_Smoothness")) mat.SetFloat("_Smoothness", 1f); if (mat.HasProperty("_SmoothnessTextureChannel")) mat.SetFloat("_SmoothnessTextureChannel", 0f); mat.EnableKeyword("_METALLICGLOSSMAP"); mat.EnableKeyword("_METALLICSPECGLOSSMAP"); } } } if (occlusion != null) { mat.SetTexture("_OcclusionMap", occlusion); if (mat.HasProperty("_OcclusionStrength")) mat.SetFloat("_OcclusionStrength", 1f); } if (emission != null) { mat.SetTexture(mode == 2 ? "_EmissiveColorMap" : "_EmissionMap", emission); mat.EnableKeyword("_EMISSION"); mat.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive; } if (existing == null) AssetDatabase.CreateAsset(mat, matAsset); else EditorUtility.SetDirty(mat); return mat; } Dictionary ConvertAllMaterials(string baseName, Dictionary glbPaths) { var map = new Dictionary(); int globalIdx = 0; foreach (var kv in glbPaths) { string lod = kv.Key; var allAssets = AssetDatabase.LoadAllAssetsAtPath(kv.Value); foreach (var asset in allAssets) { if (asset is Material gltfMat && !map.ContainsKey(gltfMat)) { string safeName = Regex.Replace(gltfMat.name, @"[^a-zA-Z0-9_\-]", "_"); string matName = $"{baseName}_{safeName}"; string matAsset = $"{D_MAT}/{matName}.mat"; if (AssetDatabase.LoadAssetAtPath(matAsset) != null) { matName = $"{baseName}_{globalIdx}_{safeName}"; matAsset = $"{D_MAT}/{matName}.mat"; } map[gltfMat] = ConvertGltfMaterial(gltfMat, matName, matAsset); globalIdx++; } } } AssetDatabase.SaveAssets(); return map; } void ApplyMaterialMap(GameObject inst, Dictionary matMap) { foreach (var rn in inst.GetComponentsInChildren(true)) { var mats = rn.sharedMaterials; bool changed = false; for (int i = 0; i < mats.Length; i++) { if (mats[i] != null && matMap.TryGetValue(mats[i], out var lit)) { mats[i] = lit; changed = true; } } if (changed) rn.sharedMaterials = mats; } } // ── LOD builder ── void BuildLOD(string name, Dictionary glbPaths, AnimatorController animCtrl, Dictionary matMap) { var parent = new GameObject(name); Undo.RegisterCreatedObjectUndo(parent, "Import " + name); var lg = parent.AddComponent(); var lods = new List(); foreach (var (lod, thr) in new[] { ("100k", _lod0), ("10k", _lod1), ("1k", _lod2) }) { if (!glbPaths.TryGetValue(lod, out string path)) continue; var prefab = AssetDatabase.LoadAssetAtPath(path); if (prefab == null) { Debug.LogWarning("[Free3D] No prefab at: " + path); continue; } var inst = (GameObject)PrefabUtility.InstantiatePrefab(prefab, parent.transform); inst.name = $"{name}_{lod}"; if (Mathf.Abs(_scale - 1f) > 0.0001f) inst.transform.localScale = Vector3.one * _scale; ApplyMaterialMap(inst, matMap); lods.Add(new LOD(thr, inst.GetComponentsInChildren())); } if (lods.Count > 0) { lg.SetLODs(lods.ToArray()); lg.RecalculateBounds(); } if (animCtrl != null) AssignAnimatorController(parent, animCtrl); if (SceneView.lastActiveSceneView != null) parent.transform.position = SceneView.lastActiveSceneView.pivot; Selection.activeGameObject = parent; EditorGUIUtility.PingObject(parent); } void PlaceSingle(string name, Dictionary glbPaths, AnimatorController animCtrl, Dictionary matMap) { string path = null; foreach (var l in _lodOrder) if (glbPaths.TryGetValue(l, out path)) break; if (path == null) { _status = "No GLB downloaded."; return; } var prefab = AssetDatabase.LoadAssetAtPath(path); if (prefab == null) { _status = "Cannot load: " + path; return; } var inst = (GameObject)PrefabUtility.InstantiatePrefab(prefab); inst.name = name; Undo.RegisterCreatedObjectUndo(inst, "Import " + name); if (Mathf.Abs(_scale - 1f) > 0.0001f) inst.transform.localScale = Vector3.one * _scale; ApplyMaterialMap(inst, matMap); if (animCtrl != null) AssignAnimatorController(inst, animCtrl); if (SceneView.lastActiveSceneView != null) inst.transform.position = SceneView.lastActiveSceneView.pivot; Selection.activeGameObject = inst; } // ── utilities ── string Sanitize(string title) { string s = title.ToLowerInvariant(); s = Regex.Replace(s, @"[^a-z0-9\s-]", ""); s = Regex.Replace(s, @"\s+", "-").Trim('-'); if (s.Length > NAME_MAX) { int c = s.LastIndexOf('-', NAME_MAX); s = c > 10 ? s[..c] : s[..NAME_MAX]; } string final_ = s; int n = 2; while (File.Exists(ToAbs($"{D_GLB}/{final_}_100k.glb"))) { final_ = $"{s}_{n}"; n++; } return final_; } string Url(string raw) { if (string.IsNullOrEmpty(raw)) return ""; string candidate = raw.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? raw : BASE + raw; candidate = candidate.Replace(" ", "%20"); return Uri.EscapeUriString(candidate); } string ToAbs(string asset) => Path.Combine(Path.GetDirectoryName(Application.dataPath)!, asset); void EnsureDirs() { Directory.CreateDirectory(ToAbs(D_GLB)); Directory.CreateDirectory(ToAbs(D_TEX)); Directory.CreateDirectory(ToAbs(D_MAT)); Directory.CreateDirectory(ToAbs(D_CTRL)); } void Fail(string msg) { _importing = false; _status = msg; Debug.LogError("[Free3D] " + msg); Repaint(); } // ── web request system ── void Send(UnityWebRequest www, Action cb) { www.SendWebRequest(); _reqs.Add(new PendingReq { www = www, callback = cb }); } void PumpReqs() { for (int i = _reqs.Count - 1; i >= 0; i--) { if (!_reqs[i].www.isDone) continue; var q = _reqs[i]; _reqs.RemoveAt(i); try { q.callback?.Invoke(q.www); } catch (Exception e) { Debug.LogError("[Free3D] Callback: " + e); } q.www.Dispose(); } } static string JStr(string json, string key) { var m = Regex.Match(json, $"\"{Regex.Escape(key)}\"\\s*:\\s*\"([^\"]*)\""); if (m.Success) return m.Groups[1].Value; var m2 = Regex.Match(json, $"\"{Regex.Escape(key)}\"\\s*:\\s*([0-9.]+)"); return m2.Success ? m2.Groups[1].Value : null; } } } #endif