Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 47b49bf20465486fb6ffdf6c03775b16
@@ -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; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cb5b302104dc446d8d94f33252087062
@@ -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>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7ff032cead554b7f97cd2467a83abd35
@@ -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);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b78420627d9cd1e43835d9088975e691
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f57a6d794d1e4c40976f65ec3774b8ed