using System; using System.Collections.Generic; using System.Reflection; using Newtonsoft.Json.Linq; using Unity.Pipeline.Commands; using Unity.Pipeline.Editor.Authoring; using Unity.Pipeline.Models; using UnityEditor; namespace Unity.Pipeline.Editor.Commands.Assets { /// /// /// Import-settings authoring commands (CLI-191 + CLI-212). Reads and edits the /// for an asset. /// /// /// /// set_import_settings sets named members on the importer from a JSON object and re-imports. /// For the Default platform it sets top-level public properties/fields via reflection (works /// across every importer kind). For a real platform on a / /// , keys are applied onto the platform-override struct /// ( / ) which /// is unreachable by top-level reflection. has no per-platform overrides. /// /// /// /// get_import_settings reads the importer state structured by importer type (texture / model / /// audio), including the default-platform fields and, for textures/audio, one platform override block. /// /// /// /// Unknown keys are reported back rather than silently ignored, so an agent gets actionable feedback. /// /// /// /// NOTE: import settings live in the asset's .meta file via the importer; this is an AssetDatabase /// operation and is not part of Unity's Undo system. SaveAndReimport is not destructive to the /// source file, so no confirm is required. /// /// public static class AssetImportCommands { // Caller-facing platform names accepted by both commands. "Default" means the default platform // (top-level importer properties / default sample settings); the rest are real build platforms. private static readonly HashSet s_SupportedPlatforms = new HashSet(StringComparer.OrdinalIgnoreCase) { "Default", "Standalone", "iOS", "Android", "WebGL", "tvOS" }; [CliCommand("set_import_settings", "Set import settings on an asset's AssetImporter (default platform top-level properties, or a texture/audio per-platform override) and re-import it.")] public static SetImportSettingsResult SetImportSettings( [CliArg("asset", "Reference to the asset whose importer to edit (path / guid / globalId).", Required = true)] ObjectRef asset, [CliArg("settings", "JSON object of importer property/field names to values, e.g. {\"isReadable\": true, \"textureType\": \"NormalMap\"}. For platform != Default on a texture/audio importer, keys map onto the platform-settings struct (e.g. maxTextureSize, format, compressionFormat, quality, and overridden).", Required = true)] JObject settings, [CliArg("platform", "Target platform: Default | Standalone | iOS | Android | WebGL | tvOS. Defaults to Default (top-level importer properties). A real platform writes a per-platform override (textures/audio only).")] string platform = "Default", [CliArg("dry_run", "If true, validate which settings would apply (and which are unknown) without writing or re-importing.")] bool dryRun = false) { var importer = ResolveImporter(asset, out var assetPath); if (settings == null || settings.Count == 0) throw new ArgumentException("settings must be a non-empty JSON object."); if (!s_SupportedPlatforms.Contains(platform)) throw new ArgumentException( Serialize(new ErrorResult { Code = "unknown_platform", Message = $"Unknown platform '{platform}'. Supported: {string.Join(", ", s_SupportedPlatforms)}." })); var canonicalPlatform = Canonical(platform); var isDefault = string.Equals(canonicalPlatform, "Default", StringComparison.OrdinalIgnoreCase); var result = new SetImportSettingsResult { AssetPath = assetPath, ImporterType = importer.GetType().Name, Platform = canonicalPlatform }; // Strip the server-injected token before iterating: it is never an importer setting. var keys = new List(); foreach (var pair in settings) { if (pair.Key == "token") continue; keys.Add(pair.Key); } if (keys.Count == 0) throw new ArgumentException("settings must be a non-empty JSON object."); if (isDefault) { ApplyDefaultPlatform(importer, settings, keys, result, write: !dryRun); } else { switch (importer) { case TextureImporter texture: ApplyTexturePlatform(texture, settings, keys, canonicalPlatform, result, write: !dryRun); break; case AudioImporter audio: ApplyAudioPlatform(audio, settings, keys, canonicalPlatform, result, write: !dryRun); break; case ModelImporter _: throw new ArgumentException( Serialize(new ErrorResult { Code = "no_platform_overrides", Message = "ModelImporter has no per-platform overrides; use platform=Default." })); default: throw new ArgumentException( Serialize(new ErrorResult { Code = "no_platform_overrides", Message = $"Importer '{result.ImporterType}' has no per-platform overrides; use platform=Default." })); } } if (result.Unknown.Count > 0 && result.Applied.Count == 0) throw new ArgumentException($"None of the supplied settings could be applied to '{result.ImporterType}' (unknown key or invalid value): {string.Join(", ", result.Unknown)}."); if (dryRun) return result; importer.SaveAndReimport(); return result; } [CliCommand("get_import_settings", "Read an asset's import settings, structured by importer type (texture/model/audio), including the default-platform fields and (for textures/audio) one platform override block.")] public static GetImportSettingsResult GetImportSettings( [CliArg("asset", "Reference to the asset whose importer to read (path / guid / globalId).", Required = true)] ObjectRef asset, [CliArg("platform", "Platform whose override to read: Default | Standalone | iOS | Android | WebGL | tvOS. Defaults to Default.")] string platform = "Default") { var importer = ResolveImporter(asset, out var assetPath); if (!s_SupportedPlatforms.Contains(platform)) throw new ArgumentException( Serialize(new ErrorResult { Code = "unknown_platform", Message = $"Unknown platform '{platform}'. Supported: {string.Join(", ", s_SupportedPlatforms)}." })); var canonicalPlatform = Canonical(platform); var isDefault = string.Equals(canonicalPlatform, "Default", StringComparison.OrdinalIgnoreCase); var result = new GetImportSettingsResult { AssetPath = assetPath, ImporterType = importer.GetType().Name, Platform = canonicalPlatform }; switch (importer) { case TextureImporter texture: result.Settings = ReadTextureDefaults(texture); result.PlatformOverride = ReadTexturePlatformOverride(texture, canonicalPlatform, isDefault); break; case ModelImporter model: result.Settings = ReadModelDefaults(model); result.PlatformOverride = null; // ModelImporter has no per-platform overrides. break; case AudioImporter audio: result.Settings = ReadAudioDefaults(audio); result.PlatformOverride = ReadAudioPlatformOverride(audio, canonicalPlatform, isDefault); break; default: // Non-import asset (e.g. a ScriptableObject via NativeFormatImporter): return a // minimal settings block rather than throwing. result.Settings = new Dictionary { ["userData"] = importer.userData, ["assetBundleName"] = importer.assetBundleName }; result.PlatformOverride = null; break; } return result; } // ------------------------------------------------------------------ resolution / helpers /// /// Resolve an to its , confined to the /// authoring root. Throws an actionable on any failure. /// private static AssetImporter ResolveImporter(ObjectRef asset, out string assetPath) { if (!ObjectResolver.TryResolve(asset, out var obj, out var error)) throw new ArgumentException(error); assetPath = AssetDatabase.GetAssetPath(obj); if (string.IsNullOrEmpty(assetPath)) throw new ArgumentException($"Reference '{asset}' does not point at an on-disk asset."); // Confine to the authoring root so a GUID/globalId handle cannot reach an asset outside the // sandbox. assetPath = ProjectPaths.Resolve(assetPath, out var confineError); if (assetPath == null) throw new ArgumentException( $"Asset '{asset}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}"); var importer = AssetImporter.GetAtPath(assetPath); if (importer == null) throw new ArgumentException($"No AssetImporter found for '{assetPath}'."); return importer; } /// Normalize a caller platform name to its canonical capitalization (e.g. "ios" -> "iOS"). private static string Canonical(string platform) { foreach (var supported in s_SupportedPlatforms) { if (string.Equals(supported, platform, StringComparison.OrdinalIgnoreCase)) return supported; } return platform; } /// /// Map a caller-facing platform name to the build-platform string Unity's importer APIs expect. /// Most match 1:1; iOS is "iPhone" to those APIs. /// private static string UnityPlatformName(string canonicalPlatform) { return string.Equals(canonicalPlatform, "iOS", StringComparison.OrdinalIgnoreCase) ? "iPhone" : canonicalPlatform; } // ------------------------------------------------------------------ default-platform reflection private static void ApplyDefaultPlatform(AssetImporter importer, JObject settings, List keys, SetImportSettingsResult result, bool write) { var importerType = importer.GetType(); const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; foreach (var key in keys) { // In dry_run we only validate the member exists and the value converts — never mutate the // (possibly cached) importer instance, so a dry run leaves no trace. var applied = TryApplyMember(importer, importerType, flags, key, settings[key], write); if (applied) result.Applied.Add(key); else result.Unknown.Add(key); } } /// /// Set a single named property or field on the target from a JSON token. Returns false when no /// writable member with that name exists or the value cannot be converted to the member type. /// Enum members accept the value name (case-insensitive) as well as numeric values. /// private static bool TryApplyMember(object target, Type type, BindingFlags flags, string memberName, JToken value, bool write) { var property = type.GetProperty(memberName, flags); if (property != null && property.CanWrite && property.GetIndexParameters().Length == 0) { return TryConvertAndSet(property.PropertyType, value, converted => { if (write) property.SetValue(target, converted); }); } var field = type.GetField(memberName, flags); if (field != null) { return TryConvertAndSet(field.FieldType, value, converted => { if (write) field.SetValue(target, converted); }); } return false; } /// /// Convert a JSON token to (with case-insensitive enum-name support) /// and, on success, invoke . Returns false when the value cannot convert. /// private static bool TryConvertAndSet(Type targetType, JToken value, Action set) { try { if (!TryConvert(targetType, value, out var converted)) return false; set(converted); return true; } catch { return false; } } /// /// Convert a JSON token to a CLR value. Enums accept their value name (case-insensitive) or an /// integral value; everything else defers to Newtonsoft's . /// private static bool TryConvert(Type targetType, JToken value, out object converted) { converted = null; var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; if (underlying.IsEnum) { if (value.Type == JTokenType.String) { var name = value.Value(); if (string.IsNullOrEmpty(name)) return false; try { converted = Enum.Parse(underlying, name, ignoreCase: true); return true; } catch { return false; } } if (value.Type == JTokenType.Integer) { converted = Enum.ToObject(underlying, value.Value()); return true; } return false; } converted = value.ToObject(targetType); return true; } // ------------------------------------------------------------------ texture platform overrides private static void ApplyTexturePlatform(TextureImporter texture, JObject settings, List keys, string canonicalPlatform, SetImportSettingsResult result, bool write) { var unityPlatform = UnityPlatformName(canonicalPlatform); // Boxed struct so reflection SetValue mutates the local copy; written back via SetPlatformTextureSettings. object boxed = texture.GetPlatformTextureSettings(unityPlatform); var type = typeof(TextureImporterPlatformSettings); const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; // Only treat "overridden" as explicitly provided when the caller passes a valid boolean. // A non-boolean value is caught by TrySetBoolMember (reported to unknown[]), so we must // not suppress the default overridden=true in that case or the override is silently skipped. var explicitOverridden = settings.TryGetValue("overridden", StringComparison.OrdinalIgnoreCase, out var overriddenTok) && overriddenTok.Type == JTokenType.Boolean; foreach (var key in keys) { if (string.Equals(key, "overridden", StringComparison.OrdinalIgnoreCase)) { // Handled explicitly below; still report it as applied. if (TrySetBoolMember(type, flags, boxed, "overridden", settings[key], write)) result.Applied.Add(key); else result.Unknown.Add(key); continue; } if (TryApplyMember(boxed, type, flags, key, settings[key], write)) result.Applied.Add(key); else result.Unknown.Add(key); } // Default the override on (the caller intends to set platform-specific values) unless they // explicitly passed overridden:false (in which case the value they passed is already in the // boxed copy from the loop above). if (!explicitOverridden) SetBool(type, flags, boxed, "overridden", true); if (write && result.Applied.Count > 0) { var settingsStruct = (TextureImporterPlatformSettings)boxed; settingsStruct.name = unityPlatform; texture.SetPlatformTextureSettings(settingsStruct); } } /// /// Set a named boolean member from a JSON token. Returns false when the token is not a boolean or /// no settable bool member with that name exists, so a non-existent member is reported as unknown /// rather than falsely applied. In =false (dry-run) mode the member is only /// probed for existence, never mutated. /// private static bool TrySetBoolMember(Type type, BindingFlags flags, object boxed, string member, JToken token, bool write) { if (token.Type != JTokenType.Boolean) return false; if (!write) return BoolMemberExists(type, flags, member); return SetBool(type, flags, boxed, member, token.Value()); } /// True when a settable boolean property or field with that name exists on the type. private static bool BoolMemberExists(Type type, BindingFlags flags, string member) { var property = type.GetProperty(member, flags); if (property != null && property.CanWrite && property.PropertyType == typeof(bool) && property.GetIndexParameters().Length == 0) return true; var field = type.GetField(member, flags); return field != null && field.FieldType == typeof(bool); } /// /// Write a boolean property or field (matching 's property-then-field /// resolution so a bool implemented as a property is also honored). Returns false — without /// mutating anything — when no settable bool member with that name exists. /// private static bool SetBool(Type type, BindingFlags flags, object boxed, string member, bool value) { var property = type.GetProperty(member, flags); if (property != null && property.CanWrite && property.PropertyType == typeof(bool) && property.GetIndexParameters().Length == 0) { property.SetValue(boxed, value); return true; } var field = type.GetField(member, flags); if (field != null && field.FieldType == typeof(bool)) { field.SetValue(boxed, value); return true; } return false; } private static Dictionary ReadTextureDefaults(TextureImporter texture) { return new Dictionary { ["textureType"] = texture.textureType.ToString(), ["textureShape"] = texture.textureShape.ToString(), ["sRGBTexture"] = texture.sRGBTexture, ["alphaSource"] = texture.alphaSource.ToString(), ["alphaIsTransparency"] = texture.alphaIsTransparency, ["isReadable"] = texture.isReadable, ["streamingMipmaps"] = texture.streamingMipmaps, ["mipmapEnabled"] = texture.mipmapEnabled, ["wrapMode"] = texture.wrapMode.ToString(), ["filterMode"] = texture.filterMode.ToString(), ["anisoLevel"] = texture.anisoLevel, ["spriteImportMode"] = texture.spriteImportMode.ToString(), ["spritePixelsPerUnit"] = texture.spritePixelsPerUnit, ["npotScale"] = texture.npotScale.ToString(), ["maxTextureSize"] = texture.maxTextureSize }; } private static Dictionary ReadTexturePlatformOverride(TextureImporter texture, string canonicalPlatform, bool isDefault) { var settings = isDefault ? texture.GetDefaultPlatformTextureSettings() : texture.GetPlatformTextureSettings(UnityPlatformName(canonicalPlatform)); return new Dictionary { ["overridden"] = !isDefault && settings.overridden, ["maxTextureSize"] = settings.maxTextureSize, ["resizeAlgorithm"] = settings.resizeAlgorithm.ToString(), ["format"] = settings.format.ToString(), ["textureCompression"] = settings.textureCompression.ToString(), ["compressionQuality"] = settings.compressionQuality, ["crunchedCompression"] = settings.crunchedCompression, ["androidETC2FallbackOverride"] = settings.androidETC2FallbackOverride.ToString() }; } // ------------------------------------------------------------------ audio platform overrides private static void ApplyAudioPlatform(AudioImporter audio, JObject settings, List keys, string canonicalPlatform, SetImportSettingsResult result, bool write) { var unityPlatform = UnityPlatformName(canonicalPlatform); // overridden:false clears the override entirely (and ignores any other keys). if (settings.TryGetValue("overridden", StringComparison.OrdinalIgnoreCase, out var overriddenToken) && overriddenToken.Type == JTokenType.Boolean && !overriddenToken.Value()) { result.Applied.Add("overridden"); if (write) audio.ClearSampleSettingOverride(unityPlatform); return; } object boxed = audio.GetOverrideSampleSettings(unityPlatform); var type = typeof(AudioImporterSampleSettings); const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; foreach (var key in keys) { if (string.Equals(key, "overridden", StringComparison.OrdinalIgnoreCase)) { // Only accept a boolean; anything else is a misuse and goes to unknown[]. if (settings[key].Type == JTokenType.Boolean) result.Applied.Add(key); else result.Unknown.Add(key); continue; } if (TryApplyMember(boxed, type, flags, key, settings[key], write)) result.Applied.Add(key); else result.Unknown.Add(key); } if (write && result.Applied.Count > 0) { var settingsStruct = (AudioImporterSampleSettings)boxed; audio.SetOverrideSampleSettings(unityPlatform, settingsStruct); } } private static Dictionary ReadAudioDefaults(AudioImporter audio) { var sample = audio.defaultSampleSettings; return new Dictionary { ["forceToMono"] = audio.forceToMono, ["loadInBackground"] = audio.loadInBackground, ["ambisonic"] = audio.ambisonic, ["loadType"] = sample.loadType.ToString(), ["compressionFormat"] = sample.compressionFormat.ToString(), ["quality"] = sample.quality, ["sampleRateSetting"] = sample.sampleRateSetting.ToString(), ["sampleRateOverride"] = sample.sampleRateOverride }; } private static Dictionary ReadAudioPlatformOverride(AudioImporter audio, string canonicalPlatform, bool isDefault) { if (isDefault) { var sample = audio.defaultSampleSettings; return new Dictionary { ["overridden"] = false, ["loadType"] = sample.loadType.ToString(), ["compressionFormat"] = sample.compressionFormat.ToString(), ["quality"] = sample.quality, ["sampleRateSetting"] = sample.sampleRateSetting.ToString(), ["sampleRateOverride"] = sample.sampleRateOverride }; } var unityPlatform = UnityPlatformName(canonicalPlatform); var overridden = audio.ContainsSampleSettingsOverride(unityPlatform); var settings = audio.GetOverrideSampleSettings(unityPlatform); return new Dictionary { ["overridden"] = overridden, ["loadType"] = settings.loadType.ToString(), ["compressionFormat"] = settings.compressionFormat.ToString(), ["quality"] = settings.quality, ["sampleRateSetting"] = settings.sampleRateSetting.ToString(), ["sampleRateOverride"] = settings.sampleRateOverride }; } // ------------------------------------------------------------------ model defaults (no overrides) private static Dictionary ReadModelDefaults(ModelImporter model) { return new Dictionary { // Meshes ["globalScale"] = model.globalScale, ["useFileScale"] = model.useFileScale, ["meshCompression"] = model.meshCompression.ToString(), ["isReadable"] = model.isReadable, ["meshOptimizationFlags"] = model.meshOptimizationFlags.ToString(), ["weldVertices"] = model.weldVertices, ["indexFormat"] = model.indexFormat.ToString(), ["keepQuads"] = model.keepQuads, // Normals / Tangents ["importNormals"] = model.importNormals.ToString(), ["normalCalculationMode"] = model.normalCalculationMode.ToString(), ["normalSmoothingAngle"] = model.normalSmoothingAngle, ["importTangents"] = model.importTangents.ToString(), ["importBlendShapes"] = model.importBlendShapes, // Geometry ["importCameras"] = model.importCameras, ["importLights"] = model.importLights, ["swapUVChannels"] = model.swapUVChannels, ["generateSecondaryUV"] = model.generateSecondaryUV, // Materials ["materialImportMode"] = model.materialImportMode.ToString(), ["materialLocation"] = model.materialLocation.ToString(), // Animation ["importAnimation"] = model.importAnimation, ["animationType"] = model.animationType.ToString(), ["importConstraints"] = model.importConstraints, ["importVisibility"] = model.importVisibility }; } private static string Serialize(object value) { return Newtonsoft.Json.JsonConvert.SerializeObject(value); } } /// /// Result of set_import_settings: target platform plus which keys were applied vs. unknown. /// [Serializable] public class SetImportSettingsResult { [Newtonsoft.Json.JsonProperty("assetPath")] public string AssetPath { get; set; } [Newtonsoft.Json.JsonProperty("importerType")] public string ImporterType { get; set; } [Newtonsoft.Json.JsonProperty("platform")] public string Platform { get; set; } [Newtonsoft.Json.JsonProperty("applied")] public List Applied { get; set; } = new List(); [Newtonsoft.Json.JsonProperty("unknown")] public List Unknown { get; set; } = new List(); } /// /// Result of get_import_settings: the importer type, default-platform settings, and (for /// textures/audio) one platform-override block. platformOverride is null for ModelImporter and /// non-import assets. /// [Serializable] public class GetImportSettingsResult { [Newtonsoft.Json.JsonProperty("assetPath")] public string AssetPath { get; set; } [Newtonsoft.Json.JsonProperty("importerType")] public string ImporterType { get; set; } [Newtonsoft.Json.JsonProperty("settings")] public Dictionary Settings { get; set; } [Newtonsoft.Json.JsonProperty("platform")] public string Platform { get; set; } [Newtonsoft.Json.JsonProperty("platformOverride")] public Dictionary PlatformOverride { get; set; } } /// Structured error payload serialized into the thrown ArgumentException message. [Serializable] public class ErrorResult { [Newtonsoft.Json.JsonProperty("code")] public string Code { get; set; } [Newtonsoft.Json.JsonProperty("message")] public string Message { get; set; } } }