From 470efa8fa7f7027b05dabc72d225f44cb6d8f16a Mon Sep 17 00:00:00 2001 From: Bilal Date: Sun, 8 Feb 2026 23:53:39 +0300 Subject: [PATCH 01/13] Add replace prefabs instead delete first --- .../Editor/FigmaImportProcessData.cs | 10 +++ .../Editor/UnityFigmaBridgeImporter.cs | 16 +++- UnityFigmaBridge/Editor/Utils/FigmaPaths.cs | 84 +++++++++++++++++-- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/UnityFigmaBridge/Editor/FigmaImportProcessData.cs b/UnityFigmaBridge/Editor/FigmaImportProcessData.cs index 073448d..ebbca8a 100644 --- a/UnityFigmaBridge/Editor/FigmaImportProcessData.cs +++ b/UnityFigmaBridge/Editor/FigmaImportProcessData.cs @@ -76,6 +76,16 @@ public class FigmaImportProcessData /// Allow faster lookup of nodes by ID /// public Dictionary NodeLookupDictionary = new(); + + /// + /// Old screen prefab paths before import (for cleanup comparison) + /// + public List OldScreenPrefabPaths = new(); + + /// + /// Old page prefab paths before import (for cleanup comparison) + /// + public List OldPagePrefabPaths = new(); } /// diff --git a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs index e72a15a..10dd528 100644 --- a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs +++ b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs @@ -325,9 +325,15 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis // Build a list of page IDs to download var downloadPageIdList = downloadPageNodeList.Select(p => p.id).ToList(); - // Ensure we have all required directories, and remove existing files - // TODO - Once we move to processing only differences, we won't remove existing files - FigmaPaths.CreateRequiredDirectories(); + // Store for old prefabs before directory creation (for orphan cleanup) + var figmaBridgeProcessData = new FigmaImportProcessData + { + Settings = s_UnityFigmaBridgeSettings, + SourceFile = figmaFile + }; + + // Ensure we have all required directories, and capture old paths for cleanup + FigmaPaths.CreateRequiredDirectories(figmaBridgeProcessData); // Next build a list of all externally referenced components not included in the document (eg // from external libraries) and download @@ -521,6 +527,10 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis // Write CS file with references to flowScreen name if (s_UnityFigmaBridgeSettings.CreateScreenNameCSharpFile) ScreenNameCodeGenerator.WriteScreenNamesCodeFile(figmaBridgeProcessData.ScreenPrefabs); } + + // Clean up orphaned prefabs (those that existed before but don't exist in the new import) + FigmaPaths.CleanupOrphanedPrefabs(figmaBridgeProcessData); + CleanUpPostGeneration(); EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); diff --git a/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs b/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs index 05fca4e..cf8a8e8 100644 --- a/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs +++ b/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs @@ -107,7 +107,7 @@ public static string MakeValidFileName(string name) return System.Text.RegularExpressions.Regex.Replace(name, invalidRegStr, "_"); } - public static void CreateRequiredDirectories() + public static void CreateRequiredDirectories(FigmaImportProcessData importProcessData = null) { // Create directory for pages if required @@ -115,11 +115,15 @@ public static void CreateRequiredDirectories() { Directory.CreateDirectory(FigmaPagePrefabFolder); } - - // Remove existing prefabs for pages - foreach (var file in new DirectoryInfo(FigmaPagePrefabFolder).GetFiles()) + else if (importProcessData != null) + { + // Capture existing page prefab paths before they get replaced + var pageDir = new DirectoryInfo(FigmaPagePrefabFolder); + foreach (var file in pageDir.GetFiles()) { - file.Delete(); + if (file.Extension == ".prefab") + importProcessData.OldPagePrefabPaths.Add(file.FullName); + } } // Create directory for flowScreen prefabs if required @@ -127,10 +131,15 @@ public static void CreateRequiredDirectories() { Directory.CreateDirectory(FigmaScreenPrefabFolder); } - // Remove existing flowScreen prefabs - foreach (FileInfo file in new DirectoryInfo(FigmaScreenPrefabFolder).GetFiles()) + else if (importProcessData != null) { - file.Delete(); + // Capture existing screen prefab paths before they get replaced + var screenDir = new DirectoryInfo(FigmaScreenPrefabFolder); + foreach (FileInfo file in screenDir.GetFiles()) + { + if (file.Extension == ".prefab") + importProcessData.OldScreenPrefabPaths.Add(file.FullName); + } } if (!Directory.Exists(FigmaComponentPrefabFolder)) @@ -161,5 +170,64 @@ public static void CreateRequiredDirectories() } } + /// + /// Delete orphaned prefabs that were in the old list but not in the new list + /// + public static void CleanupOrphanedPrefabs(FigmaImportProcessData importProcessData) + { + if (importProcessData == null) + return; + + // Get all current screen prefab paths + var currentScreenPaths = new HashSet(); + foreach (var screenPrefab in importProcessData.ScreenPrefabs) + { + var path = UnityEditor.AssetDatabase.GetAssetPath(screenPrefab); + if (!string.IsNullOrEmpty(path)) + currentScreenPaths.Add(System.IO.Path.GetFullPath(path)); + } + + // Get all current page prefab paths + var currentPagePaths = new HashSet(); + foreach (var pagePrefab in importProcessData.PagePrefabs) + { + var path = UnityEditor.AssetDatabase.GetAssetPath(pagePrefab); + if (!string.IsNullOrEmpty(path)) + currentPagePaths.Add(System.IO.Path.GetFullPath(path)); + } + + // Delete screen prefabs that no longer exist in Figma + foreach (var oldPath in importProcessData.OldScreenPrefabPaths) + { + if (!currentScreenPaths.Contains(oldPath)) + { + if (System.IO.File.Exists(oldPath)) + { + System.IO.File.Delete(oldPath); + var metaPath = oldPath + ".meta"; + if (System.IO.File.Exists(metaPath)) + System.IO.File.Delete(metaPath); + UnityEngine.Debug.Log($"Deleted orphaned screen prefab: {oldPath}"); + } + } + } + + // Delete page prefabs that no longer exist in Figma + foreach (var oldPath in importProcessData.OldPagePrefabPaths) + { + if (!currentPagePaths.Contains(oldPath)) + { + if (System.IO.File.Exists(oldPath)) + { + System.IO.File.Delete(oldPath); + var metaPath = oldPath + ".meta"; + if (System.IO.File.Exists(metaPath)) + System.IO.File.Delete(metaPath); + UnityEngine.Debug.Log($"Deleted orphaned page prefab: {oldPath}"); + } + } + } + } + } } \ No newline at end of file From ffef26edcb2c775bddc0bb191944a09531b8bbde Mon Sep 17 00:00:00 2001 From: Bilal Date: Sun, 8 Feb 2026 23:59:40 +0300 Subject: [PATCH 02/13] Add support to change FigmaAssetsRootFolder --- .../Settings/UnityFigmaBridgeSettings.cs | 4 +++ .../Editor/UnityFigmaBridgeImporter.cs | 3 ++ UnityFigmaBridge/Editor/Utils/FigmaPaths.cs | 28 +++++++++++-------- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs b/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs index ae06c01..e729e27 100644 --- a/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs +++ b/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs @@ -40,6 +40,10 @@ public class UnityFigmaBridgeSettings : ScriptableObject [Tooltip("If true, download only selected pages and screens")] public bool OnlyImportSelectedPages = false; + [Space(10)] + [Tooltip("Root folder path for generated Figma assets (default: Assets/Figma)")] + public string FigmaAssetsRootFolder = "Assets/Figma"; + [HideInInspector] public List PageDataList = new (); diff --git a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs index 10dd528..72f333d 100644 --- a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs +++ b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs @@ -138,6 +138,9 @@ public static bool CheckRequirements() { } } + // Initialize FigmaPaths with configured assets root folder + FigmaPaths.FigmaAssetsRootFolder = s_UnityFigmaBridgeSettings.FigmaAssetsRootFolder; + if (Shader.Find("TextMeshPro/Mobile/Distance Field")==null) { EditorUtility.DisplayDialog("Text Mesh Pro" ,"You need to install TestMeshPro Essentials. Use Window->Text Mesh Pro->Import TMP Essential Resources","OK"); diff --git a/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs b/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs index cf8a8e8..e6f4254 100644 --- a/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs +++ b/UnityFigmaBridge/Editor/Utils/FigmaPaths.cs @@ -8,39 +8,45 @@ namespace UnityFigmaBridge.Editor.Utils public static class FigmaPaths { /// - /// Root folder for assets + /// Root folder for assets (can be overridden via settings) /// - public static string FigmaAssetsRootFolder = "Assets/Figma"; + private static string s_FigmaAssetsRootFolder = "Assets/Figma"; + + public static string FigmaAssetsRootFolder + { + get => s_FigmaAssetsRootFolder; + set => s_FigmaAssetsRootFolder = value; + } /// - /// Assert folder to store page prefabs) + /// Assert folder to store page prefabs /// - public static string FigmaPagePrefabFolder = $"{FigmaAssetsRootFolder}/Pages"; + public static string FigmaPagePrefabFolder => $"{FigmaAssetsRootFolder}/Pages"; /// /// Assert folder to store flowScreen prefabs (root level frames on pages) /// - public static string FigmaScreenPrefabFolder = $"{FigmaAssetsRootFolder}/Screens"; + public static string FigmaScreenPrefabFolder => $"{FigmaAssetsRootFolder}/Screens"; /// /// Assert folder to store compoment prefabs /// - public static string FigmaComponentPrefabFolder = $"{FigmaAssetsRootFolder}/Components"; + public static string FigmaComponentPrefabFolder => $"{FigmaAssetsRootFolder}/Components"; /// /// Asset folder to store image fills /// - public static string FigmaImageFillFolder = $"{FigmaAssetsRootFolder}/ImageFills"; + public static string FigmaImageFillFolder => $"{FigmaAssetsRootFolder}/ImageFills"; /// /// Asset folder to store server rendered images /// - public static string FigmaServerRenderedImagesFolder = $"{FigmaAssetsRootFolder}/ServerRenderedImages"; + public static string FigmaServerRenderedImagesFolder => $"{FigmaAssetsRootFolder}/ServerRenderedImages"; /// /// Asset folder to store Font material presets /// - public static string FigmaFontMaterialPresetsFolder = $"{FigmaAssetsRootFolder}/FontMaterialPresets"; + public static string FigmaFontMaterialPresetsFolder => $"{FigmaAssetsRootFolder}/FontMaterialPresets"; /// /// Asset folder to store Font assets (TTF and generated TMP fonts) /// - public static string FigmaFontsFolder = $"{FigmaAssetsRootFolder}/Fonts"; + public static string FigmaFontsFolder => $"{FigmaAssetsRootFolder}/Fonts"; public static string GetPathForImageFill(string imageId) @@ -120,7 +126,7 @@ public static void CreateRequiredDirectories(FigmaImportProcessData importProces // Capture existing page prefab paths before they get replaced var pageDir = new DirectoryInfo(FigmaPagePrefabFolder); foreach (var file in pageDir.GetFiles()) - { + { if (file.Extension == ".prefab") importProcessData.OldPagePrefabPaths.Add(file.FullName); } From 8766e3e22951ed225569162c092a5d83743fb797 Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 00:00:41 +0300 Subject: [PATCH 03/13] Fix adding multiple LayoutElement --- .../Editor/Nodes/NodeTransformManager.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs b/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs index a6312d3..8e5b7ad 100644 --- a/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs +++ b/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs @@ -47,8 +47,10 @@ public static void ApplyFigmaTransform(RectTransform targetRectTransform, Node f // Apply the "size" figmaNode from figma to set size targetRectTransform.sizeDelta = new Vector2(figmaNode.size.x, figmaNode.size.y); - //Add a layout element and set its preferred size - LayoutElement layoutElement = targetRectTransform.gameObject.AddComponent(); + //Add a layout element and set its preferred size (only if it doesn't already exist) + LayoutElement layoutElement = targetRectTransform.gameObject.GetComponent(); + if (layoutElement == null) + layoutElement = targetRectTransform.gameObject.AddComponent(); layoutElement.preferredWidth = figmaNode.size.x; layoutElement.preferredHeight = figmaNode.size.y; @@ -143,8 +145,10 @@ public static void ApplyAbsoluteBoundsFigmaTransform(RectTransform targetRectTra // We'll use absolute bounding box size targetRectTransform.sizeDelta = new Vector2(figmaNode.absoluteBoundingBox.width, figmaNode.absoluteBoundingBox.height); - //Add a layout element and set its preferred size - LayoutElement layoutElement = targetRectTransform.gameObject.AddComponent(); + //Add a layout element and set its preferred size (only if it doesn't already exist) + LayoutElement layoutElement = targetRectTransform.gameObject.GetComponent(); + if (layoutElement == null) + layoutElement = targetRectTransform.gameObject.AddComponent(); layoutElement.preferredWidth = figmaNode.absoluteBoundingBox.width; layoutElement.preferredHeight = figmaNode.absoluteBoundingBox.height; From 1a6ba30a5d29e0af0b58fa39dd68417fa5e30562 Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 00:03:43 +0300 Subject: [PATCH 04/13] Add suppport to add Canvas components to screen prefabs --- .../Editor/Nodes/FigmaAssetGenerator.cs | 7 +++++ .../PrototypeFlow/BehaviourBindingManager.cs | 29 +++++++++++++++++++ .../Settings/UnityFigmaBridgeSettings.cs | 4 +++ 3 files changed, 40 insertions(+) diff --git a/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs b/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs index 9cf3c91..47aad77 100644 --- a/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs +++ b/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs @@ -258,6 +258,13 @@ private static void SaveFigmaScreenAsPrefab(Node node, Node parentNode,RectTrans // We want prefab to be stored with a default position, so reset and restore var current = screenRectTransform.anchoredPosition; screenRectTransform.anchoredPosition = Vector2.zero; + + // Enhance screen with UI components if enabled + if (figmaImportProcessData.Settings.EnhanceScreensWithUIComponents) + { + BehaviourBindingManager.EnhanceScreenWithComponents(screenRectTransform.gameObject); + } + // Write prefab var screenPrefab = PrefabUtility.SaveAsPrefabAssetAndConnect(screenRectTransform.gameObject, FigmaPaths.GetPathForScreenPrefab(node,screenNameCount), InteractionMode.UserAction); diff --git a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs index 0d88485..0212b1e 100644 --- a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs +++ b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs @@ -17,6 +17,35 @@ public static class BehaviourBindingManager private const int MAX_SEARCH_DEPTH_FOR_TRANSFORMS = 3; + /// + /// Add essential UI components to screens if they're missing (Canvas, CanvasScaler, GraphicRaycaster) + /// + public static void EnhanceScreenWithComponents(GameObject screenGameObject) + { + // Add Canvas if missing + if (screenGameObject.GetComponent() == null) + { + var canvas = screenGameObject.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.additionalShaderChannels = AdditionalCanvasShaderChannels.TexCoord1 | AdditionalCanvasShaderChannels.TexCoord2 | AdditionalCanvasShaderChannels.TexCoord3 | AdditionalCanvasShaderChannels.Normal | AdditionalCanvasShaderChannels.Tangent; + } + + // Add CanvasScaler if missing + if (screenGameObject.GetComponent() == null) + { + var canvasScaler = screenGameObject.AddComponent(); + canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + canvasScaler.referenceResolution = new Vector2(1920, 1080); + } + + // Add GraphicRaycaster if missing + if (screenGameObject.GetComponent() == null) + { + var graphicRaycaster = screenGameObject.AddComponent(); + graphicRaycaster.blockingObjects = GraphicRaycaster.BlockingObjects.TwoD; + } + } + /// /// Attempts to find a suitable mono behaviour to bind /// diff --git a/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs b/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs index e729e27..b4747fa 100644 --- a/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs +++ b/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs @@ -40,6 +40,10 @@ public class UnityFigmaBridgeSettings : ScriptableObject [Tooltip("If true, download only selected pages and screens")] public bool OnlyImportSelectedPages = false; + [Space(10)] + [Tooltip("Automatically add Canvas, CanvasScaler, and GraphicRaycaster components to generated screens (useful for standalone prefabs)")] + public bool EnhanceScreensWithUIComponents = false; + [Space(10)] [Tooltip("Root folder path for generated Figma assets (default: Assets/Figma)")] public string FigmaAssetsRootFolder = "Assets/Figma"; From be778571005cc28f64e661fa92a8fa9255e770d9 Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 18:19:56 +0300 Subject: [PATCH 05/13] Add completely ignorable PrototypeFlowController --- .../Editor/UnityFigmaBridgeImporter.cs | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs index 72f333d..eed66f0 100644 --- a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs +++ b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs @@ -64,7 +64,7 @@ static void Sync() { SyncAsync(); } - + private static async void SyncAsync() { var requirementsMet = CheckRequirements(); @@ -110,14 +110,15 @@ private static async void SyncAsync() } await ImportDocument(s_UnityFigmaBridgeSettings.FileId, figmaFile, pageNodeList); - + } /// /// Check to make sure all requirements are met before syncing /// + /// If true, checks and sets up prototype flow requirements. Set to false for reprocessing or server image sync only. /// - public static bool CheckRequirements() { + public static bool CheckRequirements(bool checkPrototypeFlow = true) { // Find the settings asset if it exists if (s_UnityFigmaBridgeSettings == null) @@ -169,7 +170,7 @@ public static bool CheckRequirements() { } // Check all requirements for run time if required - if (s_UnityFigmaBridgeSettings.BuildPrototypeFlow) + if (checkPrototypeFlow && s_UnityFigmaBridgeSettings.BuildPrototypeFlow) { if (!CheckRunTimeRequirements()) return false; @@ -213,19 +214,26 @@ private static bool CheckRunTimeRequirements() } } - // Find a canvas in the active scene - s_SceneCanvas = Object.FindObjectOfType(); - // If doesnt exist create new one if (s_SceneCanvas == null) { s_SceneCanvas = CreateCanvas(true); } - // If we are building a prototype, ensure we have a UI Controller component - s_PrototypeFlowController = s_SceneCanvas.GetComponent(); - if (s_PrototypeFlowController== null) - s_PrototypeFlowController = s_SceneCanvas.gameObject.AddComponent(); + // If we are building a prototype and settings allow, ensure we have a UI Controller component + if (s_UnityFigmaBridgeSettings.BuildPrototypeFlow) + { + // First try to find existing PrototypeFlowController on the canvas + s_PrototypeFlowController = Object.FindObjectOfType(); + // Only create if still not found + if (s_PrototypeFlowController == null) + s_PrototypeFlowController = s_SceneCanvas.gameObject.AddComponent(); + } + else + { + // Skip creating PrototypeFlowController + s_PrototypeFlowController = null; + } return true; } @@ -467,7 +475,9 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis } else { - s_SceneCanvas = CreateCanvas(false); + // Only create new canvas if we don't have one already + if (s_SceneCanvas == null) + s_SceneCanvas = CreateCanvas(false); } try @@ -527,8 +537,8 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis var screenInstance=(GameObject)PrefabUtility.InstantiatePrefab(defaultScreenData.FigmaScreenPrefab, figmaBridgeProcessData.PrototypeFlowController.ScreenParentTransform); figmaBridgeProcessData.PrototypeFlowController.SetCurrentScreen(screenInstance,defaultScreenData.FigmaNodeId,true); } - // Write CS file with references to flowScreen name - if (s_UnityFigmaBridgeSettings.CreateScreenNameCSharpFile) ScreenNameCodeGenerator.WriteScreenNamesCodeFile(figmaBridgeProcessData.ScreenPrefabs); + // Write CS file with references to flowScreen name + if (s_UnityFigmaBridgeSettings.CreateScreenNameCSharpFile) ScreenNameCodeGenerator.WriteScreenNamesCodeFile(figmaBridgeProcessData.ScreenPrefabs); } // Clean up orphaned prefabs (those that existed before but don't exist in the new import) From 83a8d55d000c4764d807747baf7af368ce853e1a Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 18:22:12 +0300 Subject: [PATCH 06/13] Add safe field name binding --- .../PrototypeFlow/BehaviourBindingManager.cs | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs index 0212b1e..2f21eb7 100644 --- a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs +++ b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs @@ -55,7 +55,11 @@ private static void BindBehaviourToNode(GameObject gameObject, FigmaImportProces { // Add in any special behaviours driven by name or other rules. If special case, dont add any more behaviours bool specialCaseNode=AddSpecialBehavioursToNode(gameObject,importProcessData); - if (specialCaseNode) return; + if (specialCaseNode) + { + Debug.Log($"Added special case behaviour to node {gameObject.name}, skipping further bindings"); + return; + } var bindingNameSpace = importProcessData.Settings.ScreenBindingNamespace; var className = $"{gameObject.name}"; @@ -72,11 +76,21 @@ private static void BindBehaviourToNode(GameObject gameObject, FigmaImportProces if (!matchingType.IsSubclassOf(typeof(MonoBehaviour))) { // Type found but is not a MonoBehaviour, cannot attach"); + Debug.Log($"Type found for {gameObject.name} with expected class name {className} in namespace {bindingNameSpace} is not a MonoBehaviour"); return; } // Make sure it doesnt already have this component attached (this can happen for nested components) var attachedBehaviour = gameObject.GetComponent(matchingType); - if (attachedBehaviour==null) attachedBehaviour=gameObject.AddComponent(matchingType); + if (attachedBehaviour==null) + { + attachedBehaviour=gameObject.AddComponent(matchingType); + + // Move component to the top of the inspector + for (int i = 0; i < gameObject.GetComponents().Length - 1; i++) + { + UnityEditorInternal.ComponentUtility.MoveComponentUp(attachedBehaviour); + } + } // Find all fields for this class, and if inherit from component, look to assign BindFieldsForComponent(gameObject, attachedBehaviour); @@ -107,7 +121,9 @@ public static void BindFieldsForComponent(GameObject gameObject, Component compo // Then check private fields FieldInfo[] privateSerializedFields=componentType.GetFields( BindingFlags.NonPublic | - BindingFlags.Instance); + BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.FlattenHierarchy); List allSerializedComponentFields = privateSerializedFields.Where(field => field.GetCustomAttribute(typeof(SerializeField)) != null).ToList(); // And add all public fields @@ -200,6 +216,13 @@ private static bool CheckNodeNameMatches(Transform transform, string nameMatch, if (caseInsensitive && transform.name == nameMatch) return true; if (!caseInsensitive && String.Equals(transform.name, nameMatch, StringComparison.CurrentCultureIgnoreCase)) return true; + // Normalize both strings for comparison (remove spaces, dashes, and underscores) + var normalizedTransformName = NormalizeFieldName(transform.name); + var normalizedNameMatch = NormalizeFieldName(nameMatch); + + if (normalizedTransformName == normalizedNameMatch) + return true; + // If this contains an underscore, check the substring after // This is to allow matches of fields such as m_ScoreLabel as "ScoreLabel" from figma doc if (nameMatch.Contains("_")) @@ -211,6 +234,19 @@ private static bool CheckNodeNameMatches(Transform transform, string nameMatch, return false; } + /// + /// Normalize field name by removing spaces, dashes, underscores and converting to lowercase + /// This allows matching Figma names like "My Label" or "my-label" to C# fields like "MyLabel" + /// + private static string NormalizeFieldName(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + // Remove spaces, dashes, and underscores, then convert to lowercase + return System.Text.RegularExpressions.Regex.Replace(name, @"[\s\-_]", "").ToLower(); + } + public static Type GetTypeByName(string nameSpace,string name) From fa505a3fdecea5070762cc676bae14907971c35b Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 19:55:10 +0300 Subject: [PATCH 07/13] Fix PrototypeFlowController null on prefab generation --- .../Editor/Nodes/FigmaAssetGenerator.cs | 75 +++++++++++++++++-- .../Editor/UnityFigmaBridgeImporter.cs | 61 +++------------ 2 files changed, 78 insertions(+), 58 deletions(-) diff --git a/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs b/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs index 47aad77..2aef8e8 100644 --- a/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs +++ b/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs @@ -21,10 +21,19 @@ public static class FigmaAssetGenerator /// /// Builds a native unity UI given input figma data /// - /// Root canvas for generation /// - public static void BuildFigmaFile(Canvas rootCanvas, FigmaImportProcessData figmaImportProcessData) + public static GameObject BuildFigmaFile(FigmaImportProcessData figmaImportProcessData) { + var root = new GameObject("FigmaRoot"); + var canvas = root.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.additionalShaderChannels = AdditionalCanvasShaderChannels.TexCoord1 | AdditionalCanvasShaderChannels.TexCoord2 | AdditionalCanvasShaderChannels.TexCoord3 | AdditionalCanvasShaderChannels.Normal | AdditionalCanvasShaderChannels.Tangent; + var canvasScaler = root.AddComponent(); + canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + canvasScaler.referenceResolution = new Vector2(1920, 1080); + var graphicRaycaster = root.AddComponent(); + graphicRaycaster.blockingObjects = GraphicRaycaster.BlockingObjects.TwoD; + // Save prefab for each page var downloadPageIdList = figmaImportProcessData.SelectedPagesForImport.Select(p => p.id).ToList(); @@ -34,7 +43,7 @@ public static void BuildFigmaFile(Canvas rootCanvas, FigmaImportProcessData figm { bool includedPageObject = downloadPageIdList.Contains(figmaCanvasNode.id); EditorUtility.DisplayProgressBar(UnityFigmaBridgeImporter.PROGRESS_BOX_TITLE, $"Generating Page {figmaCanvasNode.name} ", 0); - var pageGameObject = BuildFigmaPage(figmaCanvasNode, rootCanvas.transform as RectTransform, figmaImportProcessData,includedPageObject); + var pageGameObject = BuildFigmaPage(figmaCanvasNode, root.transform as RectTransform, figmaImportProcessData,includedPageObject); createdPages.Add((figmaCanvasNode,pageGameObject)); } @@ -59,6 +68,8 @@ public static void BuildFigmaFile(Canvas rootCanvas, FigmaImportProcessData figm // At the very end, we want to apply figmaNode behaviour where required BehaviourBindingManager.BindBehaviours(figmaImportProcessData); + + return root; } @@ -274,6 +285,8 @@ private static void SaveFigmaScreenAsPrefab(Node node, Node parentNode,RectTrans // If we are building the prototype flow, add this to the current flowScreen controller if (figmaImportProcessData.Settings.BuildPrototypeFlow) { + figmaImportProcessData.PrototypeFlowController = InitPrototypeFlowControllerOnScene(); + figmaImportProcessData.PrototypeFlowController.ClearFigmaScreens(); figmaImportProcessData.PrototypeFlowController.RegisterFigmaScreen(new FigmaFlowScreen { FigmaScreenPrefab = screenPrefab, @@ -307,10 +320,6 @@ private static void SaveFigmaPageAsPrefab(Node node, GameObject pageGameObject, figmaImportProcessData.PagePrefabs.Add(pagePrefab); } - - - - /// /// Registers a figma section. This is needed for flow controller to properly transition between sections /// @@ -343,6 +352,56 @@ private static void RegisterFigmaSection(Node node, FigmaImportProcessData figma }); } } - } + + /// + /// Initializes a prototype flow controller on the scene. This is required to build prototype flows, and will be added to any screen prefabs as part of generation. We have this as a separate method to ensure we can easily find and reference the controller from generated screens, without having to search through the scene for it. + /// + /// The initialized PrototypeFlowController + public static PrototypeFlowController InitPrototypeFlowControllerOnScene() + { + // First try to find existing PrototypeFlowController on the canvas + var s_PrototypeFlowController = Object.FindObjectOfType(); + // Only create if still not found + if (s_PrototypeFlowController == null) + { + // If doesnt exist create new one + var s_SceneCanvas = CreateCanvas(true); + s_PrototypeFlowController = s_SceneCanvas.gameObject.AddComponent(); + } + return s_PrototypeFlowController; + } + + /// + /// Creates a temporary canvas for generation if one doesn't already exist in the scene. This is required as we need a canvas to generate UI elements under, but we want to avoid creating multiple canvases if there are multiple screens being generated. We also want to avoid creating a canvas if we are generating a prototype flow and there is already a canvas with a PrototypeFlowController on it, as this will be used for generation instead. + /// + /// + /// + private static Canvas CreateCanvas(bool createEventSystem) + { + // Canvas + var canvasGameObject = new GameObject("Canvas"); + var canvas=canvasGameObject.AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvasGameObject.AddComponent(); + if (!createEventSystem) return canvas; + + var existingEventSystem = Object.FindObjectOfType(); + if (existingEventSystem == null) + { + // Create new event system + var eventSystemGameObject = new GameObject("EventSystem"); + existingEventSystem=eventSystemGameObject.AddComponent(); + } + + var pointerInputModule = Object.FindObjectOfType(); + if (pointerInputModule == null) + { + // TODO - Allow for new input system? + existingEventSystem.gameObject.AddComponent(); + } + + return canvas; + } + } } \ No newline at end of file diff --git a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs index eed66f0..f3542c1 100644 --- a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs +++ b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs @@ -214,20 +214,10 @@ private static bool CheckRunTimeRequirements() } } - // If doesnt exist create new one - if (s_SceneCanvas == null) - { - s_SceneCanvas = CreateCanvas(true); - } - // If we are building a prototype and settings allow, ensure we have a UI Controller component if (s_UnityFigmaBridgeSettings.BuildPrototypeFlow) { - // First try to find existing PrototypeFlowController on the canvas - s_PrototypeFlowController = Object.FindObjectOfType(); - // Only create if still not found - if (s_PrototypeFlowController == null) - s_PrototypeFlowController = s_SceneCanvas.gameObject.AddComponent(); + s_PrototypeFlowController = FigmaAssetGenerator.InitPrototypeFlowControllerOnScene(); } else { @@ -271,34 +261,6 @@ static bool RequestPersonalAccessToken() return false; } - - private static Canvas CreateCanvas(bool createEventSystem) - { - // Canvas - var canvasGameObject = new GameObject("Canvas"); - var canvas=canvasGameObject.AddComponent(); - canvas.renderMode = RenderMode.ScreenSpaceOverlay; - canvasGameObject.AddComponent(); - - if (!createEventSystem) return canvas; - - var existingEventSystem = Object.FindObjectOfType(); - if (existingEventSystem == null) - { - // Create new event system - var eventSystemGameObject = new GameObject("EventSystem"); - existingEventSystem=eventSystemGameObject.AddComponent(); - } - - var pointerInputModule = Object.FindObjectOfType(); - if (pointerInputModule == null) - { - // TODO - Allow for new input system? - existingEventSystem.gameObject.AddComponent(); - } - - return canvas; - } private static void ReportError(string message,string error) @@ -473,22 +435,17 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis if (figmaBridgeProcessData.PrototypeFlowController) figmaBridgeProcessData.PrototypeFlowController.ClearFigmaScreens(); } - else - { - // Only create new canvas if we don't have one already - if (s_SceneCanvas == null) - s_SceneCanvas = CreateCanvas(false); - } + GameObject root = null; try { - FigmaAssetGenerator.BuildFigmaFile(s_SceneCanvas, figmaBridgeProcessData); + root = FigmaAssetGenerator.BuildFigmaFile(figmaBridgeProcessData); } catch (Exception e) { ReportError("Error generating Figma document. Check log for details", e.ToString()); EditorUtility.ClearProgressBar(); - CleanUpPostGeneration(); + CleanUpPostGeneration(root); return; } @@ -544,7 +501,7 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis // Clean up orphaned prefabs (those that existed before but don't exist in the new import) FigmaPaths.CleanupOrphanedPrefabs(figmaBridgeProcessData); - CleanUpPostGeneration(); + CleanUpPostGeneration(root); EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); } @@ -552,13 +509,17 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis /// /// Clean up any leftover assets post-generation /// - private static void CleanUpPostGeneration() + private static void CleanUpPostGeneration(GameObject root) { - if (!s_UnityFigmaBridgeSettings.BuildPrototypeFlow) + if (!s_UnityFigmaBridgeSettings.BuildPrototypeFlow && s_SceneCanvas != null) { // Destroy temporary canvas Object.DestroyImmediate(s_SceneCanvas.gameObject); } + if(root != null) + { + Object.DestroyImmediate(root); + } } } } From e143ee20a85f7853a9943369212b79263c3842ce Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 21:06:23 +0300 Subject: [PATCH 08/13] Add: placeholder image support, no image downloading sync, offline parsing support --- .../Editor/FigmaApi/FigmaApiUtils.cs | 175 +++++++++- .../Editor/UnityFigmaBridgeImporter.cs | 309 +++++++++++++++--- 2 files changed, 428 insertions(+), 56 deletions(-) diff --git a/UnityFigmaBridge/Editor/FigmaApi/FigmaApiUtils.cs b/UnityFigmaBridge/Editor/FigmaApi/FigmaApiUtils.cs index 68c513e..9e163ba 100644 --- a/UnityFigmaBridge/Editor/FigmaApi/FigmaApiUtils.cs +++ b/UnityFigmaBridge/Editor/FigmaApi/FigmaApiUtils.cs @@ -130,6 +130,38 @@ public static async Task GetFigmaDocument(string fileId, string acces } /// + /// Load Figma document from cached file (previously downloaded) + /// + /// The cached FigmaFile or null if not found + public static FigmaFile LoadFigmaDocumentFromCache() + { + var cachePath = Path.Combine("Assets", WRITE_FILE_PATH); + + if (!File.Exists(cachePath)) + { + return null; + } + + try + { + var jsonContent = File.ReadAllText(cachePath); + JsonSerializerSettings settings = new JsonSerializerSettings() + { + DefaultValueHandling = DefaultValueHandling.Include, + MissingMemberHandling = MissingMemberHandling.Ignore, + NullValueHandling = NullValueHandling.Ignore, + }; + + var figmaFile = JsonConvert.DeserializeObject(jsonContent, settings); + Debug.Log($"Figma file loaded from cache: {figmaFile.name}"); + return figmaFile; + } + catch (Exception e) + { + Debug.LogError($"Error loading cached Figma document: {e}"); + return null; + } + } /// Requests a server-side rendering of nodes from a document, returning list of urls to download /// /// Figma File Id @@ -245,24 +277,24 @@ public static async Task GetFigmaFileNodes(string fileId, string /// Generates a standardised list of files to download /// /// - /// /// /// /// - public static List GenerateDownloadQueue(FigmaImageFillData imageFillData,List foundImageFills,List serverRenderData,List serverRenderNodes) + public static List GenerateDownloadQueue(FigmaImageFillData imageFillData, List serverRenderData,List serverRenderNodes) { // Check if each image fill file has already been downloaded. If not, add to download list //Dictionary filteredImageFillList = new Dictionary(); List downloadList = new List(); - foreach (var keyPair in imageFillData.meta.images) + foreach (var keyPair in imageFillData.meta?.images ?? new Dictionary()) { + var path = FigmaPaths.GetPathForImageFill(keyPair.Key); // Only download if it is used in the document and not already downloaded - if (foundImageFills.Contains(keyPair.Key) && !File.Exists(FigmaPaths.GetPathForImageFill(keyPair.Key))) + if (!File.Exists(path) || IsPlaceholderImage(path)) { downloadList.Add(new FigmaDownloadQueueItem { Url=keyPair.Value, - FilePath = FigmaPaths.GetPathForImageFill(keyPair.Key), + FilePath = path, FileType = FigmaDownloadQueueItem.FigmaFileType.ImageFill }); } @@ -273,18 +305,19 @@ public static List GenerateDownloadQueue(FigmaImageFillD { foreach (var keyPair in serverRenderDataEntry.images) { + var path = FigmaPaths.GetPathForServerRenderedImage(keyPair.Key, serverRenderNodes); if (string.IsNullOrEmpty(keyPair.Value)) { // if the url is invalid... Debug.Log($"Can't download image for Server Node {keyPair.Key}"); } - else + else if (!File.Exists(path) || IsPlaceholderImage(path)) { // Always overwrite as may have changed downloadList.Add(new FigmaDownloadQueueItem { Url = keyPair.Value, - FilePath = FigmaPaths.GetPathForServerRenderedImage(keyPair.Key, serverRenderNodes), + FilePath = path, FileType = FigmaDownloadQueueItem.FigmaFileType.ServerRenderedImage }); } @@ -358,6 +391,7 @@ public static async Task DownloadFiles(List downloadItem } downloadIndex++; } + AssetDatabase.Refresh(); } @@ -369,6 +403,133 @@ public static void CheckExistingAssetProperties() CheckImageFillTextureProperties(); } + /// + /// Check if a file is a placeholder image (2x2 gray PNG) + /// + public static bool IsPlaceholderImage(string filePath) + { + try + { + if (!File.Exists(filePath)) + return false; + + // Placeholder images are very small (2x2 PNGs are typically < 1KB) + var fileInfo = new FileInfo(filePath); + if (fileInfo.Length > 10000) // Larger than 10KB = not a placeholder + return false; + + // Try to load and check dimensions + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); + byte[] fileData = File.ReadAllBytes(filePath); + if (texture.LoadImage(fileData)) + { + bool isPlaceholder = texture.width == 2 && texture.height == 2; + UnityEngine.Object.DestroyImmediate(texture); + return isPlaceholder; + } + UnityEngine.Object.DestroyImmediate(texture); + } + catch + { + // If we can't determine, assume it's not a placeholder + } + + return false; + } + + /// + /// Create placeholder only if file doesn't exist + /// + public static bool CreatePlaceholderImageIfNotExists(string filePath) + { + if (File.Exists(filePath)) + return false; // Don't overwrite existing file + + CreatePlaceholderImage(filePath); + return true; + } + + /// + /// Create a 2x2 placeholder PNG for failed downloads + /// + public static void CreatePlaceholderImage(string filePath) + { + try + { + // Create a 2x2 transparent PNG texture + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); + var pixels = new Color32[4]; + + // Make it semi-transparent gray (placeholder color) + for (int i = 0; i < 4; i++) + { + pixels[i] = new Color32(128, 128, 128, 128); + } + + texture.SetPixels32(pixels); + texture.Apply(); + + // Encode to PNG and save + byte[] pngData = texture.EncodeToPNG(); + + var directoryPath = Path.GetDirectoryName(filePath); + if (!Directory.Exists(directoryPath)) + Directory.CreateDirectory(directoryPath); + + File.WriteAllBytes(filePath, pngData); + + // Clean up texture + UnityEngine.Object.DestroyImmediate(texture); + + // Refresh the asset database to ensure the asset has been created + AssetDatabase.ImportAsset(filePath); + AssetDatabase.Refresh(); + + // Set the properties for the texture, to mark as a sprite and with alpha transparency and no compression + TextureImporter textureImporter = (TextureImporter) AssetImporter.GetAtPath(filePath); + textureImporter.textureType = TextureImporterType.Sprite; + textureImporter.spriteImportMode = SpriteImportMode.Single; + textureImporter.alphaIsTransparency = true; + textureImporter.mipmapEnabled = true; // We'll enable mip maps to stop issues at lower resolutions + textureImporter.textureCompression = TextureImporterCompression.Uncompressed; + textureImporter.sRGBTexture = true; + textureImporter.wrapMode = TextureWrapMode.Clamp; + textureImporter.SaveAndReimport(); + + Debug.Log($"Created placeholder image at {filePath}"); + } + catch (Exception e) + { + Debug.LogError($"Failed to create placeholder image: {e.Message}"); + } + } + + + /// + /// Create 2x2 placeholder PNG files for nodes that failed to download + /// + public static int CreatePlaceholderImagesForBatch(string folderPath, List nodeIds) + { + int placeholderCount = 0; + foreach (var nodeId in nodeIds) + { + var placeholderPath = $"{folderPath}/{nodeId}.png"; + try + { + // Only create if doesn't exist - keep existing successful downloads + if (CreatePlaceholderImageIfNotExists(placeholderPath)) + { + placeholderCount++; + } + } + catch (Exception e) + { + Debug.LogWarning($"Failed to create placeholder for {nodeId}: {e.Message}"); + } + } + return placeholderCount; + } + /// /// Checks downloaded image fills /// diff --git a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs index f3542c1..5b04f51 100644 --- a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs +++ b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs @@ -59,13 +59,54 @@ public static class UnityFigmaBridgeImporter /// private static PrototypeFlowController s_PrototypeFlowController; - [MenuItem("Figma Bridge/Sync Document")] - static void Sync() + [MenuItem("Figma Bridge/Sync ALL", priority = 0, secondaryPriority = 0)] + static void SyncAll() { SyncAsync(); } + [MenuItem("Figma Bridge/Reprocess Downloaded Document", priority = 2, secondaryPriority = 0)] + static void ReprocessDownloadedDocument() + { + ReprocessDocumentAsync(); + } + + + [MenuItem("Figma Bridge/Sync Document (No Image)", priority = 3, secondaryPriority = 0)] + static void SyncDocument() + { + SyncDocumentAsync(); + } + + [MenuItem("Figma Bridge/Download Server Rendered Images", priority = 15)] + static void DownloadServerRenderedImages() + { + SyncServerRenderedImagesAsync(null); + } + + [MenuItem("Figma Bridge/Download Image Fills", priority = 16)] + static void DownloadImageFills() + { + SyncImageFillsAsync(null); + } + + private static async void SyncAsync() + { + await SyncDocumentAsync(); + var figmaFile = FigmaApiUtils.LoadFigmaDocumentFromCache(); + await SyncServerRenderedImagesAsync(figmaFile); + await SyncImageFillsAsync(figmaFile); + } + + private static async void ReprocessDocumentAsync() + { + var figmaFile = FigmaApiUtils.LoadFigmaDocumentFromCache(); + await SyncServerRenderedImagesAsync(figmaFile); + await SyncImageFillsAsync(figmaFile); + } + + private static async Task SyncDocumentAsync() { var requirementsMet = CheckRequirements(); if (!requirementsMet) return; @@ -110,7 +151,204 @@ private static async void SyncAsync() } await ImportDocument(s_UnityFigmaBridgeSettings.FileId, figmaFile, pageNodeList); + } + + /// + /// Download server rendered images separately with rate limiting to avoid 429 errors + /// + private static async Task SyncServerRenderedImagesAsync(FigmaFile figmaFile) + { + var requirementsMet = CheckRequirements(checkPrototypeFlow: false); + if (!requirementsMet) return; + + EditorUtility.DisplayProgressBar(PROGRESS_BOX_TITLE, "Loading cached Figma document...", 0); + + // Load from cache instead of downloading + if (figmaFile == null) + { + figmaFile = FigmaApiUtils.LoadFigmaDocumentFromCache(); + } + if (figmaFile == null) + { + EditorUtility.ClearProgressBar(); + ReportError("No cached Figma document found", "Please run 'Sync Document' first to download the Figma file before syncing server rendered images."); + return; + } + + var pageNodeList = FigmaDataUtils.GetPageNodes(figmaFile); + + if (s_UnityFigmaBridgeSettings.OnlyImportSelectedPages) + { + var enabledPageIdList = s_UnityFigmaBridgeSettings.PageDataList.Where(p => p.Selected).Select(p => p.NodeId).ToList(); + pageNodeList = pageNodeList.Where(p => enabledPageIdList.Contains(p.id)).ToList(); + } + + var downloadPageIdList = pageNodeList.Select(p => p.id).ToList(); + var externalComponentList = FigmaDataUtils.FindMissingComponentDefinitions(figmaFile); + var serverRenderNodes = FigmaDataUtils.FindAllServerRenderNodesInFile(figmaFile, externalComponentList, downloadPageIdList); + + if (serverRenderNodes.Count == 0) + { + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog("No Server Rendered Images", "No complex shapes that require server rendering were found in the document.", "OK"); + return; + } + + EditorUtility.DisplayProgressBar(PROGRESS_BOX_TITLE, "Downloading server rendered images...", 0); + + try + { + // Request a render of these nodes on the server if required + var serverRenderData=new List(); + if (serverRenderNodes.Count > 0) + { + var allNodeIds = serverRenderNodes.Select(serverRenderNode => serverRenderNode.SourceNode.id).ToList(); + // As the API has an upper limit of images that can be rendered in a single request, we'll need to batch + var batchCount = Mathf.CeilToInt((float)allNodeIds.Count / MAX_SERVER_RENDER_IMAGE_BATCH_SIZE); + for (var i = 0; i < batchCount; i++) + { + var startIndex = i * MAX_SERVER_RENDER_IMAGE_BATCH_SIZE; + var nodeBatch = allNodeIds.GetRange(startIndex, + Mathf.Min(MAX_SERVER_RENDER_IMAGE_BATCH_SIZE, allNodeIds.Count - startIndex)); + var serverNodeCsvList = string.Join(",", nodeBatch); + EditorUtility.DisplayProgressBar(PROGRESS_BOX_TITLE, $"Downloading server-rendered image data {i+1}/{batchCount}",(float)i/(float)batchCount); + try + { + var figmaTask = FigmaApiUtils.GetFigmaServerRenderData(s_UnityFigmaBridgeSettings.FileId, s_PersonalAccessToken, + serverNodeCsvList, s_UnityFigmaBridgeSettings.ServerRenderImageScale); + await figmaTask; + serverRenderData.Add(figmaTask.Result); + } + catch (Exception e) + { + EditorUtility.ClearProgressBar(); + ReportError("Error downloading Figma Server Render Image Data", e.ToString()); + return; + } + } + } + + // Process server rendered images in batches to avoid rate limiting (429 errors) + var nodeIds = serverRenderNodes.Select(n => n.SourceNode.id).ToList(); + var successCount = 0; + var failureCount = 0; + EditorUtility.DisplayProgressBar( + PROGRESS_BOX_TITLE, + $"Downloading server rendered images", 0); + + try + { + if (serverRenderData != null) + { + // Generate download list from the rendered images + var downloadList = FigmaApiUtils.GenerateDownloadQueue(new FigmaImageFillData(), + serverRenderData, + serverRenderNodes); + + if (downloadList.Count > 0) + { + // Download all files in this batch + await FigmaApiUtils.DownloadFiles(downloadList, s_UnityFigmaBridgeSettings); + successCount += downloadList.Count; + } + } + } + catch (Exception e) + { + Debug.LogError($"Error downloading: {e}"); + + // Create placeholder images for failed batch + nodeIds = nodeIds.Select(i => FigmaDataUtils.ReplaceUnsafeFileCharactersForNodeId(i)).ToList(); + var placeholderCount = FigmaApiUtils.CreatePlaceholderImagesForBatch(FigmaPaths.FigmaServerRenderedImagesFolder, nodeIds); + } + + // Small delay between batches to respect rate limits + await Task.Delay(1000); + EditorUtility.ClearProgressBar(); + AssetDatabase.Refresh(); + Debug.Log($"Server rendered images sync completed. {successCount} images downloaded successfully, {failureCount} failed (placeholders created)."); + } + catch (Exception e) + { + EditorUtility.ClearProgressBar(); + ReportError("Error downloading server rendered images", e.ToString()); + } + } + + /// + /// Download image fills from Figma document + /// + private static async Task SyncImageFillsAsync(FigmaFile figmaFile) + { + var requirementsMet = CheckRequirements(checkPrototypeFlow: false); + if (!requirementsMet) return; + + EditorUtility.DisplayProgressBar(PROGRESS_BOX_TITLE, "Loading cached Figma document...", 0); + + + // Load from cache instead of downloading + if(figmaFile == null) + { + figmaFile = FigmaApiUtils.LoadFigmaDocumentFromCache(); + } + if (figmaFile == null) + { + EditorUtility.ClearProgressBar(); + ReportError("No cached Figma document found", "Please run 'Sync Document' first to download the Figma file before syncing image fills."); + return; + } + + var pageNodeList = FigmaDataUtils.GetPageNodes(figmaFile); + + if (s_UnityFigmaBridgeSettings.OnlyImportSelectedPages) + { + var enabledPageIdList = s_UnityFigmaBridgeSettings.PageDataList.Where(p => p.Selected).Select(p => p.NodeId).ToList(); + pageNodeList = pageNodeList.Where(p => enabledPageIdList.Contains(p.id)).ToList(); + } + + var downloadPageIdList = pageNodeList.Select(p => p.id).ToList(); + var foundImageFills = FigmaDataUtils.GetAllImageFillIdsFromFile(figmaFile, downloadPageIdList); + + if (foundImageFills.Count == 0) + { + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog("No Image Fills", "No image fills found in the document.", "OK"); + return; + } + + EditorUtility.DisplayProgressBar(PROGRESS_BOX_TITLE, "Downloading image fill data...", 0); + + try + { + // Get image fill data + var figmaTask = FigmaApiUtils.GetDocumentImageFillData(s_UnityFigmaBridgeSettings.FileId, s_PersonalAccessToken); + await figmaTask; + var activeFigmaImageFillData = figmaTask.Result; + + // Generate download list for image fills only + var downloadList = FigmaApiUtils.GenerateDownloadQueue(activeFigmaImageFillData, new List(), new List()); + + if (downloadList.Count == 0) + { + EditorUtility.ClearProgressBar(); + EditorUtility.DisplayDialog("No Images to Download", "All image fills are already up to date.", "OK"); + return; + } + + // Download all image files + await FigmaApiUtils.DownloadFiles(downloadList, s_UnityFigmaBridgeSettings); + + EditorUtility.ClearProgressBar(); + AssetDatabase.Refresh(); + Debug.Log($"Image fills sync completed. {downloadList.Count} images processed."); + } + catch (Exception e) + { + Debug.LogError($"Error syncing image fills: {e}"); + EditorUtility.ClearProgressBar(); + ReportError("Error downloading image fills", e.ToString()); + } } /// @@ -342,34 +580,13 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis // First up create a list of nodes we'll substitute with rendered images var serverRenderNodes = FigmaDataUtils.FindAllServerRenderNodesInFile(figmaFile,externalComponentList,downloadPageIdList); - // Request a render of these nodes on the server if required - var serverRenderData=new List(); + // For now, skip downloading server-rendered images during normal sync to avoid rate limiting + // Users can use "Sync Server Rendered Images" menu item to download them separately with rate limiting if (serverRenderNodes.Count > 0) { - var allNodeIds = serverRenderNodes.Select(serverRenderNode => serverRenderNode.SourceNode.id).ToList(); - // As the API has an upper limit of images that can be rendered in a single request, we'll need to batch - var batchCount = Mathf.CeilToInt((float)allNodeIds.Count / MAX_SERVER_RENDER_IMAGE_BATCH_SIZE); - for (var i = 0; i < batchCount; i++) - { - var startIndex = i * MAX_SERVER_RENDER_IMAGE_BATCH_SIZE; - var nodeBatch = allNodeIds.GetRange(startIndex, - Mathf.Min(MAX_SERVER_RENDER_IMAGE_BATCH_SIZE, allNodeIds.Count - startIndex)); - var serverNodeCsvList = string.Join(",", nodeBatch); - EditorUtility.DisplayProgressBar(PROGRESS_BOX_TITLE, $"Downloading server-rendered image data {i+1}/{batchCount}",(float)i/(float)batchCount); - try - { - var figmaTask = FigmaApiUtils.GetFigmaServerRenderData(fileId, s_PersonalAccessToken, - serverNodeCsvList, s_UnityFigmaBridgeSettings.ServerRenderImageScale); - await figmaTask; - serverRenderData.Add(figmaTask.Result); - } - catch (Exception e) - { - EditorUtility.ClearProgressBar(); - ReportError("Error downloading Figma Server Render Image Data", e.ToString()); - return; - } - } + var allNodeIds = serverRenderNodes.Select(serverRenderNode => FigmaDataUtils.ReplaceUnsafeFileCharactersForNodeId(serverRenderNode.SourceNode.id)).ToList(); + var placeholderCount = FigmaApiUtils.CreatePlaceholderImagesForBatch(FigmaPaths.FigmaServerRenderedImagesFolder, allNodeIds); + Debug.Log($"Created {placeholderCount} placeholder images for server rendered nodes. Use 'Sync Server Rendered Images' to download actual images with rate limiting."); } // Make sure that existing downloaded assets are in the correct format @@ -394,13 +611,13 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis return; } - // Generate a list of all items that need to be downloaded - var downloadList = - FigmaApiUtils.GenerateDownloadQueue(activeFigmaImageFillData,foundImageFills, serverRenderData, serverRenderNodes); - - // Download all required files - await FigmaApiUtils.DownloadFiles(downloadList, s_UnityFigmaBridgeSettings); - + // Create placeholders for image fills + if (foundImageFills.Count > 0) + { + var imageFillIds = foundImageFills.Select(id => FigmaDataUtils.ReplaceUnsafeFileCharactersForNodeId(id)).ToList(); + var placeholderCount = FigmaApiUtils.CreatePlaceholderImagesForBatch(FigmaPaths.FigmaImageFillFolder, imageFillIds); + Debug.Log($"Created {placeholderCount} placeholder images for image fills."); + } // Generate font mapping data var figmaFontMapTask = FontManager.GenerateFontMapForDocument(figmaFile, @@ -414,20 +631,14 @@ private static async Task ImportDocument(string fileId, FigmaFile figmaFile, Lis MissingComponentDefinitionsList = externalComponentList, }; - // Stores necessary importer data needed for document generator. - var figmaBridgeProcessData = new FigmaImportProcessData - { - Settings=s_UnityFigmaBridgeSettings, - SourceFile = figmaFile, - ComponentData = componentData, - ServerRenderNodes = serverRenderNodes, - PrototypeFlowController = s_PrototypeFlowController, - FontMap = fontMap, - PrototypeFlowStartPoints = FigmaDataUtils.GetAllPrototypeFlowStartingPoints(figmaFile), - SelectedPagesForImport = downloadPageNodeList, - NodeLookupDictionary = FigmaDataUtils.BuildNodeLookupDictionary(figmaFile) - }; - + // Update the process data with remaining info + figmaBridgeProcessData.ComponentData = componentData; + figmaBridgeProcessData.ServerRenderNodes = serverRenderNodes; + figmaBridgeProcessData.PrototypeFlowController = s_PrototypeFlowController; + figmaBridgeProcessData.FontMap = fontMap; + figmaBridgeProcessData.PrototypeFlowStartPoints = FigmaDataUtils.GetAllPrototypeFlowStartingPoints(figmaFile); + figmaBridgeProcessData.SelectedPagesForImport = downloadPageNodeList; + figmaBridgeProcessData.NodeLookupDictionary = FigmaDataUtils.BuildNodeLookupDictionary(figmaFile); // Clear the existing screens on the flowScreen controller if (s_UnityFigmaBridgeSettings.BuildPrototypeFlow) From c7be28a9d9b6009c279490f2f118caaf4a3adfa7 Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 21:31:47 +0300 Subject: [PATCH 09/13] Update readme --- README.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c4997f8..d227735 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,17 @@ the specified screen as per the Figma document. Currently there is a dew ## Syncing your Figma Document -* Click Figma Bridge → Sync Document -* Enter your Personal Access Token (this will be stored in your Player Prefs for future use) -* It will ask if you want to use the current scene to generate prototype flow - Click yes +Click on the appropriate menu item under **Figma Bridge** to sync your document: + +* **Figma Bridge → Sync ALL** - Complete sync including document download and all assets (recommended for initial setup) +* **Figma Bridge → Sync Document (No Image)** - Downloads only the document structure without downloading image assets +* **Figma Bridge → Reprocess Downloaded Document** - Reprocess the currently cached Figma document without re-downloading +* **Figma Bridge → Download Server Rendered Images** - Separately download complex vector shapes (useful for updating only rendered assets) +* **Figma Bridge → Download Image Fills** - Separately download image fills (useful for updating only image assets) + +You will need to: +* Enter or confirm your Personal Access Token (this will be stored in your Player Prefs for future use) +* Choose whether you want to use the current scene to generate prototype flow when prompted ## Selecting Figma Pages @@ -92,6 +100,12 @@ assets imported. Any page that is not selected will have the following rules: | **Pages** | Prefabs of each complete page are stored in the *Pages* folder | | **Vectors** | Rendered on the server as a PNG (see *Server Rendering* nelow) | +* **Placeholder Images** - When image downloads fail (due to network issues, rate limiting, etc.), placeholder images (2x2 gray PNG) are automatically created +* **Failed Downloads Can Be Retried** - Use the "Download Server Rendered Images" or "Download Image Fills" menu items to re-attempt downloads - placeholders are automatically replaced with actual images +* **No Broken References** - Placeholder images ensure your UI continues to render even if some images fail to download + +This is particularly useful when dealing with the Figma API's rate limiting (429 errors) on documents with many server-rendered shapes. + ## Fonts With the goal of trying to make it 1-click sync, if the font doesnt exist in your project it will try and download a @@ -167,6 +181,14 @@ uses reflection to do the following: For example, I could add ```public TextMeshPro_UGUI Title``` and if there is a text object called *Title* then it will be assigned to that field. +### Advanced Field Binding +The field binding system now supports multiple naming conventions, making it more flexible: +* **Exact Case-Insensitive Match** - "MyButton" field matches "mybutton" object +* **Normalized Name Matching** - "My_Play_Button" field matches "My Play Button" or "my-play-button" object (spaces, dashes, and underscores are ignored) +* **Underscore-Prefixed Fields** - Fields like "m_ScoreLabel" will match objects named "ScoreLabel" + +This allows your Figma document to use natural naming (with spaces and dashes) that will be automatically matched to C# field names. + ![Button Press Binding](/Docs/ButtonPressBinding.png) * If you add the ```[[BindFigmaButtonPress("PlayButton")]]``` attribute to a method, then it will add an onClick From aa3471884ae67df007e28c7dd8aaf8d84e266522 Mon Sep 17 00:00:00 2001 From: Bilal Date: Mon, 9 Feb 2026 23:39:57 +0300 Subject: [PATCH 10/13] Fix: offline parsing for prefabs not working, Add auto canvas resolution about figma size --- .../Editor/Nodes/FigmaAssetGenerator.cs | 2 +- .../PrototypeFlow/BehaviourBindingManager.cs | 8 ++++++-- .../Editor/UnityFigmaBridgeImporter.cs | 15 ++++++++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs b/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs index 2aef8e8..53ca6a9 100644 --- a/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs +++ b/UnityFigmaBridge/Editor/Nodes/FigmaAssetGenerator.cs @@ -273,7 +273,7 @@ private static void SaveFigmaScreenAsPrefab(Node node, Node parentNode,RectTrans // Enhance screen with UI components if enabled if (figmaImportProcessData.Settings.EnhanceScreensWithUIComponents) { - BehaviourBindingManager.EnhanceScreenWithComponents(screenRectTransform.gameObject); + BehaviourBindingManager.EnhanceScreenWithComponents(screenRectTransform.gameObject, node.size.x, node.size.y); } // Write prefab diff --git a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs index 2f21eb7..3c25b33 100644 --- a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs +++ b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs @@ -20,7 +20,10 @@ public static class BehaviourBindingManager /// /// Add essential UI components to screens if they're missing (Canvas, CanvasScaler, GraphicRaycaster) /// - public static void EnhanceScreenWithComponents(GameObject screenGameObject) + /// The screen GameObject to enhance + /// Reference width from Figma (default: 1920) + /// Reference height from Figma (default: 1080) + public static void EnhanceScreenWithComponents(GameObject screenGameObject, float screenWidth = 1920f, float screenHeight = 1080f) { // Add Canvas if missing if (screenGameObject.GetComponent() == null) @@ -35,7 +38,8 @@ public static void EnhanceScreenWithComponents(GameObject screenGameObject) { var canvasScaler = screenGameObject.AddComponent(); canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; - canvasScaler.referenceResolution = new Vector2(1920, 1080); + // Use Figma screen dimensions as reference resolution + canvasScaler.referenceResolution = new Vector2(screenWidth, screenHeight); } // Add GraphicRaycaster if missing diff --git a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs index 5b04f51..39e035b 100644 --- a/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs +++ b/UnityFigmaBridge/Editor/UnityFigmaBridgeImporter.cs @@ -65,8 +65,8 @@ static void SyncAll() SyncAsync(); } - [MenuItem("Figma Bridge/Reprocess Downloaded Document", priority = 2, secondaryPriority = 0)] - static void ReprocessDownloadedDocument() + [MenuItem("Figma Bridge/Reprocess Cached Document", priority = 2, secondaryPriority = 0)] + static void ReprocessCachedDocument() { ReprocessDocumentAsync(); } @@ -102,6 +102,7 @@ private static async void SyncAsync() private static async void ReprocessDocumentAsync() { var figmaFile = FigmaApiUtils.LoadFigmaDocumentFromCache(); + await ProcessDocument(figmaFile); await SyncServerRenderedImagesAsync(figmaFile); await SyncImageFillsAsync(figmaFile); } @@ -114,6 +115,14 @@ private static async Task SyncDocumentAsync() var figmaFile = await DownloadFigmaDocument(s_UnityFigmaBridgeSettings.FileId); if (figmaFile == null) return; + await ProcessDocument(figmaFile); + } + + private static async Task ProcessDocument(FigmaFile figmaFile) + { + var requirementsMet = CheckRequirements(); + if (!requirementsMet) return; + var pageNodeList = FigmaDataUtils.GetPageNodes(figmaFile); if (s_UnityFigmaBridgeSettings.OnlyImportSelectedPages) @@ -152,7 +161,7 @@ private static async Task SyncDocumentAsync() await ImportDocument(s_UnityFigmaBridgeSettings.FileId, figmaFile, pageNodeList); } - + /// /// Download server rendered images separately with rate limiting to avoid 429 errors /// From cd5a2259d25319cbb4363d7ffff172519c82c6d4 Mon Sep 17 00:00:00 2001 From: Bilal Date: Tue, 10 Feb 2026 03:08:41 +0300 Subject: [PATCH 11/13] Fix: set anchor to center for root Nodes --- .../Editor/Nodes/NodeTransformManager.cs | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs b/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs index 8e5b7ad..571ff26 100644 --- a/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs +++ b/UnityFigmaBridge/Editor/Nodes/NodeTransformManager.cs @@ -81,7 +81,14 @@ public static void ApplyFigmaTransform(RectTransform targetRectTransform, Node f private static void ApplyFigmaConstraints(RectTransform targetRectTransform, Node figmaNode,Node figmaParentNode) { // Setup anchor positions - (targetRectTransform.anchorMin, targetRectTransform.anchorMax) = AnchorPositionsForFigmaConstraints(figmaNode.constraints); + if(figmaParentNode.size == null) + { + (targetRectTransform.anchorMin, targetRectTransform.anchorMax) = (new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f)); + } + else + { + (targetRectTransform.anchorMin, targetRectTransform.anchorMax) = AnchorPositionsForFigmaConstraints(figmaNode.constraints); + } // We'll need to use the size of the parent node to determine anchor position var parentNodeSize = figmaParentNode.size != null ? figmaParentNode.size : new Vector { x = 0, y = 0 }; @@ -107,24 +114,26 @@ private static void ApplyFigmaConstraints(RectTransform targetRectTransform, Nod targetRectTransform.anchoredPosition = anchoredPosition; - switch (figmaNode.constraints.horizontal) + if(figmaParentNode.size != null) { - case LayoutConstraint.HorizontalLayoutConstraint.LEFT_RIGHT: - case LayoutConstraint.HorizontalLayoutConstraint.SCALE: - var sizeDelta = targetRectTransform.sizeDelta; - targetRectTransform.sizeDelta = new Vector2(sizeDelta.x-parentNodeSize.x, sizeDelta.y); - break; - } + switch (figmaNode.constraints.horizontal) + { + case LayoutConstraint.HorizontalLayoutConstraint.LEFT_RIGHT: + case LayoutConstraint.HorizontalLayoutConstraint.SCALE: + var sizeDelta = targetRectTransform.sizeDelta; + targetRectTransform.sizeDelta = new Vector2(sizeDelta.x - parentNodeSize.x, sizeDelta.y); + break; + } - switch (figmaNode.constraints.vertical) - { - case LayoutConstraint.VerticalLayoutConstraint.TOP_BOTTOM: - case LayoutConstraint.VerticalLayoutConstraint.SCALE: - var sizeDelta = targetRectTransform.sizeDelta; - targetRectTransform.sizeDelta = new Vector2(sizeDelta.x, sizeDelta.y - parentNodeSize.y); - break; + switch (figmaNode.constraints.vertical) + { + case LayoutConstraint.VerticalLayoutConstraint.TOP_BOTTOM: + case LayoutConstraint.VerticalLayoutConstraint.SCALE: + var sizeDelta = targetRectTransform.sizeDelta; + targetRectTransform.sizeDelta = new Vector2(sizeDelta.x, sizeDelta.y - parentNodeSize.y); + break; + } } - } /// From b0288249ce474679141cd87d4abcc5356b1be5b9 Mon Sep 17 00:00:00 2001 From: Bilal Date: Wed, 11 Feb 2026 00:27:26 +0300 Subject: [PATCH 12/13] Add automatically bind components to nodes --- .../PrototypeFlow/BehaviourBindingManager.cs | 178 +++++++++++++----- .../Settings/UnityFigmaBridgeSettings.cs | 33 +++- 2 files changed, 165 insertions(+), 46 deletions(-) diff --git a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs index 3c25b33..85e83dc 100644 --- a/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs +++ b/UnityFigmaBridge/Editor/PrototypeFlow/BehaviourBindingManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Reflection; using UnityEditor; @@ -57,14 +58,6 @@ public static void EnhanceScreenWithComponents(GameObject screenGameObject, floa /// private static void BindBehaviourToNode(GameObject gameObject, FigmaImportProcessData importProcessData) { - // Add in any special behaviours driven by name or other rules. If special case, dont add any more behaviours - bool specialCaseNode=AddSpecialBehavioursToNode(gameObject,importProcessData); - if (specialCaseNode) - { - Debug.Log($"Added special case behaviour to node {gameObject.name}, skipping further bindings"); - return; - } - var bindingNameSpace = importProcessData.Settings.ScreenBindingNamespace; var className = $"{gameObject.name}"; @@ -77,72 +70,73 @@ private static void BindBehaviourToNode(GameObject gameObject, FigmaImportProces } //Debug.Log($"Matching type found {className}"); - if (!matchingType.IsSubclassOf(typeof(MonoBehaviour))) + if (!matchingType.IsSubclassOf(typeof(Component))) { - // Type found but is not a MonoBehaviour, cannot attach"); - Debug.Log($"Type found for {gameObject.name} with expected class name {className} in namespace {bindingNameSpace} is not a MonoBehaviour"); + // Type found but is not a Component, cannot attach"); + Debug.Log($"Type found for {gameObject.name} with expected class name {className} in namespace {bindingNameSpace} is not a Component"); return; } // Make sure it doesnt already have this component attached (this can happen for nested components) var attachedBehaviour = gameObject.GetComponent(matchingType); if (attachedBehaviour==null) { - attachedBehaviour=gameObject.AddComponent(matchingType); + attachedBehaviour = gameObject.AddComponent(matchingType); - // Move component to the top of the inspector - for (int i = 0; i < gameObject.GetComponents().Length - 1; i++) - { - UnityEditorInternal.ComponentUtility.MoveComponentUp(attachedBehaviour); + // Move component to the top of the inspector + for (int i = 0; i < gameObject.GetComponents().Length - 1; i++) + { + UnityEditorInternal.ComponentUtility.MoveComponentUp(attachedBehaviour); + } } - } // Find all fields for this class, and if inherit from component, look to assign BindFieldsForComponent(gameObject, attachedBehaviour); - - } - - private static bool AddSpecialBehavioursToNode(GameObject gameObject, FigmaImportProcessData importProcessData) - { - if (gameObject.name.ToUpper() == "SAFEAREA") - { - // Add in a safe area component for correct resizing - if (gameObject.GetComponent() == null) - { - gameObject.AddComponent(); - // Also move pivot to top left, to make offset calc a bit easier - //FigmaDocumentUtils.SetPivot(gameObject.transform as RectTransform, new Vector2(0,1)); - return true; - } - } - return false; } public static void BindFieldsForComponent(GameObject gameObject, Component component) { var componentType = component.GetType(); - // Then check private fields - FieldInfo[] privateSerializedFields=componentType.GetFields( + // Get all fields (public and private) + FieldInfo[] allFields = componentType.GetFields( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | - BindingFlags.Public | - BindingFlags.FlattenHierarchy); - List allSerializedComponentFields = privateSerializedFields.Where(field => field.GetCustomAttribute(typeof(SerializeField)) != null).ToList(); + BindingFlags.IgnoreCase); + + // Filter to only serializable fields (Unity serialization rules) + List serializableFields = new List(); - // And add all public fields - allSerializedComponentFields.AddRange(componentType.GetFields()); + foreach (var field in allFields) + { + // Skip if marked with NonSerialized attribute + if (field.GetCustomAttribute(typeof(NonSerializedAttribute)) != null) + continue; + + // Check public fields - they serialize by default (unless NonSerialized) + if (field.IsPublic) + { + serializableFields.Add(field); + } + // Check private/internal fields - they only serialize if marked with SerializeField + else if (field.GetCustomAttribute(typeof(SerializeField)) != null) + { + serializableFields.Add(field); + } + } - foreach (var field in allSerializedComponentFields) + // Now bind values to serializable fields + foreach (var field in serializableFields) { var fieldType = field.FieldType; // See if there is a child transform with matching name (case insensitive) - var matchingTransform = GetChildTransformByName(gameObject.transform, field.Name, true,MAX_SEARCH_DEPTH_FOR_TRANSFORMS); + var matchingTransform = GetChildTransformByName(gameObject.transform, field.Name, true, MAX_SEARCH_DEPTH_FOR_TRANSFORMS); if (matchingTransform) { if (fieldType == typeof(GameObject)) { - field.SetValue(component,matchingTransform.gameObject); + field.SetValue(component, matchingTransform.gameObject); } else if (fieldType.IsSubclassOf(typeof(Component))) { @@ -151,7 +145,7 @@ public static void BindFieldsForComponent(GameObject gameObject, Component compo if (matchingComponent) { // Found matching component - set - field.SetValue(component,matchingComponent); + field.SetValue(component, matchingComponent); } } } @@ -275,6 +269,30 @@ public static Type GetTypeByName(string nameSpace,string name) return null; } + /// + /// Get Type by fully qualified name (e.g., "UnityEngine.UI.Button") + /// + private static Type GetTypeByFullName(string fullTypeName) + { + if (string.IsNullOrEmpty(fullTypeName)) + return null; + + // First, try using Type.GetType() which works for most built-in types + var type = Type.GetType(fullTypeName); + if (type != null) + return type; + + // If not found, search all loaded assemblies + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + type = assembly.GetType(fullTypeName); + if (type != null) + return type; + } + + return null; + } + /// /// Bind behaviours to every component and flowScreen generated during the process /// @@ -314,6 +332,76 @@ private static void BindBehaviourToNodeAndChildren(GameObject targetGameObject,F } // Finally apply to this node BindBehaviourToNode(targetGameObject, figmaImportProcessData); + + // Add in any special behaviours driven by name or other rules. If special case, dont add any more behaviours + if(figmaImportProcessData.Settings.EnableAddSpecialBehaviours) + { + var specialCaseNodes = AddSpecialBehavioursToNode(targetGameObject, figmaImportProcessData); + if (specialCaseNodes.Count > 0) + { + Debug.Log($"Added special case behaviour to node {targetGameObject.name}: {string.Join(", ", specialCaseNodes.Select(c => c.GetType().Name))}"); + } + } + } + + /// + /// Apply auto component bindings based on node name filter rules from settings + /// + private static List AddSpecialBehavioursToNode(GameObject gameObject, FigmaImportProcessData importProcessData) + { + var addedComponents = new List(); + + if (importProcessData.Settings.SpecialBehaviourBindings == null || importProcessData.Settings.SpecialBehaviourBindings.Count == 0) + return addedComponents; + + foreach (var bindingRule in importProcessData.Settings.SpecialBehaviourBindings) + { + // Skip if no filter is specified + if (string.IsNullOrEmpty(bindingRule.NodeNameFilter)) + continue; + + // Check if node name contains the filter string (case-insensitive) + if (!gameObject.name.ToLower(CultureInfo.InvariantCulture).Contains(bindingRule.NodeNameFilter.ToLower(CultureInfo.InvariantCulture))) + continue; + + // Try to find the component type using fully qualified name + var componentType = GetTypeByFullName(bindingRule.ComponentTypeName); + if (componentType == null) + { + Debug.LogWarning($"type '{bindingRule.ComponentTypeName}' not found for binding rule on node '{gameObject.name}'"); + continue; + } + + // Check if component already exists + var existingComponent = gameObject.GetComponent(componentType); + if (existingComponent != null) + { + // Component already exists, just track it for field binding + addedComponents.Add(existingComponent); + continue; + } + + // Add the component + try + { + var component = gameObject.AddComponent(componentType); + if (component == null) + { + Debug.LogError($"Failed to add component '{bindingRule.ComponentTypeName}' to '{gameObject.name}' for unknown reasons"); + continue; + } + Debug.Log($"Auto-bound component '{bindingRule.ComponentTypeName}' to node '{gameObject.name}' (matched filter: '{bindingRule.NodeNameFilter}')", gameObject); + addedComponents.Add(component); + + BindFieldsForComponent(gameObject, component); + } + catch (Exception e) + { + Debug.LogError($"Failed to add component '{bindingRule.ComponentTypeName}' to '{gameObject.name}': {e.Message}"); + } + } + return addedComponents; } + } } \ No newline at end of file diff --git a/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs b/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs index b4747fa..e0b01c1 100644 --- a/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs +++ b/UnityFigmaBridge/Editor/Settings/UnityFigmaBridgeSettings.cs @@ -44,6 +44,20 @@ public class UnityFigmaBridgeSettings : ScriptableObject [Tooltip("Automatically add Canvas, CanvasScaler, and GraphicRaycaster components to generated screens (useful for standalone prefabs)")] public bool EnhanceScreensWithUIComponents = false; + [Space(10)] + [Tooltip("Automatically bind components to nodes based on name filters (e.g., add Button component if node name contains 'button')")] + public bool EnableAddSpecialBehaviours = false; + + [Tooltip("Auto-bind components to nodes based on name filters (e.g., add Button component if node name contains 'button')")] + public List SpecialBehaviourBindings = new(){ + new ComponentBindingRule("UnityEngine.UI.Button", "button"), + new ComponentBindingRule("UnityEngine.UI.Image", "image"), + new ComponentBindingRule("TMPro.TextMeshProUGUI", "text"), + new ComponentBindingRule("TMPro.TMP_InputField", "inputfield"), + new ComponentBindingRule("UnityEngine.UI.Toggle", "toggle"), + new ComponentBindingRule("UnityFigmaBridge.Runtime.UI.SafeArea", "safearea"), + }; + [Space(10)] [Tooltip("Root folder path for generated Figma assets (default: Assets/Figma)")] public string FigmaAssetsRootFolder = "Assets/Figma"; @@ -103,5 +117,22 @@ public FigmaPageData(string name, string nodeId) Selected = true; // default is true } } - + + [Serializable] + public class ComponentBindingRule + { + [Tooltip("Full qualified component type name (e.g., 'UnityEngine.UI.Button', 'UnityEngine.UI.Image')")] + public string ComponentTypeName; + + [Tooltip("Node name filter - component will be added if this string is found in the node name (case-insensitive, e.g., 'button', '-toggle')")] + public string NodeNameFilter; + + public ComponentBindingRule() { } + + public ComponentBindingRule(string componentTypeName, string nodeNameFilter) + { + ComponentTypeName = componentTypeName; + NodeNameFilter = nodeNameFilter; + } + } } \ No newline at end of file From bbb95d2dda34b4818393de91f05b614f01ab16c3 Mon Sep 17 00:00:00 2001 From: Bilal Date: Thu, 26 Feb 2026 01:49:02 +0300 Subject: [PATCH 13/13] Prefab Health Check feature --- .../Editor/PrefabHealthCheck.meta | 8 + .../PrefabHealthCheckData.cs | 75 ++++ .../PrefabHealthCheckData.cs.meta | 2 + .../PrefabHealthCheckWindow.cs | 366 +++++++++++++++++ .../PrefabHealthCheckWindow.cs.meta | 2 + .../PrefabHealthCheck/PrefabHealthChecker.cs | 368 ++++++++++++++++++ .../PrefabHealthChecker.cs.meta | 2 + 7 files changed, 823 insertions(+) create mode 100644 UnityFigmaBridge/Editor/PrefabHealthCheck.meta create mode 100644 UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs create mode 100644 UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs.meta create mode 100644 UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs create mode 100644 UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs.meta create mode 100644 UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs create mode 100644 UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs.meta diff --git a/UnityFigmaBridge/Editor/PrefabHealthCheck.meta b/UnityFigmaBridge/Editor/PrefabHealthCheck.meta new file mode 100644 index 0000000..e3ff27d --- /dev/null +++ b/UnityFigmaBridge/Editor/PrefabHealthCheck.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6ec279dd3df36ec47a7961bc384fffc0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs new file mode 100644 index 0000000..12fb7a8 --- /dev/null +++ b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +namespace UnityFigmaBridge.Editor.PrefabHealthCheck +{ + /// + /// Data model for a Figma variant - a prefab derived from a Figma source prefab + /// + [Serializable] + public class FigmaVariantInfo + { + public string VariantPath; + public string BasePrefabPath; + public string BasePrefabName; + } + + /// + /// Represents a single missing object reference within a component + /// + [Serializable] + public class MissingObjectRef + { + public string GameObjectName; + public string ComponentType; + public string PropertyPath; + } + + /// + /// Represents a missing nested prefab instance inside a prefab + /// + [Serializable] + public class MissingNestedPrefab + { + public string ParentGameObjectName; + public string MissingGameObjectName; + } + + /// + /// Data model for a prefab containing missing references (scripts, object refs, or nested prefabs) + /// + [Serializable] + public class MissingReferenceInfo + { + public string PrefabPath; + public int MissingScriptCount; + public int MissingPrefabCount; + public List MissingObjectRefs = new List(); + public List MissingNestedPrefabs = new List(); + + public int TotalMissingCount => MissingScriptCount + MissingObjectRefs.Count + MissingPrefabCount; + } + + /// + /// Data model for a variant whose base prefab has been deleted + /// + [Serializable] + public class OrphanedVariantInfo + { + public string VariantPath; + public string MissingBaseGuid; + public string OriginalBasePath; + } + + /// + /// Container for all health check scan results + /// + [Serializable] + public class PrefabHealthCheckResult + { + public List FigmaVariants = new List(); + public List MissingReferences = new List(); + public List OrphanedVariants = new List(); + public bool IsComplete; + } +} diff --git a/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs.meta b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs.meta new file mode 100644 index 0000000..d66ea06 --- /dev/null +++ b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 211758587707f1d4280d24a1bd062986 \ No newline at end of file diff --git a/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs new file mode 100644 index 0000000..08ee3bd --- /dev/null +++ b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs @@ -0,0 +1,366 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace UnityFigmaBridge.Editor.PrefabHealthCheck +{ + /// + /// Editor window for displaying prefab health check results. + /// Provides three tabs: Figma Variants, Missing References, and Orphaned Variants. + /// + public class PrefabHealthCheckWindow : EditorWindow + { + private enum Tab + { + FigmaVariants, + MissingReferences, + OrphanedVariants + } + + private Tab _currentTab = Tab.FigmaVariants; + private PrefabHealthCheckResult _result; + private bool _isScanning; + private string _progressMessage = ""; + private float _progressValue; + + private Vector2 _scrollVariants; + private Vector2 _scrollMissing; + private Vector2 _scrollOrphaned; + + // Foldout states for missing reference details (keyed by prefab path) + private readonly Dictionary _missingFoldouts = new Dictionary(); + + private static readonly string[] TabLabels = + { + "Figma Variants", + "Missing References", + "Orphaned Variants" + }; + + [MenuItem("Figma Bridge/Prefab Health Check", priority = 20)] + public static void ShowWindow() + { + var window = GetWindow("Prefab Health Check"); + window.minSize = new Vector2(500, 350); + window.Show(); + } + + private void OnGUI() + { + DrawToolbar(); + DrawTabs(); + + if (_isScanning) + { + DrawProgressBar(); + return; + } + + if (_result == null || !_result.IsComplete) + { + EditorGUILayout.HelpBox( + "Click \"Scan\" to run the prefab health check.", + MessageType.Info); + return; + } + + switch (_currentTab) + { + case Tab.FigmaVariants: + DrawFigmaVariantsTab(); + break; + case Tab.MissingReferences: + DrawMissingReferencesTab(); + break; + case Tab.OrphanedVariants: + DrawOrphanedVariantsTab(); + break; + } + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + GUI.enabled = !_isScanning; + if (GUILayout.Button("Scan", EditorStyles.toolbarButton, GUILayout.Width(60))) + { + RunScan(); + } + + if (_result != null && _result.IsComplete) + { + if (GUILayout.Button("Refresh", EditorStyles.toolbarButton, GUILayout.Width(60))) + { + RunScan(); + } + } + + GUI.enabled = true; + + GUILayout.FlexibleSpace(); + + // Summary counts + if (_result != null && _result.IsComplete) + { + GUILayout.Label( + $"V: {_result.FigmaVariants.Count} | M: {_result.MissingReferences.Count} | O: {_result.OrphanedVariants.Count}", + EditorStyles.toolbarButton); + } + + EditorGUILayout.EndHorizontal(); + } + + private void DrawTabs() + { + EditorGUILayout.BeginHorizontal(); + _currentTab = (Tab)GUILayout.Toolbar((int)_currentTab, TabLabels); + EditorGUILayout.EndHorizontal(); + } + + private void DrawProgressBar() + { + EditorGUILayout.Space(10); + var rect = EditorGUILayout.GetControlRect(false, 20); + EditorGUI.ProgressBar(rect, _progressValue, _progressMessage); + EditorGUILayout.Space(5); + EditorGUILayout.HelpBox("Scanning in progress...", MessageType.None); + } + + // ──────────────────────────────────────────────────────────── + // Tab 1: Figma Variants + // ──────────────────────────────────────────────────────────── + + private void DrawFigmaVariantsTab() + { + EditorGUILayout.HelpBox( + "Lists all prefab variants in the project that are derived from a Figma source prefab " + + "(Pages, Screens, Components). Use this to track which project prefabs depend on Figma assets.", + MessageType.None); + EditorGUILayout.Space(2); + + var variants = _result.FigmaVariants; + + if (variants.Count == 0) + { + EditorGUILayout.HelpBox( + "No Figma-derived variant prefabs found in the project.", + MessageType.Info); + return; + } + + EditorGUILayout.LabelField($"Found {variants.Count} variant(s) derived from Figma prefabs:", + EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + _scrollVariants = EditorGUILayout.BeginScrollView(_scrollVariants); + + foreach (var v in variants) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField("Variant:", v.VariantPath, EditorStyles.miniLabel); + EditorGUILayout.LabelField("Base:", v.BasePrefabPath, EditorStyles.miniLabel); + EditorGUILayout.EndVertical(); + + if (GUILayout.Button("Select", GUILayout.Width(55), GUILayout.Height(30))) + { + SelectAsset(v.VariantPath); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + + EditorGUILayout.EndScrollView(); + } + + // ──────────────────────────────────────────────────────────── + // Tab 2: Missing References + // ──────────────────────────────────────────────────────────── + + private void DrawMissingReferencesTab() + { + EditorGUILayout.HelpBox( + "Scans all project prefabs for broken references: missing scripts (deleted/renamed MonoBehaviours), " + + "missing nested prefabs (deleted prefab instances inside a prefab), and missing object references " + + "(fields pointing to assets that no longer exist).", + MessageType.None); + EditorGUILayout.Space(2); + + var missing = _result.MissingReferences; + + if (missing.Count == 0) + { + EditorGUILayout.HelpBox( + "No missing references found in any prefab.", + MessageType.Info); + return; + } + + EditorGUILayout.LabelField($"Found {missing.Count} prefab(s) with missing references:", + EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + _scrollMissing = EditorGUILayout.BeginScrollView(_scrollMissing); + + foreach (var m in missing) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField(m.PrefabPath, EditorStyles.miniLabel); + + string summary = ""; + if (m.MissingScriptCount > 0) + summary += $"Missing Scripts: {m.MissingScriptCount} "; + if (m.MissingPrefabCount > 0) + summary += $"Missing Prefabs: {m.MissingPrefabCount} "; + if (m.MissingObjectRefs.Count > 0) + summary += $"Missing Refs: {m.MissingObjectRefs.Count}"; + + EditorGUILayout.LabelField(summary, EditorStyles.boldLabel); + EditorGUILayout.EndVertical(); + + if (GUILayout.Button("Select", GUILayout.Width(55), GUILayout.Height(30))) + { + SelectAsset(m.PrefabPath); + } + + EditorGUILayout.EndHorizontal(); + + // Expandable details for missing nested prefabs and object references + if (m.MissingNestedPrefabs.Count > 0 || m.MissingObjectRefs.Count > 0) + { + if (!_missingFoldouts.ContainsKey(m.PrefabPath)) + _missingFoldouts[m.PrefabPath] = false; + + _missingFoldouts[m.PrefabPath] = EditorGUILayout.Foldout( + _missingFoldouts[m.PrefabPath], "Details", true); + + if (_missingFoldouts[m.PrefabPath]) + { + EditorGUI.indentLevel++; + + foreach (var mp in m.MissingNestedPrefabs) + { + EditorGUILayout.LabelField( + $" [Missing Prefab] \"{mp.MissingGameObjectName}\" under \"{mp.ParentGameObjectName}\"", + EditorStyles.miniLabel); + } + + foreach (var refInfo in m.MissingObjectRefs) + { + EditorGUILayout.LabelField( + $" [{refInfo.GameObjectName}] {refInfo.ComponentType}.{refInfo.PropertyPath}", + EditorStyles.miniLabel); + } + + EditorGUI.indentLevel--; + } + } + + EditorGUILayout.EndVertical(); + } + + EditorGUILayout.EndScrollView(); + } + + // ──────────────────────────────────────────────────────────── + // Tab 3: Orphaned Variants + // ──────────────────────────────────────────────────────────── + + private void DrawOrphanedVariantsTab() + { + EditorGUILayout.HelpBox( + "Detects variant prefabs whose base (source) prefab has been deleted from the project. " + + "When a base prefab is removed (e.g. after a Figma re-sync or manual deletion), any variant " + + "derived from it becomes broken. These orphaned variants should be deleted or re-linked to a new base.", + MessageType.None); + EditorGUILayout.Space(2); + + var orphaned = _result.OrphanedVariants; + + if (orphaned.Count == 0) + { + EditorGUILayout.HelpBox( + "No orphaned variants found. All variant base prefabs are intact.", + MessageType.Info); + return; + } + + EditorGUILayout.LabelField($"Found {orphaned.Count} orphaned variant(s):", + EditorStyles.boldLabel); + EditorGUILayout.Space(4); + + _scrollOrphaned = EditorGUILayout.BeginScrollView(_scrollOrphaned); + + foreach (var o in orphaned) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField("Variant:", o.VariantPath, EditorStyles.miniLabel); + + if (!string.IsNullOrEmpty(o.OriginalBasePath)) + EditorGUILayout.LabelField("Original base:", o.OriginalBasePath, EditorStyles.miniLabel); + + if (!string.IsNullOrEmpty(o.MissingBaseGuid)) + EditorGUILayout.LabelField($"Missing GUID: {o.MissingBaseGuid}", EditorStyles.miniLabel); + else + EditorGUILayout.LabelField("Missing GUID: unknown", EditorStyles.miniLabel); + + EditorGUILayout.EndVertical(); + + if (GUILayout.Button("Select", GUILayout.Width(55), GUILayout.Height(30))) + { + SelectAsset(o.VariantPath); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + + EditorGUILayout.EndScrollView(); + } + + // ──────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────── + + private void RunScan() + { + _isScanning = true; + _missingFoldouts.Clear(); + Repaint(); + + // Run scan with progress callback + _result = PrefabHealthChecker.RunFullScan((message, progress) => + { + _progressMessage = message; + _progressValue = progress; + }); + + _isScanning = false; + Repaint(); + } + + private static void SelectAsset(string assetPath) + { + var obj = AssetDatabase.LoadAssetAtPath(assetPath); + if (obj != null) + { + Selection.activeObject = obj; + EditorGUIUtility.PingObject(obj); + } + else + { + Debug.LogWarning($"[PrefabHealthCheck] Asset not found at path: {assetPath}"); + } + } + } +} diff --git a/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs.meta b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs.meta new file mode 100644 index 0000000..6215df1 --- /dev/null +++ b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthCheckWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a647b513361375d4ebd75f9e8f031c96 \ No newline at end of file diff --git a/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs new file mode 100644 index 0000000..2515013 --- /dev/null +++ b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs @@ -0,0 +1,368 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using UnityEditor; +using UnityEngine; +using UnityFigmaBridge.Editor.Utils; + +namespace UnityFigmaBridge.Editor.PrefabHealthCheck +{ + /// + /// Core scanning logic for prefab health checks. + /// Detects Figma variants, missing references, and orphaned variants. + /// + /// Can be used as an API for scripting and automation: + /// var result = PrefabHealthChecker.RunFullScan(); + /// var variants = PrefabHealthChecker.ScanFigmaVariants(); + /// var missing = PrefabHealthChecker.ScanMissingReferences(); + /// var orphaned = PrefabHealthChecker.ScanOrphanedVariants(); + /// + public static class PrefabHealthChecker + { + /// + /// Run all health checks and return the combined result. + /// + /// Optional callback (message, 0-1 progress). + public static PrefabHealthCheckResult RunFullScan(Action onProgress = null) + { + var result = new PrefabHealthCheckResult(); + + try + { + onProgress?.Invoke("Collecting Figma source prefabs...", 0f); + var figmaSourcePaths = CollectFigmaSourcePrefabs(); + + onProgress?.Invoke("Collecting all project prefabs...", 0.05f); + var allPrefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }); + + // Phase 1: Find Figma variants + onProgress?.Invoke("Scanning for Figma variants...", 0.1f); + FindFigmaVariants(allPrefabGuids, figmaSourcePaths, result, onProgress, 0.1f, 0.4f); + + // Phase 2: Detect missing references + onProgress?.Invoke("Scanning for missing references...", 0.4f); + FindMissingReferences(allPrefabGuids, result, onProgress, 0.4f, 0.75f); + + // Phase 3: Detect orphaned variants + onProgress?.Invoke("Scanning for orphaned variants...", 0.75f); + FindOrphanedVariants(allPrefabGuids, result, onProgress, 0.75f, 1f); + + result.IsComplete = true; + onProgress?.Invoke("Scan complete.", 1f); + } + catch (Exception e) + { + Debug.LogError($"[PrefabHealthCheck] Scan failed: {e}"); + } + + return result; + } + + /// + /// Scan only for Figma-derived variant prefabs. + /// Returns a list of all project prefabs that are variants of a Figma source prefab. + /// + /// Optional callback (message, 0-1 progress). + public static List ScanFigmaVariants(Action onProgress = null) + { + var result = new PrefabHealthCheckResult(); + var figmaSourcePaths = CollectFigmaSourcePrefabs(); + var allPrefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }); + FindFigmaVariants(allPrefabGuids, figmaSourcePaths, result, onProgress, 0f, 1f); + return result.FigmaVariants; + } + + /// + /// Scan only for missing references (missing scripts, missing prefabs, missing object refs). + /// Returns a list of prefabs that contain at least one broken reference. + /// + /// Optional callback (message, 0-1 progress). + public static List ScanMissingReferences(Action onProgress = null) + { + var result = new PrefabHealthCheckResult(); + var allPrefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }); + FindMissingReferences(allPrefabGuids, result, onProgress, 0f, 1f); + return result.MissingReferences; + } + + /// + /// Scan only for orphaned variants (variants whose base prefab has been deleted). + /// Returns a list of broken variant prefabs. + /// + /// Optional callback (message, 0-1 progress). + public static List ScanOrphanedVariants(Action onProgress = null) + { + var result = new PrefabHealthCheckResult(); + var allPrefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets" }); + FindOrphanedVariants(allPrefabGuids, result, onProgress, 0f, 1f); + return result.OrphanedVariants; + } + + /// + /// Check if the project has any health issues at all (quick boolean check). + /// Runs a full scan and returns true if any problems were found. + /// + public static bool HasAnyIssues() + { + var result = RunFullScan(); + return result.MissingReferences.Count > 0 || result.OrphanedVariants.Count > 0; + } + + /// + /// Collect all prefab asset paths from Figma source folders (Pages, Screens, Components). + /// + public static HashSet CollectFigmaSourcePrefabs() + { + var sourcePaths = new HashSet(); + + string[] figmaFolders = + { + FigmaPaths.FigmaPagePrefabFolder, + FigmaPaths.FigmaScreenPrefabFolder, + FigmaPaths.FigmaComponentPrefabFolder + }; + + foreach (var folder in figmaFolders) + { + if (!AssetDatabase.IsValidFolder(folder)) + continue; + + var guids = AssetDatabase.FindAssets("t:Prefab", new[] { folder }); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + sourcePaths.Add(path); + } + } + + return sourcePaths; + } + + /// + /// Find all prefabs that are variants of a Figma source prefab + /// + private static void FindFigmaVariants( + string[] allPrefabGuids, + HashSet figmaSourcePaths, + PrefabHealthCheckResult result, + Action onProgress, + float progressStart, + float progressEnd) + { + for (int i = 0; i < allPrefabGuids.Length; i++) + { + var path = AssetDatabase.GUIDToAssetPath(allPrefabGuids[i]); + + // Skip prefabs that are themselves Figma source prefabs + if (figmaSourcePaths.Contains(path)) + continue; + + float progress = Mathf.Lerp(progressStart, progressEnd, + (float)i / allPrefabGuids.Length); + onProgress?.Invoke($"Checking variant: {Path.GetFileName(path)}", progress); + + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset == null) + continue; + + var assetType = PrefabUtility.GetPrefabAssetType(asset); + if (assetType == PrefabAssetType.NotAPrefab) + continue; + + // Check if this is a variant + if (!PrefabUtility.IsPartOfVariantPrefab(asset)) + continue; + + var originalSource = PrefabUtility.GetCorrespondingObjectFromOriginalSource(asset); + if (originalSource == null) + continue; + + var basePath = AssetDatabase.GetAssetPath(originalSource); + if (string.IsNullOrEmpty(basePath)) + continue; + + // Check if the base prefab is in a Figma folder + if (figmaSourcePaths.Contains(basePath)) + { + result.FigmaVariants.Add(new FigmaVariantInfo + { + VariantPath = path, + BasePrefabPath = basePath, + BasePrefabName = Path.GetFileNameWithoutExtension(basePath) + }); + } + } + } + + /// + /// Find all prefabs with missing scripts or missing object references + /// + private static void FindMissingReferences( + string[] allPrefabGuids, + PrefabHealthCheckResult result, + Action onProgress, + float progressStart, + float progressEnd) + { + for (int i = 0; i < allPrefabGuids.Length; i++) + { + var path = AssetDatabase.GUIDToAssetPath(allPrefabGuids[i]); + + float progress = Mathf.Lerp(progressStart, progressEnd, + (float)i / allPrefabGuids.Length); + onProgress?.Invoke($"Checking references: {Path.GetFileName(path)}", progress); + + var info = new MissingReferenceInfo { PrefabPath = path }; + + // Load prefab contents for deep inspection + var prefabRoot = PrefabUtility.LoadPrefabContents(path); + if (prefabRoot == null) + continue; + + try + { + ScanGameObjectForMissing(prefabRoot, info); + } + finally + { + PrefabUtility.UnloadPrefabContents(prefabRoot); + } + + if (info.TotalMissingCount > 0) + { + result.MissingReferences.Add(info); + } + } + } + + /// + /// Recursively scan a GameObject and its children for missing scripts, + /// missing object references, and missing nested prefab instances + /// + private static void ScanGameObjectForMissing(GameObject go, MissingReferenceInfo info) + { + // Check for missing scripts on this GameObject + int missingCount = GameObjectUtility.GetMonoBehavioursWithMissingScriptCount(go); + info.MissingScriptCount += missingCount; + + // Check for missing nested prefab instances + // A missing prefab shows up as PrefabAssetType.MissingAsset on the nested instance + if (PrefabUtility.IsAnyPrefabInstanceRoot(go)) + { + var nestedType = PrefabUtility.GetPrefabInstanceStatus(go); + if (nestedType == PrefabInstanceStatus.MissingAsset) + { + info.MissingPrefabCount++; + var parentName = go.transform.parent != null ? go.transform.parent.name : "(root)"; + info.MissingNestedPrefabs.Add(new MissingNestedPrefab + { + ParentGameObjectName = parentName, + MissingGameObjectName = go.name + }); + } + } + + // Check each component for missing object references + var components = go.GetComponents(); + foreach (var component in components) + { + // A null component means the script is missing + if (component == null) + continue; + + var so = new SerializedObject(component); + var sp = so.GetIterator(); + + while (sp.NextVisible(true)) + { + if (sp.propertyType != SerializedPropertyType.ObjectReference) + continue; + + // objectReferenceValue is null but instanceID is non-zero => missing ref + if (sp.objectReferenceValue == null && + sp.objectReferenceInstanceIDValue != 0) + { + info.MissingObjectRefs.Add(new MissingObjectRef + { + GameObjectName = go.name, + ComponentType = component.GetType().Name, + PropertyPath = sp.propertyPath + }); + } + } + } + + // Recurse into children + for (int i = 0; i < go.transform.childCount; i++) + { + ScanGameObjectForMissing(go.transform.GetChild(i).gameObject, info); + } + } + + /// + /// Find all variant prefabs whose base prefab has been deleted (orphaned) + /// + private static void FindOrphanedVariants( + string[] allPrefabGuids, + PrefabHealthCheckResult result, + Action onProgress, + float progressStart, + float progressEnd) + { + // Regex to extract m_SourcePrefab guid from YAML + var guidRegex = new Regex(@"m_SourcePrefab:\s*\{fileID:\s*\d+,\s*guid:\s*([a-f0-9]+)", + RegexOptions.Compiled); + + for (int i = 0; i < allPrefabGuids.Length; i++) + { + var path = AssetDatabase.GUIDToAssetPath(allPrefabGuids[i]); + + float progress = Mathf.Lerp(progressStart, progressEnd, + (float)i / allPrefabGuids.Length); + onProgress?.Invoke($"Checking orphaned: {Path.GetFileName(path)}", progress); + + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset == null) + continue; + + // Only check variant prefabs + if (!PrefabUtility.IsPartOfVariantPrefab(asset)) + continue; + + // Try API approach first + var originalSource = PrefabUtility.GetCorrespondingObjectFromOriginalSource(asset); + if (originalSource != null) + continue; // Base exists, not orphaned + + // Base is missing - try to extract GUID from YAML for diagnostics + string missingGuid = ""; + string originalBasePath = ""; + + try + { + var fullPath = Path.GetFullPath(path); + var yamlContent = File.ReadAllText(fullPath); + var match = guidRegex.Match(yamlContent); + if (match.Success) + { + missingGuid = match.Groups[1].Value; + // Try to find what path this GUID used to point to + originalBasePath = AssetDatabase.GUIDToAssetPath(missingGuid); + } + } + catch (Exception e) + { + Debug.LogWarning($"[PrefabHealthCheck] Could not read YAML for {path}: {e.Message}"); + } + + result.OrphanedVariants.Add(new OrphanedVariantInfo + { + VariantPath = path, + MissingBaseGuid = missingGuid, + OriginalBasePath = originalBasePath + }); + } + } + } +} diff --git a/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs.meta b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs.meta new file mode 100644 index 0000000..b74d350 --- /dev/null +++ b/UnityFigmaBridge/Editor/PrefabHealthCheck/PrefabHealthChecker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d3d6b9a3e9eabef4b9926ad08119a59c \ No newline at end of file