Utility for wardrobe/catalog of clothing items. Defines WardrobeItem record, lists default paths and categories, validates and toggles clothing selections, checks conflicts using ClothingResources, randomizes outfits, and loads clothing and related models asynchronously.
using System;
using System.Threading.Tasks;
namespace CharacterEditor;
public sealed record WardrobeItem(string Name, string Category, string Slot, string Path, string SlotsUnder = "", string SlotsOver = "", string HideBody = "", string NativeCategory = "");
public static partial class WardrobeCatalog
{
public static readonly string[] DefaultPaths =
{
"models/citizen_clothes/shirt/army_shirt/army_shirt.clothing",
"models/citizen_clothes/trousers/cargopants/brown_cargo_pants.clothing",
"models/citizen_clothes/shoes/boots/army_boots.clothing"
};
public static readonly string[] Categories =
{
"Tops",
"Bottoms",
"Shoes",
"Outerwear",
"Outfits",
"Glasses",
"Hats",
"Gloves",
"Accessories",
"Hair",
"Facial hair"
};
static bool Overlap(string a, string b) => (a ?? "").Split(',').Select(x => x.Trim()).Where(x => x.Length > 0).Intersect((b ?? "").Split(',').Select(x => x.Trim())).Any();
public static bool Conflicts(WardrobeItem a, WardrobeItem b)
{
var first = ClothingResources.Get(a.Path);
var second = ClothingResources.Get(b.Path);
if (first is not null && second is not null)
return !first.CanBeWornWith(second);
return a.Slot == b.Slot || Overlap(a.SlotsUnder, b.SlotsUnder) || Overlap(a.SlotsOver, b.SlotsOver);
}
public static IEnumerable<string> Paths(CharacterAppearanceData a) => (a.ClothingPaths ?? DefaultPaths).Select(p => p?.ToLowerInvariant());
public static string Validate(string[] paths)
{
if (paths is null)
return null;
if (paths.Length > 16 || paths.Distinct(StringComparer.OrdinalIgnoreCase).Count() != paths.Length)
return "Choose up to 16 different clothing items.";
var available = ClothingResources.Available.ToArray();
var items = paths.Select(p => available.FirstOrDefault(x => string.Equals(x.Path, p, StringComparison.OrdinalIgnoreCase))).ToArray();
// Native Dresser users may add their own human clothing resources. Keep
// portable paths while the engine handles those items' native metadata.
if (paths.Any(p => !ClothingResources.ValidReference(p)))
return "Use clothing resource paths or cloud:organization.item identities.";
var known = items.Where(i => i is not null).ToArray();
for (var i = 0; i < known.Length; i++)
for (var j = i + 1; j < known.Length; j++)
if (Conflicts(known[i], known[j]))
return "These clothing items occupy the same clothing slots.";
return null;
}
public static void Toggle(CharacterAppearanceData a, WardrobeItem item)
{
var selected = Paths(a).ToList();
var clothing = ClothingResources.Get(item.Path);
var worn = selected.FindIndex(path => string.Equals(path, item.Path, StringComparison.OrdinalIgnoreCase) || (clothing is not null && ClothingResources.Get(path) == clothing));
if (worn >= 0)
selected.RemoveAt(worn);
else
{
selected.RemoveAll(path =>
{
var other = ClothingResources.Available.FirstOrDefault(x => x.Path == path);
return other is not null && Conflicts(item, other);
});
selected.Add(item.Path);
}
a.ClothingPaths = selected.ToArray();
a.Version = 2;
}
// Deliberate everyday outfit preferences, separate from unrestricted manual choices.
public static string[] Randomize(Random rng, bool female)
{
var chosen = new List<WardrobeItem>();
bool Everyday(WardrobeItem i) => !i.Path.Contains("/seasonal/") && !i.Path.Contains("boxing") && !i.Path.Contains("football") && !i.Path.Contains("chef") && !i.Path.Contains("fisherman") && !i.Path.Contains("prison") && !i.Path.Contains("leotard");
void Pick(string category, Func<WardrobeItem, bool> allow)
{
var pool = Items.Where(i => i.Category == category && Everyday(i) && allow(i) && !chosen.Any(c => Conflicts(c, i))).ToArray();
if (pool.Length > 0)
chosen.Add(pool[rng.Next(pool.Length)]);
}
if (female && rng.NextDouble() < .25)
Pick("Outfits", i => i.NativeCategory == "Dress");
if (chosen.Count == 0)
{
Pick("Tops", i => female || !i.Path.Contains("crop_top"));
Pick("Bottoms", i => female || i.NativeCategory != "Skirt");
}
Pick("Shoes", i => female || i.NativeCategory != "Heels");
return chosen.Select(i => i.Path).ToArray();
}
public static async Task Load(CharacterAppearanceData a)
{
foreach (var path in Paths(a))
{
var item = await ClothingResources.Load(path);
if (item is null)
throw new InvalidOperationException("Clothing unavailable: " + path);
}
if (a.BaseShape is not null)
{
var skin = ClothingResources.Get(UnifiedHuman.PresentationSkin(a));
var tags = new TagSet(skin.HumanSkinTags.TryGetAll());
tags.Add("human");
var selected = new[]
{
skin
}.Concat(Paths(a).Select(ClothingResources.Get)).ToArray();
foreach (var item in selected.Skip(1))
{
await Model.LoadAsync(UnifiedWardrobe.ModelPath(item, selected, tags, a));
if ((item.HideBody & Clothing.BodyGroups.Head) != 0 && !string.IsNullOrEmpty(item.HumanSkinModel))
await Model.LoadAsync(item.HumanSkinModel);
}
}
}
}