Code/CharacterKit/ClothingResources.cs

Utility class that manages clothing asset references and previews for the character editor. It validates references, loads Clothing assets from local ResourceLibrary or cloud packages, caches loaded items and preview icon URLs, and exposes helpers to categorize and list available wardrobe items.

File AccessNetworking
using System;
using System.Threading.Tasks;

namespace CharacterEditor;

/// <summary>Native paths and portable cloud identities. Assets remain owned by their original packages.</summary>
public static class ClothingResources
{
	static readonly Dictionary<string, Clothing> loaded = new(StringComparer.OrdinalIgnoreCase);
	static readonly Dictionary<string, Task<Clothing>> pending = new(StringComparer.OrdinalIgnoreCase);
	static readonly Dictionary<string, Task> iconLoads = new(StringComparer.OrdinalIgnoreCase);
	static readonly System.Threading.SemaphoreSlim iconRequests = new(3);
	static readonly Dictionary<string, string> cloudIcons = new(StringComparer.OrdinalIgnoreCase);

	public static int Revision { get; private set; }

	public static bool IsCloud(string reference) => reference?.StartsWith("cloud:", StringComparison.OrdinalIgnoreCase) == true;

	public static bool ValidReference(string reference)
	{
		if (string.IsNullOrWhiteSpace(reference) || reference.Length > 256 || reference.Contains("..")) return false;
		if (!IsCloud(reference)) return reference.EndsWith(".clothing", StringComparison.OrdinalIgnoreCase);
		var ident = reference[6..];
		return ident.Split('.').Length == 2 && ident.Split('.').All(x => x.Length > 0) && ident.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-');
	}

	public static Clothing Get(string reference) => reference is null ? null : loaded.TryGetValue(reference, out var item) ? item : ResourceLibrary.Get<Clothing>(reference);

	public static string Reference(Clothing item)
	{
		var cloud = loaded.FirstOrDefault(x => IsCloud(x.Key) && x.Value == item).Key;
		if (cloud is not null) return cloud;
		var definition = item.SteamItemDefinitionId is { } id ? Sandbox.Services.Inventory.FindDefinition(id) : null;
		return !string.IsNullOrEmpty(definition?.PackageIdent) ? "cloud:" + definition.PackageIdent : item.ResourcePath;
	}

	public static Task<Clothing> Load(string reference)
	{
		if (!ValidReference(reference)) return Task.FromException<Clothing>(new InvalidOperationException("Use a .clothing asset path or cloud:creator.item reference."));
		if (Get(reference) is { } existing)
		{
			if (!SupportsHuman(existing)) return Task.FromException<Clothing>(new InvalidOperationException("This item does not have a human model."));
			return Finish(reference, existing);
		}
		if (pending.TryGetValue(reference, out var task) && !task.IsFaulted && !task.IsCanceled) return task;
		return pending[reference] = LoadMissing(reference);
	}

	static async Task<Clothing> LoadMissing(string reference)
	{
		if (!ValidReference(reference)) throw new InvalidOperationException("Invalid clothing reference.");
		var item = IsCloud(reference) ? await Cloud.Load<Clothing>(reference[6..]) : await ResourceLibrary.LoadAsync<Clothing>(reference);
		if (item is null || item.IsError) throw new InvalidOperationException("Clothing unavailable: " + reference);
		if (!SupportsHuman(item))
			throw new InvalidOperationException("This item does not have a human model.");
		loaded[reference] = item;
		Revision++;
		return await Finish(reference, item);
	}

	static async Task<Clothing> Finish(string reference, Clothing item)
	{
		if (IsCloud(reference) && !LocalIconExists(item) && !cloudIcons.ContainsKey(reference))
		{
			var definition = item.SteamItemDefinitionId is { } id ? Sandbox.Services.Inventory.FindDefinition(id) : null;
			var icon = definition?.IconUrl;
			try
			{
				if (string.IsNullOrEmpty(icon)) icon = (await Package.FetchAsync(reference[6..], partial: true))?.ThumbWide;
			}
			catch { /* A preview failure must not prevent wearing a downloaded item. */ }
			cloudIcons[reference] = icon ?? "";
			cloudIcons[item.ResourcePath] = icon ?? "";
			Revision++;
		}
		return item;
	}

	static bool LocalIconExists(Clothing item) => !string.IsNullOrEmpty(item.Icon.Path) &&
		(item.Icon.Path.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || FileSystem.Mounted.FileExists(item.Icon.Path));

	public static string Icon(string reference)
	{
		var item = Get(reference);
		if (item is not null && LocalIconExists(item)) return item.Icon.Path;
		if (reference is not null && cloudIcons.TryGetValue(reference, out var icon)) return icon;
		if (item is null) return "";
		if (cloudIcons.TryGetValue(item.ResourcePath, out icon)) return icon;
		if (!iconLoads.TryGetValue(item.ResourcePath, out var request) || request.IsCompleted)
			iconLoads[item.ResourcePath] = RecoverIcon(item);
		return "";
	}

	static async Task RecoverIcon(Clothing item)
	{
		await iconRequests.WaitAsync();
		try
		{
			var reference = Reference(item);
			if (IsCloud(reference))
			{
				await Finish(reference, item);
				return;
			}
			// Previously mounted clothing can appear as a local path without a session
			// alias. Match the package's primary asset, never just its display name.
			var definition = Sandbox.Services.Inventory.Definitions.FirstOrDefault(d => string.Equals(d.Asset, item.ResourcePath, StringComparison.OrdinalIgnoreCase));
			if (!string.IsNullOrEmpty(definition?.PackageIdent))
			{
				RegisterPreview(item, definition.PackageIdent, definition.IconUrl);
				return;
			}
			var words = string.Join(" ", (item.Title ?? "").Split(' ', StringSplitOptions.RemoveEmptyEntries).Where(x => !x.Contains(':')));
			if (words.Length == 0) return;
			var found = await Package.FindAsync(words + " type:clothing", 16);
			foreach (var candidate in found.Packages.OrderByDescending(p => string.Equals(p.Title, item.Title, StringComparison.OrdinalIgnoreCase)))
			{
				var package = await Package.FetchAsync(candidate.FullIdent, partial: false);
				if (!string.Equals(package?.PrimaryAsset?.TrimStart('/'), item.ResourcePath.TrimStart('/'), StringComparison.OrdinalIgnoreCase)) continue;
				RegisterPreview(item, package.FullIdent, package.ThumbWide);
				return;
			}
		}
		catch { /* Keep the picker usable when hosted previews are unavailable. */ }
		finally
		{
			cloudIcons.TryAdd(item.ResourcePath, "");
			Revision++;
			iconRequests.Release();
		}
	}

	static void RegisterPreview(Clothing item, string ident, string icon)
	{
		loaded["cloud:" + ident] = item;
		cloudIcons[item.ResourcePath] = icon ?? "";
		cloudIcons["cloud:" + ident] = icon ?? "";
	}

	public static bool IsHair(Clothing c) => c.Category.ToString().StartsWith("Hair");

	public static bool IsFacialHair(Clothing c) => c.Category.ToString().StartsWith("FacialHair");

	public static bool IsBaseSkin(Clothing c) => c.HasHumanSkin && string.IsNullOrEmpty(c.HumanAltModel) && string.IsNullOrEmpty(c.HumanAltFemaleModel);

	static bool SupportsHuman(Clothing c) => c.HasHumanSkin || !string.IsNullOrEmpty(c.HumanAltModel) || !string.IsNullOrEmpty(c.HumanAltFemaleModel);

	public static WardrobeItem Describe(string reference, Clothing item)
	{
		var category = Category(item.Category.ToString());
		return new(item.Title, category, category, reference, item.SlotsUnder.ToString(), item.SlotsOver.ToString(), item.HideBody.ToString(), item.Category.ToString());
	}
	static string Category(string c) => c switch
	{
		"Tops" or "TShirt" or "Sweatshirt" or "Hoodie" or "Shirt" or "Knitwear" => "Tops",
		"Bottoms" or "Shorts" or "Trousers" or "Jeans" or "Skirt" or "Underpants" => "Bottoms",
		"Footwear" or "Heels" or "Sandals" or "Shoes" or "Trainers" or "Boots" or "Slippers" or "Socks" => "Shoes",
		"Vest" or "Jacket" or "Cardigan" or "Coat" or "Gilet" => "Outerwear",
		"Fullbody" or "Dress" or "Suit" or "Costume" or "Uniform" => "Outfits",
		"Gloves" => "Gloves",
		_ when c.StartsWith("Glasses") || c == "Eyewear" => "Glasses",
		_ when c.StartsWith("Hat") || c == "Headwear" => "Hats",
		_ when c.StartsWith("FacialHair") => "Facial hair",
		_ when c.StartsWith("Hair") => "Hair",
		_ => "Accessories"
	};

	/// <summary>Project clothing is discovered automatically; cloud choices are kept for the session.</summary>

	public static IEnumerable<WardrobeItem> Available => WardrobeCatalog.Items
		.Concat(loaded.Where(x => !IsBaseSkin(x.Value)).Select(x => Describe(x.Key, x.Value)))
		.Concat(ResourceLibrary.GetAll<Clothing>().Where(c => !IsBaseSkin(c) && (!string.IsNullOrEmpty(c.HumanAltModel) || !string.IsNullOrEmpty(c.HumanAltFemaleModel)))
			.Where(c => !loaded.Values.Contains(c)).Select(c => Describe(Reference(c), c)))
		.DistinctBy(x => x.Path);
}