Add Unity Pipeline package support
This commit is contained in:
+602
@@ -0,0 +1,602 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
using PipelinePhysicsMaterial = UnityEngine.PhysicsMaterial;
|
||||
#else
|
||||
using PipelinePhysicsMaterial = UnityEngine.PhysicMaterial;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Asset lifecycle authoring commands (CLI-191): create / import / move / copy / rename / delete
|
||||
/// project assets. They sit on top of the CLI-190 authoring foundation, so:
|
||||
///
|
||||
/// - every agent-supplied path is funnelled through <see cref="ProjectPaths.Resolve"/> (sandboxed
|
||||
/// to the authoring root; rejects "../" and out-of-project writes),
|
||||
/// - results are returned as the canonical <see cref="AuthoringResult"/> envelope so an agent can
|
||||
/// reference the asset in a follow-up call,
|
||||
/// - existing assets are addressed with an <see cref="ObjectRef"/> resolved by
|
||||
/// <see cref="ObjectResolver"/>.
|
||||
///
|
||||
/// NOTE: AssetDatabase create/move/copy/rename/delete are NOT part of Unity's Undo system, so
|
||||
/// <see cref="AuthoringUndoScope"/> would have no effect here — these operations are intentionally
|
||||
/// not wrapped in an undo scope, and destructive/overwriting operations instead require an explicit
|
||||
/// <c>confirm</c> argument (and support <c>dry_run</c>) so an agent cannot silently lose data.
|
||||
/// </summary>
|
||||
public static class AssetCommands
|
||||
{
|
||||
[CliCommand("create_asset", "Create a new ScriptableObject (or other UnityEngine.Object) asset of the given type at a path under the authoring root.")]
|
||||
public static AuthoringResult CreateAsset(
|
||||
[CliArg("path", "Asset path relative to the authoring root, including extension (e.g. Data/Config.asset or Materials/Wall.mat). The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("type", "Fully-qualified or short type name to instantiate (e.g. UnityEngine.Material, MyGame.GameConfig). Must derive from UnityEngine.Object and be creatable.", Required = true)] string type,
|
||||
[CliArg("shader", "Material-only (ignored otherwise): shader name to assign (e.g. Standard, \"Universal Render Pipeline/Lit\"). When omitted, defaults to \"Universal Render Pipeline/Lit\" if a Scriptable Render Pipeline is active, otherwise the built-in \"Standard\" shader (falling back to \"Standard\" if URP/Lit is unavailable).")] string shader = null,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path. Ignored when the path is empty.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (string.IsNullOrEmpty(Path.GetExtension(normalized)))
|
||||
throw new ArgumentException($"Asset path '{normalized}' must include a file extension (e.g. .asset, .mat).");
|
||||
|
||||
var resolvedType = ResolveType(type);
|
||||
if (resolvedType == null)
|
||||
throw new ArgumentException($"Could not resolve type '{type}'. Use a fully-qualified name (e.g. UnityEngine.Material).");
|
||||
if (!typeof(Object).IsAssignableFrom(resolvedType))
|
||||
throw new ArgumentException($"Type '{resolvedType.FullName}' does not derive from UnityEngine.Object.");
|
||||
|
||||
// CLI-222: Unity 6 renamed PhysicMaterial -> PhysicsMaterial, but the on-disk asset
|
||||
// extension is still ".physicMaterial". AssetDatabase.CreateAsset rejects a
|
||||
// ".physicsMaterial" file ("should not be used to create a file of type 'physicsMaterial'"),
|
||||
// so accept either spelling from the agent and normalize to the extension Unity writes.
|
||||
if (typeof(PipelinePhysicsMaterial).IsAssignableFrom(resolvedType) &&
|
||||
normalized.EndsWith(".physicsMaterial", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized.Substring(0, normalized.Length - ".physicsMaterial".Length) + ".physicMaterial";
|
||||
}
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
// CLI-221: for a Material, resolve (and thereby validate) the shader up front so dry_run
|
||||
// also fails fast on an unknown shader — dry_run is documented as "validate inputs".
|
||||
Shader materialShader = null;
|
||||
if (typeof(Material).IsAssignableFrom(resolvedType))
|
||||
materialShader = ResolveShader(shader);
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = resolvedType.Name };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
|
||||
Object asset;
|
||||
if (typeof(ScriptableObject).IsAssignableFrom(resolvedType))
|
||||
{
|
||||
asset = ScriptableObject.CreateInstance(resolvedType);
|
||||
}
|
||||
else if (typeof(Material).IsAssignableFrom(resolvedType))
|
||||
{
|
||||
// CLI-221: Material has no public parameterless ctor (Activator.CreateInstance produces
|
||||
// an invalid Material with a null shader), so construct it explicitly from the shader
|
||||
// resolved (and validated) above. The `shader` arg is Material-specific and ignored for
|
||||
// every other type.
|
||||
asset = new Material(materialShader);
|
||||
}
|
||||
else if (typeof(PipelinePhysicsMaterial).IsAssignableFrom(resolvedType))
|
||||
{
|
||||
// CLI-222: PhysicsMaterial has a public parameterless ctor, but create it explicitly so
|
||||
// we can stamp the documented sensible defaults rather than relying on engine defaults.
|
||||
asset = new PipelinePhysicsMaterial
|
||||
{
|
||||
dynamicFriction = 0.6f,
|
||||
staticFriction = 0.6f,
|
||||
bounciness = 0f,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Other UnityEngine.Object subclasses with a public parameterless ctor.
|
||||
// Activator.CreateInstance throws for abstract types or types without an accessible
|
||||
// parameterless ctor — surface that as an actionable ArgumentException.
|
||||
try
|
||||
{
|
||||
asset = Activator.CreateInstance(resolvedType) as Object;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Type '{resolvedType.FullName}' could not be instantiated (it may be abstract or lack a public parameterless constructor): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (asset == null)
|
||||
throw new ArgumentException($"Type '{resolvedType.FullName}' could not be instantiated as a creatable asset.");
|
||||
|
||||
// AssetDatabase.CreateAsset does not reliably overwrite an existing asset at the path, so
|
||||
// explicitly delete it first. The confirm guard above gates that an asset already exists.
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(normalized);
|
||||
|
||||
AssetDatabase.CreateAsset(asset, normalized);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.ImportAsset(normalized);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = resolvedType.Name };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("import_asset", "Import an external file (e.g. a texture, model, audio clip) into the project by copying it to a path under the authoring root, then importing it.")]
|
||||
public static AuthoringResult ImportAsset(
|
||||
[CliArg("source", "Absolute filesystem path to the external file to import.", Required = true)] string source,
|
||||
[CliArg("path", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be imported without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
throw new ArgumentException("source is required.");
|
||||
if (!File.Exists(source))
|
||||
throw new ArgumentException($"Source file '{source}' does not exist.");
|
||||
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null || File.Exists(ToAbsolute(normalized));
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = "ImportedAsset" };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
File.Copy(source, ToAbsolute(normalized), overwrite: true);
|
||||
AssetDatabase.ImportAsset(normalized, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("move_asset", "Move (or rename via a new path) an asset to a new location under the authoring root. Preserves the asset's GUID.")]
|
||||
public static AuthoringResult MoveAsset(
|
||||
[CliArg("asset", "Reference to the asset to move (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination,
|
||||
[CliArg("dry_run", "If true, validate the move (via AssetDatabase.ValidateMoveAsset) without performing it.")] bool dryRun = false)
|
||||
{
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
var dest = ProjectPaths.Resolve(destination, out var error);
|
||||
if (dest == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
// For a real move, create the destination's parent folder *before* validating: Unity's
|
||||
// ValidateMoveAsset reports failure ("move as a subdirectory") when the target folder
|
||||
// doesn't exist yet. Dry-run must not create folders, so it validates against the current
|
||||
// project state only.
|
||||
if (!dryRun)
|
||||
EnsureParentFolder(dest);
|
||||
|
||||
var validation = AssetDatabase.ValidateMoveAsset(sourcePath, dest);
|
||||
if (!string.IsNullOrEmpty(validation))
|
||||
throw new ArgumentException($"Cannot move '{sourcePath}' to '{dest}': {validation}");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
|
||||
var moveError = AssetDatabase.MoveAsset(sourcePath, dest);
|
||||
if (!string.IsNullOrEmpty(moveError))
|
||||
throw new ArgumentException($"Failed to move '{sourcePath}' to '{dest}': {moveError}");
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(dest);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = dest;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("copy_asset", "Copy an asset to a new path under the authoring root. The copy gets a fresh GUID.")]
|
||||
public static AuthoringResult CopyAsset(
|
||||
[CliArg("asset", "Reference to the asset to copy (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be copied without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
var dest = ProjectPaths.Resolve(destination, out var error);
|
||||
if (dest == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(dest) != null;
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{dest}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
|
||||
EnsureParentFolder(dest);
|
||||
|
||||
// AssetDatabase.CopyAsset fails if the destination already exists, so delete it first when
|
||||
// the caller has confirmed an overwrite (the confirm guard above gates that).
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(dest);
|
||||
|
||||
if (!AssetDatabase.CopyAsset(sourcePath, dest))
|
||||
throw new ArgumentException($"Failed to copy '{sourcePath}' to '{dest}'.");
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(dest);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = dest;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("rename_asset", "Rename an asset in place (keeps it in the same folder, keeps its GUID).")]
|
||||
public static AuthoringResult RenameAsset(
|
||||
[CliArg("asset", "Reference to the asset to rename (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("new_name", "New file name WITHOUT a folder path. The extension is preserved if omitted.", Required = true)] string newName,
|
||||
[CliArg("dry_run", "If true, validate the rename without performing it.")] bool dryRun = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
throw new ArgumentException("new_name is required.");
|
||||
if (newName.Contains("/") || newName.Contains("\\"))
|
||||
throw new ArgumentException("new_name must be a file name only, not a path. Use move_asset to relocate an asset.");
|
||||
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
// Preserve the original extension when the agent omits one (AssetDatabase.RenameAsset expects no extension).
|
||||
var bareName = Path.GetFileNameWithoutExtension(newName);
|
||||
var extension = Path.GetExtension(sourcePath);
|
||||
var folder = Path.GetDirectoryName(sourcePath)?.Replace('\\', '/');
|
||||
var destPath = $"{folder}/{bareName}{extension}";
|
||||
|
||||
if (AssetDatabase.LoadMainAssetAtPath(destPath) != null)
|
||||
throw new ArgumentException($"An asset already exists at '{destPath}'.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = destPath, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
|
||||
var renameError = AssetDatabase.RenameAsset(sourcePath, bareName);
|
||||
if (!string.IsNullOrEmpty(renameError))
|
||||
throw new ArgumentException($"Failed to rename '{sourcePath}': {renameError}");
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(destPath);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = destPath;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("delete_asset", "Delete an asset from the project. Destructive: requires confirm=true.")]
|
||||
public static AuthoringResult DeleteAsset(
|
||||
[CliArg("asset", "Reference to the asset to delete (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("confirm", "Must be true to actually delete. Without it the command refuses (destructive guard).")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, report the asset that would be deleted without deleting it.")] bool dryRun = false)
|
||||
{
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
// Capture the identity before deletion so the agent gets a record of what was removed.
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(sourcePath);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
result.AssetPath = sourcePath;
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
if (!confirm)
|
||||
throw new ArgumentException($"Refusing to delete '{sourcePath}'. Pass confirm=true to delete it (destructive, not undoable via Unity's Undo).");
|
||||
|
||||
if (!AssetDatabase.DeleteAsset(sourcePath))
|
||||
throw new ArgumentException($"Failed to delete '{sourcePath}'.");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("find_assets", "Find assets by type and/or name and/or label, returning their path, GUID and type. At least one filter is required.")]
|
||||
public static FindAssetsResult FindAssets(
|
||||
[CliArg("type", "Type name to filter by (e.g. Material, GameObject, ScriptableObject, MyGame.GameConfig). Resolved to a System.Type and matched against each asset's actual main type.")] string type = null,
|
||||
[CliArg("name", "Name substring to filter by (AssetDatabase name filter).")] string name = null,
|
||||
[CliArg("label", "Asset label to filter by (AssetDatabase 'l:' filter).")] string label = null,
|
||||
[CliArg("search_in", "Folder to scope the search to, relative to the authoring root (default: the authoring root).")] string searchIn = null,
|
||||
[CliArg("limit", "Maximum number of results to return (default 200).")] int limit = 200)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type) && string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(label))
|
||||
throw new ArgumentException("At least one of type, name, or label is required.");
|
||||
|
||||
// The type filter is applied by post-filtering on each asset's actual loaded type rather
|
||||
// than via AssetDatabase's "t:" token: "t:" does NOT reliably match freshly-created/custom
|
||||
// ScriptableObject assets (it can return 0 even for "t:ScriptableObject"), though it works
|
||||
// for built-in types. Name/label searches do work, so build the query from those only.
|
||||
var filterParts = new System.Collections.Generic.List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
filterParts.Add(name.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(label))
|
||||
filterParts.Add($"l:{label.Trim()}");
|
||||
var filter = string.Join(" ", filterParts);
|
||||
|
||||
// Default the search scope to the authoring root (not the whole project) so an agent only
|
||||
// ever discovers assets within its sandbox unless an explicit scope is given.
|
||||
var scopeError = (string)null;
|
||||
var scope = !string.IsNullOrWhiteSpace(searchIn)
|
||||
? ProjectPaths.Resolve(searchIn, out scopeError)
|
||||
: ProjectPaths.AuthoringRoot;
|
||||
if (scope == null)
|
||||
throw new ArgumentException(scopeError);
|
||||
var searchFolders = new[] { scope };
|
||||
|
||||
// Resolve the type filter up front so an unresolvable name fails fast with a clear message.
|
||||
Type typeFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
typeFilter = ResolveTypeName(type.Trim());
|
||||
if (typeFilter == null)
|
||||
throw new ArgumentException(
|
||||
$"Could not resolve type '{type}'. Use a short name (e.g. Material) or a fully-qualified name (e.g. MyGame.GameConfig).");
|
||||
}
|
||||
|
||||
// An empty name/label filter enumerates every asset under the search folders.
|
||||
var guids = AssetDatabase.FindAssets(filter ?? string.Empty, searchFolders);
|
||||
|
||||
var result = new FindAssetsResult { Filter = filter };
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
continue;
|
||||
|
||||
var mainType = AssetDatabase.GetMainAssetTypeAtPath(assetPath);
|
||||
if (typeFilter != null && (mainType == null || !typeFilter.IsAssignableFrom(mainType)))
|
||||
continue;
|
||||
|
||||
if (result.Assets.Count >= limit)
|
||||
{
|
||||
result.Truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
result.Assets.Add(new AuthoringResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
Guid = guid,
|
||||
Type = mainType?.Name
|
||||
});
|
||||
}
|
||||
|
||||
result.Count = result.Assets.Count;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a user-supplied type name (short or fully-qualified) to a <see cref="Type"/> by
|
||||
/// scanning non-dynamic loaded assemblies. Returns null when no match is found.
|
||||
/// </summary>
|
||||
private static Type ResolveTypeName(string typeName)
|
||||
{
|
||||
var direct = Type.GetType(typeName, throwOnError: false);
|
||||
if (direct != null)
|
||||
return direct;
|
||||
|
||||
var physicsMaterialType = ResolvePhysicsMaterialTypeName(typeName);
|
||||
if (physicsMaterialType != null)
|
||||
return physicsMaterialType;
|
||||
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
if (assembly.IsDynamic)
|
||||
continue;
|
||||
|
||||
var byFullName = assembly.GetType(typeName, throwOnError: false);
|
||||
if (byFullName != null)
|
||||
return byFullName;
|
||||
}
|
||||
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
if (assembly.IsDynamic)
|
||||
continue;
|
||||
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types;
|
||||
}
|
||||
|
||||
foreach (var candidate in types)
|
||||
{
|
||||
if (candidate != null && candidate.Name == typeName)
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an <see cref="ObjectRef"/> to a project-relative asset path, rejecting handles that
|
||||
/// do not resolve to an on-disk asset (scene objects have no asset path). The resolved path is
|
||||
/// confined to the authoring root so a GUID/globalId handle cannot make a destructive command
|
||||
/// (delete/move/copy/rename) operate on an asset outside the sandbox.
|
||||
/// </summary>
|
||||
private static string ResolveAssetPath(ObjectRef asset)
|
||||
{
|
||||
var assetPath = ObjectResolver.TryResolve(asset, out var obj, out var error)
|
||||
? AssetDatabase.GetAssetPath(obj)
|
||||
: null;
|
||||
|
||||
// Fallback: a path reference that points at an asset which exists on disk (a GUID is
|
||||
// registered) but whose object cannot be loaded — e.g. a just-moved asset whose object
|
||||
// isn't reloadable this frame, or an asset with an unresolved/broken script. We can still
|
||||
// operate on it by path, so honor the path rather than failing the whole command.
|
||||
if (string.IsNullOrEmpty(assetPath) && !string.IsNullOrEmpty(asset?.Path))
|
||||
{
|
||||
var candidate = ProjectPaths.Resolve(asset.Path, out _);
|
||||
if (candidate != null && !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(candidate)))
|
||||
assetPath = candidate;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException(error ?? $"Reference '{asset}' does not point at an on-disk asset.");
|
||||
|
||||
// Confine to the authoring root. ProjectPaths.Resolve treats explicit "Assets/..." paths as
|
||||
// project-relative and rejects anything outside the configured root.
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Asset '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return confined;
|
||||
}
|
||||
|
||||
/// <summary>Create the asset's parent folder chain if it does not yet exist.</summary>
|
||||
private static void EnsureParentFolder(string assetPath)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
|
||||
return;
|
||||
|
||||
CreateFolderRecursive(parent);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
|
||||
/// <summary>Convert a project-relative asset path to an absolute filesystem path.</summary>
|
||||
private static string ToAbsolute(string projectRelative)
|
||||
{
|
||||
return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-221: resolve the shader to assign to a newly-created <see cref="Material"/>.
|
||||
/// - An explicit <paramref name="shaderName"/> must resolve via <see cref="Shader.Find"/>;
|
||||
/// a miss is a clear, actionable error (no null Material / null-ref downstream).
|
||||
/// - When omitted, default to the active render pipeline's lit shader: "Universal Render
|
||||
/// Pipeline/Lit" when an SRP asset is active, else built-in "Standard". If that default
|
||||
/// can't be found, fall back to "Standard"; if even that is missing, error clearly.
|
||||
/// </summary>
|
||||
private static Shader ResolveShader(string shaderName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(shaderName))
|
||||
{
|
||||
var requested = Shader.Find(shaderName.Trim());
|
||||
if (requested == null)
|
||||
throw new ArgumentException($"Shader '{shaderName}' not found.");
|
||||
return requested;
|
||||
}
|
||||
|
||||
var usingSrp = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline != null;
|
||||
var defaultName = usingSrp ? "Universal Render Pipeline/Lit" : "Standard";
|
||||
|
||||
var defaultShader = Shader.Find(defaultName);
|
||||
if (defaultShader != null)
|
||||
return defaultShader;
|
||||
|
||||
// Fall back to the built-in Standard shader (always present in the Built-in RP, and a sane
|
||||
// last resort even under an SRP project that lacks the URP/Lit shader).
|
||||
var standard = Shader.Find("Standard");
|
||||
if (standard != null)
|
||||
return standard;
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Could not resolve a default shader ('{defaultName}' or 'Standard'). Pass an explicit shader= argument.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a user-supplied type name to a <see cref="Type"/>. Tries the name as-is (covers
|
||||
/// fully-qualified names), then scans loaded assemblies for an exact full-name or short-name
|
||||
/// match (covers "Material" or a user type given without its namespace).
|
||||
/// </summary>
|
||||
private static Type ResolveType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typeName))
|
||||
return null;
|
||||
|
||||
// Trim once up front so every resolution path below (special-case + assembly/type scans)
|
||||
// sees the same normalized name — leading/trailing whitespace must not be accepted for one
|
||||
// type and rejected for another.
|
||||
typeName = typeName.Trim();
|
||||
|
||||
// CLI-222: in Unity 6 the type is UnityEngine.PhysicsMaterial (renamed from PhysicMaterial).
|
||||
// A short name "PhysicsMaterial" resolves fine, but the legacy short name "PhysicMaterial"
|
||||
// matches the obsolete forwarder/alias inconsistently across versions — normalize BOTH the
|
||||
// current and legacy names (short or fully-qualified) to the real type up front so callers
|
||||
// can use either spelling.
|
||||
if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial"
|
||||
|| typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial")
|
||||
{
|
||||
return typeof(PipelinePhysicsMaterial);
|
||||
}
|
||||
|
||||
var direct = Type.GetType(typeName, throwOnError: false);
|
||||
if (direct != null)
|
||||
return direct;
|
||||
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
var byFullName = assembly.GetType(typeName, throwOnError: false);
|
||||
if (byFullName != null)
|
||||
return byFullName;
|
||||
}
|
||||
|
||||
// Fall back to a short-name match across all loaded types.
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types;
|
||||
}
|
||||
|
||||
foreach (var candidate in types)
|
||||
{
|
||||
if (candidate != null && candidate.Name == typeName)
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Type ResolvePhysicsMaterialTypeName(string typeName)
|
||||
{
|
||||
if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial"
|
||||
|| typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial")
|
||||
{
|
||||
return typeof(PipelinePhysicsMaterial);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b49bf20465486fb6ffdf6c03775b16
|
||||
+654
@@ -0,0 +1,654 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Import-settings authoring commands (CLI-191 + CLI-212). Reads and edits the
|
||||
/// <see cref="AssetImporter"/> for an asset.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>set_import_settings</c> sets named members on the importer from a JSON object and re-imports.
|
||||
/// For the <c>Default</c> platform it sets top-level public properties/fields via reflection (works
|
||||
/// across every importer kind). For a real platform on a <see cref="TextureImporter"/> /
|
||||
/// <see cref="AudioImporter"/>, keys are applied onto the platform-override struct
|
||||
/// (<see cref="TextureImporterPlatformSettings"/> / <see cref="AudioImporterSampleSettings"/>) which
|
||||
/// is unreachable by top-level reflection. <see cref="ModelImporter"/> has no per-platform overrides.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>get_import_settings</c> reads the importer state structured by importer type (texture / model /
|
||||
/// audio), including the default-platform fields and, for textures/audio, one platform override block.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Unknown keys are reported back rather than silently ignored, so an agent gets actionable feedback.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 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 <c>confirm</c> is required.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<string> s_SupportedPlatforms = new HashSet<string>(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<string>();
|
||||
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<string, object>
|
||||
{
|
||||
["userData"] = importer.userData,
|
||||
["assetBundleName"] = importer.assetBundleName
|
||||
};
|
||||
result.PlatformOverride = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ resolution / helpers
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an <see cref="ObjectRef"/> to its <see cref="AssetImporter"/>, confined to the
|
||||
/// authoring root. Throws an actionable <see cref="ArgumentException"/> on any failure.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Normalize a caller platform name to its canonical capitalization (e.g. "ios" -> "iOS").</summary>
|
||||
private static string Canonical(string platform)
|
||||
{
|
||||
foreach (var supported in s_SupportedPlatforms)
|
||||
{
|
||||
if (string.Equals(supported, platform, StringComparison.OrdinalIgnoreCase))
|
||||
return supported;
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a JSON token to <paramref name="targetType"/> (with case-insensitive enum-name support)
|
||||
/// and, on success, invoke <paramref name="set"/>. Returns false when the value cannot convert.
|
||||
/// </summary>
|
||||
private static bool TryConvertAndSet(Type targetType, JToken value, Action<object> set)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryConvert(targetType, value, out var converted))
|
||||
return false;
|
||||
set(converted);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="JToken.ToObject(Type)"/>.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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<long>());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
converted = value.ToObject(targetType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ texture platform overrides
|
||||
|
||||
private static void ApplyTexturePlatform(TextureImporter texture, JObject settings, List<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="write"/>=false (dry-run) mode the member is only
|
||||
/// probed for existence, never mutated.
|
||||
/// </summary>
|
||||
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<bool>());
|
||||
}
|
||||
|
||||
/// <summary>True when a settable boolean property or field with that name exists on the type.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a boolean property or field (matching <see cref="TryApplyMember"/>'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.
|
||||
/// </summary>
|
||||
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<string, object> ReadTextureDefaults(TextureImporter texture)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["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<string, object> ReadTexturePlatformOverride(TextureImporter texture, string canonicalPlatform, bool isDefault)
|
||||
{
|
||||
var settings = isDefault
|
||||
? texture.GetDefaultPlatformTextureSettings()
|
||||
: texture.GetPlatformTextureSettings(UnityPlatformName(canonicalPlatform));
|
||||
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["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<string> 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<bool>())
|
||||
{
|
||||
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<string, object> ReadAudioDefaults(AudioImporter audio)
|
||||
{
|
||||
var sample = audio.defaultSampleSettings;
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["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<string, object> ReadAudioPlatformOverride(AudioImporter audio, string canonicalPlatform, bool isDefault)
|
||||
{
|
||||
if (isDefault)
|
||||
{
|
||||
var sample = audio.defaultSampleSettings;
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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<string, object> ReadModelDefaults(ModelImporter model)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>set_import_settings</c>: target platform plus which keys were applied vs. unknown.
|
||||
/// </summary>
|
||||
[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<string> Applied { get; set; } = new List<string>();
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("unknown")]
|
||||
public List<string> Unknown { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>get_import_settings</c>: the importer type, default-platform settings, and (for
|
||||
/// textures/audio) one platform-override block. <c>platformOverride</c> is null for ModelImporter and
|
||||
/// non-import assets.
|
||||
/// </summary>
|
||||
[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<string, object> Settings { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("platform")]
|
||||
public string Platform { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("platformOverride")]
|
||||
public Dictionary<string, object> PlatformOverride { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Structured error payload serialized into the thrown ArgumentException message.</summary>
|
||||
[Serializable]
|
||||
public class ErrorResult
|
||||
{
|
||||
[Newtonsoft.Json.JsonProperty("code")]
|
||||
public string Code { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb5b302104dc446d8d94f33252087062
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Structured result for the <c>find_assets</c> command (CLI-191): a list of matched assets, each
|
||||
/// described as the canonical <see cref="AuthoringResult"/> (path / GUID / type) so an agent can
|
||||
/// feed any entry straight back into another command as an <see cref="ObjectRef"/>.
|
||||
///
|
||||
/// Lives in the Editor command assembly (rather than Runtime/Models) because find_assets is an
|
||||
/// Editor-only command and the foundation Runtime/Models are shared/frozen for parallel work.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public class FindAssetsResult
|
||||
{
|
||||
/// <summary>The AssetDatabase filter string that was executed (for diagnostics).</summary>
|
||||
[JsonProperty("filter")]
|
||||
public string Filter { get; set; }
|
||||
|
||||
/// <summary>Number of assets returned (after the limit is applied).</summary>
|
||||
[JsonProperty("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>True when the result was capped by the <c>limit</c> argument.</summary>
|
||||
[JsonProperty("truncated")]
|
||||
public bool Truncated { get; set; }
|
||||
|
||||
/// <summary>The matched assets, each with path / GUID / type.</summary>
|
||||
[JsonProperty("assets")]
|
||||
public List<AuthoringResult> Assets { get; set; } = new List<AuthoringResult>();
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ff032cead554b7f97cd2467a83abd35
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Asset-folder authoring commands. Reference exemplar for the authoring foundation (CLI-190):
|
||||
/// it validates input through the project-path sandbox (<see cref="ProjectPaths"/>) and returns
|
||||
/// the canonical <see cref="AuthoringResult"/> envelope so an agent can reference the created
|
||||
/// folder in follow-up calls.
|
||||
/// </summary>
|
||||
public static class FolderCommands
|
||||
{
|
||||
[CliCommand("create_folder", "Create a folder under the authoring root (creates intermediate folders).")]
|
||||
public static AuthoringResult CreateFolder(
|
||||
[CliArg("path", "Folder path relative to the authoring root (default Assets/); the Assets/ prefix is optional. e.g. Gameplay/Enemies or Assets/Gameplay/Enemies", Required = true)] string path)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
CreateFolderRecursive(normalized);
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
var folder = AssetDatabase.LoadAssetAtPath<DefaultAsset>(normalized);
|
||||
var result = ObjectResolver.Describe(folder) ?? new AuthoringResult { Type = "DefaultAsset" };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b78420627d9cd1e43835d9088975e691
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Text-file authoring commands (CLI-191): read and write UTF-8 text files inside the authoring
|
||||
/// sandbox. Useful for editing data assets that are plain text (JSON/CSV/.txt/.cs/.shader, etc.).
|
||||
///
|
||||
/// Every path is funnelled through <see cref="ProjectPaths.Resolve"/>, so reads/writes are confined
|
||||
/// to the authoring root and "../"/out-of-project paths are rejected. Writing imports the file back
|
||||
/// into the AssetDatabase so Unity picks up the change.
|
||||
///
|
||||
/// NOTE: writing is potentially destructive (it can overwrite an existing file), so overwriting an
|
||||
/// existing file requires an explicit <c>confirm</c> argument; <c>dry_run</c> is supported.
|
||||
/// Filesystem writes are not part of Unity's Undo system.
|
||||
/// </summary>
|
||||
public static class TextFileCommands
|
||||
{
|
||||
[CliCommand("read_text_file", "Read a UTF-8 text file under the authoring root and return its contents.", MainThreadRequired = true)]
|
||||
public static ReadTextFileResult ReadTextFile(
|
||||
[CliArg("path", "Text file path relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("max_bytes", "Reject files larger than this many bytes (default 1048576 = 1 MiB) to avoid huge payloads.")] int maxBytes = 1024 * 1024)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var absolute = ToAbsolute(normalized);
|
||||
if (!File.Exists(absolute))
|
||||
throw new ArgumentException($"No file at '{normalized}'.");
|
||||
|
||||
var length = new FileInfo(absolute).Length;
|
||||
if (length > maxBytes)
|
||||
throw new ArgumentException($"File '{normalized}' is {length} bytes, which exceeds max_bytes={maxBytes}.");
|
||||
|
||||
return new ReadTextFileResult
|
||||
{
|
||||
AssetPath = normalized,
|
||||
Bytes = (int)length,
|
||||
Contents = File.ReadAllText(absolute)
|
||||
};
|
||||
}
|
||||
|
||||
[CliCommand("write_text_file", "Write UTF-8 text to a file under the authoring root, then import it. Overwriting an existing file requires confirm=true.")]
|
||||
public static AuthoringResult WriteTextFile(
|
||||
[CliArg("path", "Text file path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("contents", "The full text content to write (replaces the file).", Required = true)] string contents,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing file at the path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be written without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (contents == null)
|
||||
throw new ArgumentException("contents is required (use an empty string to write an empty file).");
|
||||
|
||||
var absolute = ToAbsolute(normalized);
|
||||
var exists = File.Exists(absolute);
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"A file already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = "TextAsset" };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
File.WriteAllText(absolute, contents);
|
||||
AssetDatabase.ImportAsset(normalized, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = "TextAsset" };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EnsureParentFolder(string assetPath)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
|
||||
return;
|
||||
|
||||
CreateFolderRecursive(parent);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
|
||||
private static string ToAbsolute(string projectRelative)
|
||||
{
|
||||
return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of <c>read_text_file</c>: the file contents plus its identity and size.</summary>
|
||||
[Serializable]
|
||||
public class ReadTextFileResult
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("bytes")]
|
||||
public int Bytes { get; set; }
|
||||
|
||||
[JsonProperty("contents")]
|
||||
public string Contents { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f57a6d794d1e4c40976f65ec3774b8ed
|
||||
Reference in New Issue
Block a user