ManageAsset.cs•69.2 kB
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using MCPForUnity.Editor.Helpers; // For Response class
using static MCPForUnity.Editor.Tools.ManageGameObject;
#if UNITY_6000_0_OR_NEWER
using PhysicsMaterialType = UnityEngine.PhysicsMaterial;
using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine;
#else
using PhysicsMaterialType = UnityEngine.PhysicMaterial;
using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine;
#endif
namespace MCPForUnity.Editor.Tools
{
/// <summary>
/// Handles asset management operations within the Unity project.
/// </summary>
[McpForUnityTool("manage_asset", AutoRegister = false)]
public static class ManageAsset
{
// --- Main Handler ---
// Define the list of valid actions
private static readonly List<string> ValidActions = new List<string>
{
"import",
"create",
"modify",
"delete",
"duplicate",
"move",
"rename",
"search",
"get_info",
"create_folder",
"get_components",
};
public static object HandleCommand(JObject @params)
{
string action = @params["action"]?.ToString().ToLower();
if (string.IsNullOrEmpty(action))
{
return new ErrorResponse("Action parameter is required.");
}
// Check if the action is valid before switching
if (!ValidActions.Contains(action))
{
string validActionsList = string.Join(", ", ValidActions);
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions are: {validActionsList}"
);
}
// Common parameters
string path = @params["path"]?.ToString();
// Coerce string JSON to JObject for 'properties' if provided as a JSON string
var propertiesToken = @params["properties"];
if (propertiesToken != null && propertiesToken.Type == JTokenType.String)
{
try
{
var parsed = JObject.Parse(propertiesToken.ToString());
@params["properties"] = parsed;
}
catch (Exception e)
{
Debug.LogWarning($"[ManageAsset] Could not parse 'properties' JSON string: {e.Message}");
}
}
try
{
switch (action)
{
case "import":
// Note: Unity typically auto-imports. This might re-import or configure import settings.
return ReimportAsset(path, @params["properties"] as JObject);
case "create":
return CreateAsset(@params);
case "modify":
var properties = @params["properties"] as JObject;
return ModifyAsset(path, properties);
case "delete":
return DeleteAsset(path);
case "duplicate":
return DuplicateAsset(path, @params["destination"]?.ToString());
case "move": // Often same as rename if within Assets/
case "rename":
return MoveOrRenameAsset(path, @params["destination"]?.ToString());
case "search":
return SearchAssets(@params);
case "get_info":
return GetAssetInfo(
path,
@params["generatePreview"]?.ToObject<bool>() ?? false
);
case "create_folder": // Added specific action for clarity
return CreateFolder(path);
case "get_components":
return GetComponentsFromAsset(path);
default:
// This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications.
string validActionsListDefault = string.Join(", ", ValidActions);
return new ErrorResponse(
$"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}"
);
}
}
catch (Exception e)
{
Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}");
return new ErrorResponse(
$"Internal error processing action '{action}' on '{path}': {e.Message}"
);
}
}
// --- Action Implementations ---
private static object ReimportAsset(string path, JObject properties)
{
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for reimport.");
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return new ErrorResponse($"Asset not found at path: {fullPath}");
try
{
// TODO: Apply importer properties before reimporting?
// This is complex as it requires getting the AssetImporter, casting it,
// applying properties via reflection or specific methods, saving, then reimporting.
if (properties != null && properties.HasValues)
{
Debug.LogWarning(
"[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet."
);
// AssetImporter importer = AssetImporter.GetAtPath(fullPath);
// if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); }
}
AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate);
// AssetDatabase.Refresh(); // Usually ImportAsset handles refresh
return new SuccessResponse($"Asset '{fullPath}' reimported.", GetAssetData(fullPath));
}
catch (Exception e)
{
return new ErrorResponse($"Failed to reimport asset '{fullPath}': {e.Message}");
}
}
private static object CreateAsset(JObject @params)
{
string path = @params["path"]?.ToString();
string assetType = @params["assetType"]?.ToString();
JObject properties = @params["properties"] as JObject;
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for create.");
if (string.IsNullOrEmpty(assetType))
return new ErrorResponse("'assetType' is required for create.");
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string directory = Path.GetDirectoryName(fullPath);
// Ensure directory exists
if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory)))
{
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory));
AssetDatabase.Refresh(); // Make sure Unity knows about the new folder
}
if (AssetExists(fullPath))
return new ErrorResponse($"Asset already exists at path: {fullPath}");
try
{
UnityEngine.Object newAsset = null;
string lowerAssetType = assetType.ToLowerInvariant();
// Handle common asset types
if (lowerAssetType == "folder")
{
return CreateFolder(path); // Use dedicated method
}
else if (lowerAssetType == "material")
{
// Prefer provided shader; fall back to common pipelines
var requested = properties?["shader"]?.ToString();
Shader shader =
(!string.IsNullOrEmpty(requested) ? Shader.Find(requested) : null)
?? Shader.Find("Universal Render Pipeline/Lit")
?? Shader.Find("HDRP/Lit")
?? Shader.Find("Standard")
?? Shader.Find("Unlit/Color");
if (shader == null)
return new ErrorResponse($"Could not find a suitable shader (requested: '{requested ?? "none"}').");
var mat = new Material(shader);
if (properties != null)
ApplyMaterialProperties(mat, properties);
AssetDatabase.CreateAsset(mat, fullPath);
newAsset = mat;
}
else if (lowerAssetType == "physicsmaterial")
{
PhysicsMaterialType pmat = new PhysicsMaterialType();
if (properties != null)
ApplyPhysicsMaterialProperties(pmat, properties);
AssetDatabase.CreateAsset(pmat, fullPath);
newAsset = pmat;
}
else if (lowerAssetType == "scriptableobject")
{
string scriptClassName = properties?["scriptClass"]?.ToString();
if (string.IsNullOrEmpty(scriptClassName))
return new ErrorResponse(
"'scriptClass' property required when creating ScriptableObject asset."
);
Type scriptType = ComponentResolver.TryResolve(scriptClassName, out var resolvedType, out var error) ? resolvedType : null;
if (
scriptType == null
|| !typeof(ScriptableObject).IsAssignableFrom(scriptType)
)
{
var reason = scriptType == null
? (string.IsNullOrEmpty(error) ? "Type not found." : error)
: "Type found but does not inherit from ScriptableObject.";
return new ErrorResponse($"Script class '{scriptClassName}' invalid: {reason}");
}
ScriptableObject so = ScriptableObject.CreateInstance(scriptType);
// TODO: Apply properties from JObject to the ScriptableObject instance?
AssetDatabase.CreateAsset(so, fullPath);
newAsset = so;
}
else if (lowerAssetType == "prefab")
{
// Creating prefabs usually involves saving an existing GameObject hierarchy.
// A common pattern is to create an empty GameObject, configure it, and then save it.
return new ErrorResponse(
"Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement."
);
// Example (conceptual):
// GameObject source = GameObject.Find(properties["sourceGameObject"].ToString());
// if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath);
}
// TODO: Add more asset types (Animation Controller, Scene, etc.)
else
{
// Generic creation attempt (might fail or create empty files)
// For some types, just creating the file might be enough if Unity imports it.
// File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close();
// AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it
// newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(fullPath);
return new ErrorResponse(
$"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject."
);
}
if (
newAsset == null
&& !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath))
) // Check if it wasn't a folder and asset wasn't created
{
return new ErrorResponse(
$"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details."
);
}
AssetDatabase.SaveAssets();
// AssetDatabase.Refresh(); // CreateAsset often handles refresh
return new SuccessResponse(
$"Asset '{fullPath}' created successfully.",
GetAssetData(fullPath)
);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to create asset at '{fullPath}': {e.Message}");
}
}
private static object CreateFolder(string path)
{
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for create_folder.");
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
string parentDir = Path.GetDirectoryName(fullPath);
string folderName = Path.GetFileName(fullPath);
if (AssetExists(fullPath))
{
// Check if it's actually a folder already
if (AssetDatabase.IsValidFolder(fullPath))
{
return new SuccessResponse(
$"Folder already exists at path: {fullPath}",
GetAssetData(fullPath)
);
}
else
{
return new ErrorResponse(
$"An asset (not a folder) already exists at path: {fullPath}"
);
}
}
try
{
// Ensure parent exists
if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir))
{
// Recursively create parent folders if needed (AssetDatabase handles this internally)
// Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh();
}
string guid = AssetDatabase.CreateFolder(parentDir, folderName);
if (string.IsNullOrEmpty(guid))
{
return new ErrorResponse(
$"Failed to create folder '{fullPath}'. Check logs and permissions."
);
}
// AssetDatabase.Refresh(); // CreateFolder usually handles refresh
return new SuccessResponse(
$"Folder '{fullPath}' created successfully.",
GetAssetData(fullPath)
);
}
catch (Exception e)
{
return new ErrorResponse($"Failed to create folder '{fullPath}': {e.Message}");
}
}
private static object ModifyAsset(string path, JObject properties)
{
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for modify.");
if (properties == null || !properties.HasValues)
return new ErrorResponse("'properties' are required for modify.");
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return new ErrorResponse($"Asset not found at path: {fullPath}");
try
{
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(
fullPath
);
if (asset == null)
return new ErrorResponse($"Failed to load asset at path: {fullPath}");
bool modified = false; // Flag to track if any changes were made
// --- NEW: Handle GameObject / Prefab Component Modification ---
if (asset is GameObject gameObject)
{
// Iterate through the properties JSON: keys are component names, values are properties objects for that component
foreach (var prop in properties.Properties())
{
string componentName = prop.Name; // e.g., "Collectible"
// Check if the value associated with the component name is actually an object containing properties
if (
prop.Value is JObject componentProperties
&& componentProperties.HasValues
) // e.g., {"bobSpeed": 2.0}
{
// Resolve component type via ComponentResolver, then fetch by Type
Component targetComponent = null;
bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError);
if (resolved)
{
targetComponent = gameObject.GetComponent(compType);
}
// Only warn about resolution failure if component also not found
if (targetComponent == null && !resolved)
{
Debug.LogWarning(
$"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}"
);
}
if (targetComponent != null)
{
// Apply the nested properties (e.g., bobSpeed) to the found component instance
// Use |= to ensure 'modified' becomes true if any component is successfully modified
modified |= ApplyObjectProperties(
targetComponent,
componentProperties
);
}
else
{
// Log a warning if a specified component couldn't be found
Debug.LogWarning(
$"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component."
);
}
}
else
{
// Log a warning if the structure isn't {"ComponentName": {"prop": value}}
// We could potentially try to apply this property directly to the GameObject here if needed,
// but the primary goal is component modification.
Debug.LogWarning(
$"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping."
);
}
}
// Note: 'modified' is now true if ANY component property was successfully changed.
}
// --- End NEW ---
// --- Existing logic for other asset types (now as else-if) ---
// Example: Modifying a Material
else if (asset is Material material)
{
// Apply properties directly to the material. If this modifies, it sets modified=true.
// Use |= in case the asset was already marked modified by previous logic (though unlikely here)
modified |= ApplyMaterialProperties(material, properties);
}
// Example: Modifying a ScriptableObject
else if (asset is ScriptableObject so)
{
// Apply properties directly to the ScriptableObject.
modified |= ApplyObjectProperties(so, properties); // General helper
}
// Example: Modifying TextureImporter settings
else if (asset is Texture)
{
AssetImporter importer = AssetImporter.GetAtPath(fullPath);
if (importer is TextureImporter textureImporter)
{
bool importerModified = ApplyObjectProperties(textureImporter, properties);
if (importerModified)
{
// Importer settings need saving and reimporting
AssetDatabase.WriteImportSettingsIfDirty(fullPath);
AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes
modified = true; // Mark overall operation as modified
}
}
else
{
Debug.LogWarning($"Could not get TextureImporter for {fullPath}.");
}
}
// TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.)
else // Fallback for other asset types OR direct properties on non-GameObject assets
{
// This block handles non-GameObject/Material/ScriptableObject/Texture assets.
// Attempts to apply properties directly to the asset itself.
Debug.LogWarning(
$"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself."
);
modified |= ApplyObjectProperties(asset, properties);
}
// --- End Existing Logic ---
// Check if any modification happened (either component or direct asset modification)
if (modified)
{
// Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it.
EditorUtility.SetDirty(asset);
// Save all modified assets to disk.
AssetDatabase.SaveAssets();
// Refresh might be needed in some edge cases, but SaveAssets usually covers it.
// AssetDatabase.Refresh();
return new SuccessResponse(
$"Asset '{fullPath}' modified successfully.",
GetAssetData(fullPath)
);
}
else
{
// If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed.
return new SuccessResponse(
$"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.",
GetAssetData(fullPath)
);
// Previous message: return new SuccessResponse($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath));
}
}
catch (Exception e)
{
// Log the detailed error internally
Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}");
// Return a user-friendly error message
return new ErrorResponse($"Failed to modify asset '{fullPath}': {e.Message}");
}
}
private static object DeleteAsset(string path)
{
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for delete.");
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return new ErrorResponse($"Asset not found at path: {fullPath}");
try
{
bool success = AssetDatabase.DeleteAsset(fullPath);
if (success)
{
// AssetDatabase.Refresh(); // DeleteAsset usually handles refresh
return new SuccessResponse($"Asset '{fullPath}' deleted successfully.");
}
else
{
// This might happen if the file couldn't be deleted (e.g., locked)
return new ErrorResponse(
$"Failed to delete asset '{fullPath}'. Check logs or if the file is locked."
);
}
}
catch (Exception e)
{
return new ErrorResponse($"Error deleting asset '{fullPath}': {e.Message}");
}
}
private static object DuplicateAsset(string path, string destinationPath)
{
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for duplicate.");
string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(sourcePath))
return new ErrorResponse($"Source asset not found at path: {sourcePath}");
string destPath;
if (string.IsNullOrEmpty(destinationPath))
{
// Generate a unique path if destination is not provided
destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath);
}
else
{
destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (AssetExists(destPath))
return new ErrorResponse($"Asset already exists at destination path: {destPath}");
// Ensure destination directory exists
EnsureDirectoryExists(Path.GetDirectoryName(destPath));
}
try
{
bool success = AssetDatabase.CopyAsset(sourcePath, destPath);
if (success)
{
// AssetDatabase.Refresh();
return new SuccessResponse(
$"Asset '{sourcePath}' duplicated to '{destPath}'.",
GetAssetData(destPath)
);
}
else
{
return new ErrorResponse(
$"Failed to duplicate asset from '{sourcePath}' to '{destPath}'."
);
}
}
catch (Exception e)
{
return new ErrorResponse($"Error duplicating asset '{sourcePath}': {e.Message}");
}
}
private static object MoveOrRenameAsset(string path, string destinationPath)
{
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for move/rename.");
if (string.IsNullOrEmpty(destinationPath))
return new ErrorResponse("'destination' path is required for move/rename.");
string sourcePath = AssetPathUtility.SanitizeAssetPath(path);
string destPath = AssetPathUtility.SanitizeAssetPath(destinationPath);
if (!AssetExists(sourcePath))
return new ErrorResponse($"Source asset not found at path: {sourcePath}");
if (AssetExists(destPath))
return new ErrorResponse(
$"An asset already exists at the destination path: {destPath}"
);
// Ensure destination directory exists
EnsureDirectoryExists(Path.GetDirectoryName(destPath));
try
{
// Validate will return an error string if failed, null if successful
string error = AssetDatabase.ValidateMoveAsset(sourcePath, destPath);
if (!string.IsNullOrEmpty(error))
{
return new ErrorResponse(
$"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {error}"
);
}
string guid = AssetDatabase.MoveAsset(sourcePath, destPath);
if (!string.IsNullOrEmpty(guid)) // MoveAsset returns the new GUID on success
{
// AssetDatabase.Refresh(); // MoveAsset usually handles refresh
return new SuccessResponse(
$"Asset moved/renamed from '{sourcePath}' to '{destPath}'.",
GetAssetData(destPath)
);
}
else
{
// This case might not be reachable if ValidateMoveAsset passes, but good to have
return new ErrorResponse(
$"MoveAsset call failed unexpectedly for '{sourcePath}' to '{destPath}'."
);
}
}
catch (Exception e)
{
return new ErrorResponse($"Error moving/renaming asset '{sourcePath}': {e.Message}");
}
}
private static object SearchAssets(JObject @params)
{
string searchPattern = @params["searchPattern"]?.ToString();
string filterType = @params["filterType"]?.ToString();
string pathScope = @params["path"]?.ToString(); // Use path as folder scope
string filterDateAfterStr = @params["filterDateAfter"]?.ToString();
int pageSize = @params["pageSize"]?.ToObject<int?>() ?? 50; // Default page size
int pageNumber = @params["pageNumber"]?.ToObject<int?>() ?? 1; // Default page number (1-based)
bool generatePreview = @params["generatePreview"]?.ToObject<bool>() ?? false;
List<string> searchFilters = new List<string>();
if (!string.IsNullOrEmpty(searchPattern))
searchFilters.Add(searchPattern);
if (!string.IsNullOrEmpty(filterType))
searchFilters.Add($"t:{filterType}");
string[] folderScope = null;
if (!string.IsNullOrEmpty(pathScope))
{
folderScope = new string[] { AssetPathUtility.SanitizeAssetPath(pathScope) };
if (!AssetDatabase.IsValidFolder(folderScope[0]))
{
// Maybe the user provided a file path instead of a folder?
// We could search in the containing folder, or return an error.
Debug.LogWarning(
$"Search path '{folderScope[0]}' is not a valid folder. Searching entire project."
);
folderScope = null; // Search everywhere if path isn't a folder
}
}
DateTime? filterDateAfter = null;
if (!string.IsNullOrEmpty(filterDateAfterStr))
{
if (
DateTime.TryParse(
filterDateAfterStr,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime parsedDate
)
)
{
filterDateAfter = parsedDate;
}
else
{
Debug.LogWarning(
$"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format."
);
}
}
try
{
string[] guids = AssetDatabase.FindAssets(
string.Join(" ", searchFilters),
folderScope
);
List<object> results = new List<object>();
int totalFound = 0;
foreach (string guid in guids)
{
string assetPath = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(assetPath))
continue;
// Apply date filter if present
if (filterDateAfter.HasValue)
{
DateTime lastWriteTime = File.GetLastWriteTimeUtc(
Path.Combine(Directory.GetCurrentDirectory(), assetPath)
);
if (lastWriteTime <= filterDateAfter.Value)
{
continue; // Skip assets older than or equal to the filter date
}
}
totalFound++; // Count matching assets before pagination
results.Add(GetAssetData(assetPath, generatePreview));
}
// Apply pagination
int startIndex = (pageNumber - 1) * pageSize;
var pagedResults = results.Skip(startIndex).Take(pageSize).ToList();
return new SuccessResponse(
$"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).",
new
{
totalAssets = totalFound,
pageSize = pageSize,
pageNumber = pageNumber,
assets = pagedResults,
}
);
}
catch (Exception e)
{
return new ErrorResponse($"Error searching assets: {e.Message}");
}
}
private static object GetAssetInfo(string path, bool generatePreview)
{
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for get_info.");
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return new ErrorResponse($"Asset not found at path: {fullPath}");
try
{
return new SuccessResponse(
"Asset info retrieved.",
GetAssetData(fullPath, generatePreview)
);
}
catch (Exception e)
{
return new ErrorResponse($"Error getting info for asset '{fullPath}': {e.Message}");
}
}
/// <summary>
/// Retrieves components attached to a GameObject asset (like a Prefab).
/// </summary>
/// <param name="path">The asset path of the GameObject or Prefab.</param>
/// <returns>A response object containing a list of component type names or an error.</returns>
private static object GetComponentsFromAsset(string path)
{
// 1. Validate input path
if (string.IsNullOrEmpty(path))
return new ErrorResponse("'path' is required for get_components.");
// 2. Sanitize and check existence
string fullPath = AssetPathUtility.SanitizeAssetPath(path);
if (!AssetExists(fullPath))
return new ErrorResponse($"Asset not found at path: {fullPath}");
try
{
// 3. Load the asset
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(
fullPath
);
if (asset == null)
return new ErrorResponse($"Failed to load asset at path: {fullPath}");
// 4. Check if it's a GameObject (Prefabs load as GameObjects)
GameObject gameObject = asset as GameObject;
if (gameObject == null)
{
// Also check if it's *directly* a Component type (less common for primary assets)
Component componentAsset = asset as Component;
if (componentAsset != null)
{
// If the asset itself *is* a component, maybe return just its info?
// This is an edge case. Let's stick to GameObjects for now.
return new ErrorResponse(
$"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject."
);
}
return new ErrorResponse(
$"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type."
);
}
// 5. Get components
Component[] components = gameObject.GetComponents<Component>();
// 6. Format component data
List<object> componentList = components
.Select(comp => new
{
typeName = comp.GetType().FullName,
instanceID = comp.GetInstanceID(),
// TODO: Add more component-specific details here if needed in the future?
// Requires reflection or specific handling per component type.
})
.ToList<object>(); // Explicit cast for clarity if needed
// 7. Return success response
return new SuccessResponse(
$"Found {componentList.Count} component(s) on asset '{fullPath}'.",
componentList
);
}
catch (Exception e)
{
Debug.LogError(
$"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}"
);
return new ErrorResponse(
$"Error getting components for asset '{fullPath}': {e.Message}"
);
}
}
// --- Internal Helpers ---
/// <summary>
/// Ensures the asset path starts with "Assets/".
/// </summary>
/// <summary>
/// Checks if an asset exists at the given path (file or folder).
/// </summary>
private static bool AssetExists(string sanitizedPath)
{
// AssetDatabase APIs are generally preferred over raw File/Directory checks for assets.
// Check if it's a known asset GUID.
if (!string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath)))
{
return true;
}
// AssetPathToGUID might not work for newly created folders not yet refreshed.
// Check directory explicitly for folders.
if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath)))
{
// Check if it's considered a *valid* folder by Unity
return AssetDatabase.IsValidFolder(sanitizedPath);
}
// Check file existence for non-folder assets.
if (File.Exists(Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath)))
{
return true; // Assume if file exists, it's an asset or will be imported
}
return false;
// Alternative: return !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(sanitizedPath));
}
/// <summary>
/// Ensures the directory for a given asset path exists, creating it if necessary.
/// </summary>
private static void EnsureDirectoryExists(string directoryPath)
{
if (string.IsNullOrEmpty(directoryPath))
return;
string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath);
if (!Directory.Exists(fullDirPath))
{
Directory.CreateDirectory(fullDirPath);
AssetDatabase.Refresh(); // Let Unity know about the new folder
}
}
/// <summary>
/// Applies properties from JObject to a Material.
/// </summary>
private static bool ApplyMaterialProperties(Material mat, JObject properties)
{
if (mat == null || properties == null)
return false;
bool modified = false;
// Example: Set shader
if (properties["shader"]?.Type == JTokenType.String)
{
Shader newShader = Shader.Find(properties["shader"].ToString());
if (newShader != null && mat.shader != newShader)
{
mat.shader = newShader;
modified = true;
}
}
// Example: Set color property
if (properties["color"] is JObject colorProps)
{
string propName = colorProps["name"]?.ToString() ?? GetMainColorPropertyName(mat); // Auto-detect if not specified
if (colorProps["value"] is JArray colArr && colArr.Count >= 3)
{
try
{
Color newColor = new Color(
colArr[0].ToObject<float>(),
colArr[1].ToObject<float>(),
colArr[2].ToObject<float>(),
colArr.Count > 3 ? colArr[3].ToObject<float>() : 1.0f
);
if (mat.HasProperty(propName))
{
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
else
{
Debug.LogWarning(
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
);
}
}
catch (Exception ex)
{
Debug.LogWarning(
$"Error parsing color property '{propName}': {ex.Message}"
);
}
}
}
else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py
{
// Auto-detect the main color property for the shader
string propName = GetMainColorPropertyName(mat);
try
{
if (colorArr.Count >= 3)
{
Color newColor = new Color(
colorArr[0].ToObject<float>(),
colorArr[1].ToObject<float>(),
colorArr[2].ToObject<float>(),
colorArr.Count > 3 ? colorArr[3].ToObject<float>() : 1.0f
);
if (mat.HasProperty(propName))
{
if (mat.GetColor(propName) != newColor)
{
mat.SetColor(propName, newColor);
modified = true;
}
}
else
{
Debug.LogWarning(
$"Material '{mat.name}' with shader '{mat.shader.name}' does not have color property '{propName}'. " +
$"Color not applied. Common color properties: _BaseColor (URP), _Color (Standard)"
);
}
}
}
catch (Exception ex)
{
Debug.LogWarning(
$"Error parsing color property '{propName}': {ex.Message}"
);
}
}
// Example: Set float property
if (properties["float"] is JObject floatProps)
{
string propName = floatProps["name"]?.ToString();
if (
!string.IsNullOrEmpty(propName) &&
(floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer)
)
{
try
{
float newVal = floatProps["value"].ToObject<float>();
if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal)
{
mat.SetFloat(propName, newVal);
modified = true;
}
}
catch (Exception ex)
{
Debug.LogWarning(
$"Error parsing float property '{propName}': {ex.Message}"
);
}
}
}
// Example: Set texture property (case-insensitive key and subkeys)
{
JObject texProps = null;
var direct = properties.Property("texture");
if (direct != null && direct.Value is JObject t0) texProps = t0;
if (texProps == null)
{
var ci = properties.Properties().FirstOrDefault(
p => string.Equals(p.Name, "texture", StringComparison.OrdinalIgnoreCase));
if (ci != null && ci.Value is JObject t1) texProps = t1;
}
if (texProps != null)
{
string rawName = (texProps["name"] ?? texProps["Name"])?.ToString();
string texPath = (texProps["path"] ?? texProps["Path"])?.ToString();
if (!string.IsNullOrEmpty(texPath))
{
var newTex = AssetDatabase.LoadAssetAtPath<Texture>(
AssetPathUtility.SanitizeAssetPath(texPath));
if (newTex == null)
{
Debug.LogWarning($"Texture not found at path: {texPath}");
}
else
{
// Reuse alias resolver so friendly names like 'albedo' work here too
string candidateName = string.IsNullOrEmpty(rawName) ? "_BaseMap" : rawName;
string targetProp = ResolvePropertyName(candidateName);
if (!string.IsNullOrEmpty(targetProp) && mat.HasProperty(targetProp))
{
if (mat.GetTexture(targetProp) != newTex)
{
mat.SetTexture(targetProp, newTex);
modified = true;
}
}
}
}
}
}
// --- Flexible direct property assignment ---
// Allow payloads like: { "_Color": [r,g,b,a] }, { "_Glossiness": 0.5 }, { "_MainTex": "Assets/.." }
// while retaining backward compatibility with the structured keys above.
// This iterates all top-level keys except the reserved structured ones and applies them
// if they match known shader properties.
var reservedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "shader", "color", "float", "texture" };
// Helper resolves common URP/Standard aliasing (e.g., _Color <-> _BaseColor, _MainTex <-> _BaseMap, _Glossiness <-> _Smoothness)
string ResolvePropertyName(string name)
{
if (string.IsNullOrEmpty(name)) return name;
string[] candidates;
var lower = name.ToLowerInvariant();
switch (lower)
{
case "_color": candidates = new[] { "_Color", "_BaseColor" }; break;
case "_basecolor": candidates = new[] { "_BaseColor", "_Color" }; break;
case "_maintex": candidates = new[] { "_MainTex", "_BaseMap" }; break;
case "_basemap": candidates = new[] { "_BaseMap", "_MainTex" }; break;
case "_glossiness": candidates = new[] { "_Glossiness", "_Smoothness" }; break;
case "_smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break;
// Friendly names → shader property names
case "metallic": candidates = new[] { "_Metallic" }; break;
case "smoothness": candidates = new[] { "_Smoothness", "_Glossiness" }; break;
case "albedo": candidates = new[] { "_BaseMap", "_MainTex" }; break;
default: candidates = new[] { name }; break; // keep original as-is
}
foreach (var candidate in candidates)
{
if (mat.HasProperty(candidate)) return candidate;
}
return name; // fall back to original
}
foreach (var prop in properties.Properties())
{
if (reservedKeys.Contains(prop.Name)) continue;
string shaderProp = ResolvePropertyName(prop.Name);
JToken v = prop.Value;
// Color: numeric array [r,g,b,(a)]
if (v is JArray arr && arr.Count >= 3 && arr.All(t => t.Type == JTokenType.Float || t.Type == JTokenType.Integer))
{
if (mat.HasProperty(shaderProp))
{
try
{
var c = new Color(
arr[0].ToObject<float>(),
arr[1].ToObject<float>(),
arr[2].ToObject<float>(),
arr.Count > 3 ? arr[3].ToObject<float>() : 1f
);
if (mat.GetColor(shaderProp) != c)
{
mat.SetColor(shaderProp, c);
modified = true;
}
}
catch (Exception ex)
{
Debug.LogWarning($"Error setting color '{shaderProp}': {ex.Message}");
}
}
continue;
}
// Float: single number
if (v.Type == JTokenType.Float || v.Type == JTokenType.Integer)
{
if (mat.HasProperty(shaderProp))
{
try
{
float f = v.ToObject<float>();
if (!Mathf.Approximately(mat.GetFloat(shaderProp), f))
{
mat.SetFloat(shaderProp, f);
modified = true;
}
}
catch (Exception ex)
{
Debug.LogWarning($"Error setting float '{shaderProp}': {ex.Message}");
}
}
continue;
}
// Texture: string path
if (v.Type == JTokenType.String)
{
string texPath = v.ToString();
if (!string.IsNullOrEmpty(texPath) && mat.HasProperty(shaderProp))
{
var tex = AssetDatabase.LoadAssetAtPath<Texture>(AssetPathUtility.SanitizeAssetPath(texPath));
if (tex != null && mat.GetTexture(shaderProp) != tex)
{
mat.SetTexture(shaderProp, tex);
modified = true;
}
}
continue;
}
}
// TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.)
return modified;
}
/// <summary>
/// Auto-detects the main color property name for a material's shader.
/// Tries common color property names in order: _BaseColor (URP), _Color (Standard), etc.
/// </summary>
private static string GetMainColorPropertyName(Material mat)
{
if (mat == null || mat.shader == null)
return "_Color";
// Try common color property names in order of likelihood
string[] commonColorProps = { "_BaseColor", "_Color", "_MainColor", "_Tint", "_TintColor" };
foreach (var prop in commonColorProps)
{
if (mat.HasProperty(prop))
return prop;
}
// Fallback to _Color if none found
return "_Color";
}
/// <summary>
/// Applies properties from JObject to a PhysicsMaterial.
/// </summary>
private static bool ApplyPhysicsMaterialProperties(PhysicsMaterialType pmat, JObject properties)
{
if (pmat == null || properties == null)
return false;
bool modified = false;
// Example: Set dynamic friction
if (properties["dynamicFriction"]?.Type == JTokenType.Float)
{
float dynamicFriction = properties["dynamicFriction"].ToObject<float>();
pmat.dynamicFriction = dynamicFriction;
modified = true;
}
// Example: Set static friction
if (properties["staticFriction"]?.Type == JTokenType.Float)
{
float staticFriction = properties["staticFriction"].ToObject<float>();
pmat.staticFriction = staticFriction;
modified = true;
}
// Example: Set bounciness
if (properties["bounciness"]?.Type == JTokenType.Float)
{
float bounciness = properties["bounciness"].ToObject<float>();
pmat.bounciness = bounciness;
modified = true;
}
List<String> averageList = new List<String> { "ave", "Ave", "average", "Average" };
List<String> multiplyList = new List<String> { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" };
List<String> minimumList = new List<String> { "min", "Min", "minimum", "Minimum" };
List<String> maximumList = new List<String> { "max", "Max", "maximum", "Maximum" };
// Example: Set friction combine
if (properties["frictionCombine"]?.Type == JTokenType.String)
{
string frictionCombine = properties["frictionCombine"].ToString();
if (averageList.Contains(frictionCombine))
pmat.frictionCombine = PhysicsMaterialCombine.Average;
else if (multiplyList.Contains(frictionCombine))
pmat.frictionCombine = PhysicsMaterialCombine.Multiply;
else if (minimumList.Contains(frictionCombine))
pmat.frictionCombine = PhysicsMaterialCombine.Minimum;
else if (maximumList.Contains(frictionCombine))
pmat.frictionCombine = PhysicsMaterialCombine.Maximum;
modified = true;
}
// Example: Set bounce combine
if (properties["bounceCombine"]?.Type == JTokenType.String)
{
string bounceCombine = properties["bounceCombine"].ToString();
if (averageList.Contains(bounceCombine))
pmat.bounceCombine = PhysicsMaterialCombine.Average;
else if (multiplyList.Contains(bounceCombine))
pmat.bounceCombine = PhysicsMaterialCombine.Multiply;
else if (minimumList.Contains(bounceCombine))
pmat.bounceCombine = PhysicsMaterialCombine.Minimum;
else if (maximumList.Contains(bounceCombine))
pmat.bounceCombine = PhysicsMaterialCombine.Maximum;
modified = true;
}
return modified;
}
/// <summary>
/// Generic helper to set properties on any UnityEngine.Object using reflection.
/// </summary>
private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties)
{
if (target == null || properties == null)
return false;
bool modified = false;
Type type = target.GetType();
foreach (var prop in properties.Properties())
{
string propName = prop.Name;
JToken propValue = prop.Value;
if (SetPropertyOrField(target, propName, propValue, type))
{
modified = true;
}
}
return modified;
}
/// <summary>
/// Helper to set a property or field via reflection, handling basic types and Unity objects.
/// </summary>
private static bool SetPropertyOrField(
object target,
string memberName,
JToken value,
Type type = null
)
{
type = type ?? target.GetType();
System.Reflection.BindingFlags flags =
System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.IgnoreCase;
try
{
System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags);
if (propInfo != null && propInfo.CanWrite)
{
object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType);
if (
convertedValue != null
&& !object.Equals(propInfo.GetValue(target), convertedValue)
)
{
propInfo.SetValue(target, convertedValue);
return true;
}
}
else
{
System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags);
if (fieldInfo != null)
{
object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType);
if (
convertedValue != null
&& !object.Equals(fieldInfo.GetValue(target), convertedValue)
)
{
fieldInfo.SetValue(target, convertedValue);
return true;
}
}
}
}
catch (Exception ex)
{
Debug.LogWarning(
$"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}"
);
}
return false;
}
/// <summary>
/// Simple JToken to Type conversion for common Unity types and primitives.
/// </summary>
private static object ConvertJTokenToType(JToken token, Type targetType)
{
try
{
if (token == null || token.Type == JTokenType.Null)
return null;
if (targetType == typeof(string))
return token.ToObject<string>();
if (targetType == typeof(int))
return token.ToObject<int>();
if (targetType == typeof(float))
return token.ToObject<float>();
if (targetType == typeof(bool))
return token.ToObject<bool>();
if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2)
return new Vector2(arrV2[0].ToObject<float>(), arrV2[1].ToObject<float>());
if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3)
return new Vector3(
arrV3[0].ToObject<float>(),
arrV3[1].ToObject<float>(),
arrV3[2].ToObject<float>()
);
if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4)
return new Vector4(
arrV4[0].ToObject<float>(),
arrV4[1].ToObject<float>(),
arrV4[2].ToObject<float>(),
arrV4[3].ToObject<float>()
);
if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4)
return new Quaternion(
arrQ[0].ToObject<float>(),
arrQ[1].ToObject<float>(),
arrQ[2].ToObject<float>(),
arrQ[3].ToObject<float>()
);
if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA
return new Color(
arrC[0].ToObject<float>(),
arrC[1].ToObject<float>(),
arrC[2].ToObject<float>(),
arrC.Count > 3 ? arrC[3].ToObject<float>() : 1.0f
);
if (targetType.IsEnum)
return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing
// Handle loading Unity Objects (Materials, Textures, etc.) by path
if (
typeof(UnityEngine.Object).IsAssignableFrom(targetType)
&& token.Type == JTokenType.String
)
{
string assetPath = AssetPathUtility.SanitizeAssetPath(token.ToString());
UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath(
assetPath,
targetType
);
if (loadedAsset == null)
{
Debug.LogWarning(
$"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}"
);
}
return loadedAsset;
}
// Fallback: Try direct conversion (might work for other simple value types)
return token.ToObject(targetType);
}
catch (Exception ex)
{
Debug.LogWarning(
$"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}"
);
return null;
}
}
// --- Data Serialization ---
/// <summary>
/// Creates a serializable representation of an asset.
/// </summary>
private static object GetAssetData(string path, bool generatePreview = false)
{
if (string.IsNullOrEmpty(path) || !AssetExists(path))
return null;
string guid = AssetDatabase.AssetPathToGUID(path);
Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path);
UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path);
string previewBase64 = null;
int previewWidth = 0;
int previewHeight = 0;
if (generatePreview && asset != null)
{
Texture2D preview = AssetPreview.GetAssetPreview(asset);
if (preview != null)
{
try
{
// Ensure texture is readable for EncodeToPNG
// Creating a temporary readable copy is safer
RenderTexture rt = null;
Texture2D readablePreview = null;
RenderTexture previous = RenderTexture.active;
try
{
rt = RenderTexture.GetTemporary(preview.width, preview.height);
Graphics.Blit(preview, rt);
RenderTexture.active = rt;
readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false);
readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
readablePreview.Apply();
var pngData = readablePreview.EncodeToPNG();
if (pngData != null && pngData.Length > 0)
{
previewBase64 = Convert.ToBase64String(pngData);
previewWidth = readablePreview.width;
previewHeight = readablePreview.height;
}
}
finally
{
RenderTexture.active = previous;
if (rt != null) RenderTexture.ReleaseTemporary(rt);
if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview);
}
}
catch (Exception ex)
{
Debug.LogWarning(
$"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable."
);
// Fallback: Try getting static preview if available?
// Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset);
}
}
else
{
Debug.LogWarning(
$"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?"
);
}
}
return new
{
path = path,
guid = guid,
assetType = assetType?.FullName ?? "Unknown",
name = Path.GetFileNameWithoutExtension(path),
fileName = Path.GetFileName(path),
isFolder = AssetDatabase.IsValidFolder(path),
instanceID = asset?.GetInstanceID() ?? 0,
lastWriteTimeUtc = File.GetLastWriteTimeUtc(
Path.Combine(Directory.GetCurrentDirectory(), path)
)
.ToString("o"), // ISO 8601
// --- Preview Data ---
previewBase64 = previewBase64, // PNG data as Base64 string
previewWidth = previewWidth,
previewHeight = previewHeight,
// TODO: Add more metadata? Importer settings? Dependencies?
};
}
}
}