mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-13 12:14:17 +01:00
Merge branch 'mdl-export-materials'
This commit is contained in:
commit
4749769dd4
5 changed files with 531 additions and 63 deletions
382
Penumbra/Import/Models/Export/MaterialExporter.cs
Normal file
382
Penumbra/Import/Models/Export/MaterialExporter.cs
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
using Lumina.Data.Parsing;
|
||||||
|
using Penumbra.GameData.Files;
|
||||||
|
using SharpGLTF.Materials;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Advanced;
|
||||||
|
using SixLabors.ImageSharp.Formats.Png;
|
||||||
|
using SixLabors.ImageSharp.Memory;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
|
namespace Penumbra.Import.Models.Export;
|
||||||
|
|
||||||
|
using ImageSharpConfiguration = SixLabors.ImageSharp.Configuration;
|
||||||
|
|
||||||
|
public class MaterialExporter
|
||||||
|
{
|
||||||
|
public struct Material
|
||||||
|
{
|
||||||
|
public MtrlFile Mtrl;
|
||||||
|
|
||||||
|
public Dictionary<TextureUsage, Image<Rgba32>> Textures;
|
||||||
|
// variant?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Build a glTF material from a hydrated XIV model, with the provided name. </summary>
|
||||||
|
public static MaterialBuilder Export(Material material, string name)
|
||||||
|
{
|
||||||
|
Penumbra.Log.Debug($"Exporting material \"{name}\".");
|
||||||
|
return material.Mtrl.ShaderPackage.Name switch
|
||||||
|
{
|
||||||
|
// NOTE: this isn't particularly precise to game behavior (it has some fade around high opacity), but good enough for now.
|
||||||
|
"character.shpk" => BuildCharacter(material, name).WithAlpha(AlphaMode.MASK, 0.5f),
|
||||||
|
"characterglass.shpk" => BuildCharacter(material, name).WithAlpha(AlphaMode.BLEND),
|
||||||
|
"hair.shpk" => BuildHair(material, name),
|
||||||
|
"iris.shpk" => BuildIris(material, name),
|
||||||
|
"skin.shpk" => BuildSkin(material, name),
|
||||||
|
_ => BuildFallback(material, name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Build a material following the semantics of character.shpk. </summary>
|
||||||
|
private static MaterialBuilder BuildCharacter(Material material, string name)
|
||||||
|
{
|
||||||
|
// Build the textures from the color table.
|
||||||
|
var table = material.Mtrl.Table;
|
||||||
|
|
||||||
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
||||||
|
|
||||||
|
var operation = new ProcessCharacterNormalOperation(normal, table);
|
||||||
|
ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, normal.Bounds(), in operation);
|
||||||
|
|
||||||
|
// Check if full textures are provided, and merge in if available.
|
||||||
|
var baseColor = operation.BaseColor;
|
||||||
|
if (material.Textures.TryGetValue(TextureUsage.SamplerDiffuse, out var diffuse))
|
||||||
|
{
|
||||||
|
MultiplyOperation.Execute(diffuse, operation.BaseColor);
|
||||||
|
baseColor = diffuse;
|
||||||
|
}
|
||||||
|
|
||||||
|
Image specular = operation.Specular;
|
||||||
|
if (material.Textures.TryGetValue(TextureUsage.SamplerSpecular, out var specularTexture))
|
||||||
|
{
|
||||||
|
MultiplyOperation.Execute(specularTexture, operation.Specular);
|
||||||
|
specular = specularTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull further information from the mask.
|
||||||
|
if (material.Textures.TryGetValue(TextureUsage.SamplerMask, out var maskTexture))
|
||||||
|
{
|
||||||
|
// Extract the red channel for "ambient occlusion".
|
||||||
|
maskTexture.Mutate(context => context.Resize(baseColor.Width, baseColor.Height));
|
||||||
|
maskTexture.ProcessPixelRows(baseColor, (maskAccessor, baseColorAccessor) =>
|
||||||
|
{
|
||||||
|
for (var y = 0; y < maskAccessor.Height; y++)
|
||||||
|
{
|
||||||
|
var maskSpan = maskAccessor.GetRowSpan(y);
|
||||||
|
var baseColorSpan = baseColorAccessor.GetRowSpan(y);
|
||||||
|
|
||||||
|
for (var x = 0; x < maskSpan.Length; x++)
|
||||||
|
baseColorSpan[x].FromVector4(baseColorSpan[x].ToVector4() * new Vector4(maskSpan[x].R / 255f));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// TODO: handle other textures stored in the mask?
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildSharedBase(material, name)
|
||||||
|
.WithBaseColor(BuildImage(baseColor, name, "basecolor"))
|
||||||
|
.WithNormal(BuildImage(operation.Normal, name, "normal"))
|
||||||
|
.WithSpecularColor(BuildImage(specular, name, "specular"))
|
||||||
|
.WithEmissive(BuildImage(operation.Emissive, name, "emissive"), Vector3.One, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: It feels a little silly to request the entire normal here when extracting the normal only needs some of the components.
|
||||||
|
// As a future refactor, it would be neat to accept a single-channel field here, and then do composition of other stuff later.
|
||||||
|
private readonly struct ProcessCharacterNormalOperation(Image<Rgba32> normal, MtrlFile.ColorTable table) : IRowOperation
|
||||||
|
{
|
||||||
|
public Image<Rgba32> Normal { get; } = normal.Clone();
|
||||||
|
public Image<Rgba32> BaseColor { get; } = new(normal.Width, normal.Height);
|
||||||
|
public Image<Rgb24> Specular { get; } = new(normal.Width, normal.Height);
|
||||||
|
public Image<Rgb24> Emissive { get; } = new(normal.Width, normal.Height);
|
||||||
|
|
||||||
|
private Buffer2D<Rgba32> NormalBuffer
|
||||||
|
=> Normal.Frames.RootFrame.PixelBuffer;
|
||||||
|
|
||||||
|
private Buffer2D<Rgba32> BaseColorBuffer
|
||||||
|
=> BaseColor.Frames.RootFrame.PixelBuffer;
|
||||||
|
|
||||||
|
private Buffer2D<Rgb24> SpecularBuffer
|
||||||
|
=> Specular.Frames.RootFrame.PixelBuffer;
|
||||||
|
|
||||||
|
private Buffer2D<Rgb24> EmissiveBuffer
|
||||||
|
=> Emissive.Frames.RootFrame.PixelBuffer;
|
||||||
|
|
||||||
|
public void Invoke(int y)
|
||||||
|
{
|
||||||
|
var normalSpan = NormalBuffer.DangerousGetRowSpan(y);
|
||||||
|
var baseColorSpan = BaseColorBuffer.DangerousGetRowSpan(y);
|
||||||
|
var specularSpan = SpecularBuffer.DangerousGetRowSpan(y);
|
||||||
|
var emissiveSpan = EmissiveBuffer.DangerousGetRowSpan(y);
|
||||||
|
|
||||||
|
for (var x = 0; x < normalSpan.Length; x++)
|
||||||
|
{
|
||||||
|
ref var normalPixel = ref normalSpan[x];
|
||||||
|
|
||||||
|
// Table row data (.a)
|
||||||
|
var tableRow = GetTableRowIndices(normalPixel.A / 255f);
|
||||||
|
var prevRow = table[tableRow.Previous];
|
||||||
|
var nextRow = table[tableRow.Next];
|
||||||
|
|
||||||
|
// Base colour (table, .b)
|
||||||
|
var lerpedDiffuse = Vector3.Lerp(prevRow.Diffuse, nextRow.Diffuse, tableRow.Weight);
|
||||||
|
baseColorSpan[x].FromVector4(new Vector4(lerpedDiffuse, 1));
|
||||||
|
baseColorSpan[x].A = normalPixel.B;
|
||||||
|
|
||||||
|
// Specular (table)
|
||||||
|
var lerpedSpecularColor = Vector3.Lerp(prevRow.Specular, nextRow.Specular, tableRow.Weight);
|
||||||
|
specularSpan[x].FromVector4(new Vector4(lerpedSpecularColor, 1));
|
||||||
|
|
||||||
|
// Emissive (table)
|
||||||
|
var lerpedEmissive = Vector3.Lerp(prevRow.Emissive, nextRow.Emissive, tableRow.Weight);
|
||||||
|
emissiveSpan[x].FromVector4(new Vector4(lerpedEmissive, 1));
|
||||||
|
|
||||||
|
// Normal (.rg)
|
||||||
|
// TODO: we don't actually need alpha at all for normal, but _not_ using the existing rgba texture means I'll need a new one, with a new accessor. Think about it.
|
||||||
|
normalPixel.B = byte.MaxValue;
|
||||||
|
normalPixel.A = byte.MaxValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TableRow GetTableRowIndices(float input)
|
||||||
|
{
|
||||||
|
// These calculations are ported from character.shpk.
|
||||||
|
var smoothed = MathF.Floor(input * 7.5f % 1.0f * 2)
|
||||||
|
* (-input * 15 + MathF.Floor(input * 15 + 0.5f))
|
||||||
|
+ input * 15;
|
||||||
|
|
||||||
|
var stepped = MathF.Floor(smoothed + 0.5f);
|
||||||
|
|
||||||
|
return new TableRow
|
||||||
|
{
|
||||||
|
Stepped = (int)stepped,
|
||||||
|
Previous = (int)MathF.Floor(smoothed),
|
||||||
|
Next = (int)MathF.Ceiling(smoothed),
|
||||||
|
Weight = smoothed % 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ref struct TableRow
|
||||||
|
{
|
||||||
|
public int Stepped;
|
||||||
|
public int Previous;
|
||||||
|
public int Next;
|
||||||
|
public float Weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct MultiplyOperation
|
||||||
|
{
|
||||||
|
public static void Execute<TPixel1, TPixel2>(Image<TPixel1> target, Image<TPixel2> multiplier)
|
||||||
|
where TPixel1 : unmanaged, IPixel<TPixel1>
|
||||||
|
where TPixel2 : unmanaged, IPixel<TPixel2>
|
||||||
|
{
|
||||||
|
// Ensure the images are the same size
|
||||||
|
var (small, large) = target.Width < multiplier.Width && target.Height < multiplier.Height
|
||||||
|
? ((Image)target, (Image)multiplier)
|
||||||
|
: (multiplier, target);
|
||||||
|
small.Mutate(context => context.Resize(large.Width, large.Height));
|
||||||
|
|
||||||
|
var operation = new MultiplyOperation<TPixel1, TPixel2>(target, multiplier);
|
||||||
|
ParallelRowIterator.IterateRows(ImageSharpConfiguration.Default, target.Bounds(), in operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly struct MultiplyOperation<TPixel1, TPixel2>(Image<TPixel1> target, Image<TPixel2> multiplier) : IRowOperation
|
||||||
|
where TPixel1 : unmanaged, IPixel<TPixel1>
|
||||||
|
where TPixel2 : unmanaged, IPixel<TPixel2>
|
||||||
|
{
|
||||||
|
public void Invoke(int y)
|
||||||
|
{
|
||||||
|
var targetSpan = target.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y);
|
||||||
|
var multiplierSpan = multiplier.Frames.RootFrame.PixelBuffer.DangerousGetRowSpan(y);
|
||||||
|
|
||||||
|
for (var x = 0; x < targetSpan.Length; x++)
|
||||||
|
targetSpan[x].FromVector4(targetSpan[x].ToVector4() * multiplierSpan[x].ToVector4());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: These are hardcoded colours - I'm not keen on supporting highly customizable exports, but there's possibly some more sensible values to use here.
|
||||||
|
private static readonly Vector4 DefaultHairColor = new Vector4(130, 64, 13, 255) / new Vector4(255);
|
||||||
|
private static readonly Vector4 DefaultHighlightColor = new Vector4(77, 126, 240, 255) / new Vector4(255);
|
||||||
|
|
||||||
|
/// <summary> Build a material following the semantics of hair.shpk. </summary>
|
||||||
|
private static MaterialBuilder BuildHair(Material material, string name)
|
||||||
|
{
|
||||||
|
// Trust me bro.
|
||||||
|
const uint categoryHairType = 0x24826489;
|
||||||
|
const uint valueFace = 0x6E5B8F10;
|
||||||
|
|
||||||
|
var isFace = material.Mtrl.ShaderPackage.ShaderKeys
|
||||||
|
.Any(key => key is { Category: categoryHairType, Value: valueFace });
|
||||||
|
|
||||||
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
||||||
|
var mask = material.Textures[TextureUsage.SamplerMask];
|
||||||
|
|
||||||
|
mask.Mutate(context => context.Resize(normal.Width, normal.Height));
|
||||||
|
|
||||||
|
var baseColor = new Image<Rgba32>(normal.Width, normal.Height);
|
||||||
|
normal.ProcessPixelRows(mask, baseColor, (normalAccessor, maskAccessor, baseColorAccessor) =>
|
||||||
|
{
|
||||||
|
for (var y = 0; y < normalAccessor.Height; y++)
|
||||||
|
{
|
||||||
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
||||||
|
var maskSpan = maskAccessor.GetRowSpan(y);
|
||||||
|
var baseColorSpan = baseColorAccessor.GetRowSpan(y);
|
||||||
|
|
||||||
|
for (var x = 0; x < normalSpan.Length; x++)
|
||||||
|
{
|
||||||
|
var color = Vector4.Lerp(DefaultHairColor, DefaultHighlightColor, maskSpan[x].A / 255f);
|
||||||
|
baseColorSpan[x].FromVector4(color * new Vector4(maskSpan[x].R / 255f));
|
||||||
|
baseColorSpan[x].A = normalSpan[x].A;
|
||||||
|
|
||||||
|
normalSpan[x].A = byte.MaxValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return BuildSharedBase(material, name)
|
||||||
|
.WithBaseColor(BuildImage(baseColor, name, "basecolor"))
|
||||||
|
.WithNormal(BuildImage(normal, name, "normal"))
|
||||||
|
.WithAlpha(isFace ? AlphaMode.BLEND : AlphaMode.MASK, 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Vector4 DefaultEyeColor = new Vector4(21, 176, 172, 255) / new Vector4(255);
|
||||||
|
|
||||||
|
/// <summary> Build a material following the semantics of iris.shpk. </summary>
|
||||||
|
// NOTE: This is largely the same as the hair material, but is also missing a few features that would cause it to diverge. Keeping separate for now.
|
||||||
|
private static MaterialBuilder BuildIris(Material material, string name)
|
||||||
|
{
|
||||||
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
||||||
|
var mask = material.Textures[TextureUsage.SamplerMask];
|
||||||
|
|
||||||
|
mask.Mutate(context => context.Resize(normal.Width, normal.Height));
|
||||||
|
|
||||||
|
var baseColor = new Image<Rgba32>(normal.Width, normal.Height);
|
||||||
|
normal.ProcessPixelRows(mask, baseColor, (normalAccessor, maskAccessor, baseColorAccessor) =>
|
||||||
|
{
|
||||||
|
for (var y = 0; y < normalAccessor.Height; y++)
|
||||||
|
{
|
||||||
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
||||||
|
var maskSpan = maskAccessor.GetRowSpan(y);
|
||||||
|
var baseColorSpan = baseColorAccessor.GetRowSpan(y);
|
||||||
|
|
||||||
|
for (var x = 0; x < normalSpan.Length; x++)
|
||||||
|
{
|
||||||
|
baseColorSpan[x].FromVector4(DefaultEyeColor * new Vector4(maskSpan[x].R / 255f));
|
||||||
|
baseColorSpan[x].A = normalSpan[x].A;
|
||||||
|
|
||||||
|
normalSpan[x].A = byte.MaxValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return BuildSharedBase(material, name)
|
||||||
|
.WithBaseColor(BuildImage(baseColor, name, "basecolor"))
|
||||||
|
.WithNormal(BuildImage(normal, name, "normal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Build a material following the semantics of skin.shpk. </summary>
|
||||||
|
private static MaterialBuilder BuildSkin(Material material, string name)
|
||||||
|
{
|
||||||
|
// Trust me bro.
|
||||||
|
const uint categorySkinType = 0x380CAED0;
|
||||||
|
const uint valueFace = 0xF5673524;
|
||||||
|
|
||||||
|
// Face is the default for the skin shader, so a lack of skin type category is also correct.
|
||||||
|
var isFace = !material.Mtrl.ShaderPackage.ShaderKeys
|
||||||
|
.Any(key => key.Category == categorySkinType && key.Value != valueFace);
|
||||||
|
|
||||||
|
// TODO: There's more nuance to skin than this, but this should be enough for a baseline reference.
|
||||||
|
// TODO: Specular?
|
||||||
|
var diffuse = material.Textures[TextureUsage.SamplerDiffuse];
|
||||||
|
var normal = material.Textures[TextureUsage.SamplerNormal];
|
||||||
|
|
||||||
|
// Create a copy of the normal that's the same size as the diffuse for purposes of copying the opacity across.
|
||||||
|
var resizedNormal = normal.Clone(context => context.Resize(diffuse.Width, diffuse.Height));
|
||||||
|
diffuse.ProcessPixelRows(resizedNormal, (diffuseAccessor, normalAccessor) =>
|
||||||
|
{
|
||||||
|
for (var y = 0; y < diffuseAccessor.Height; y++)
|
||||||
|
{
|
||||||
|
var diffuseSpan = diffuseAccessor.GetRowSpan(y);
|
||||||
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
||||||
|
|
||||||
|
for (var x = 0; x < diffuseSpan.Length; x++)
|
||||||
|
diffuseSpan[x].A = normalSpan[x].B;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the blue channel out of the normal now that we're done with it.
|
||||||
|
normal.ProcessPixelRows(normalAccessor =>
|
||||||
|
{
|
||||||
|
for (var y = 0; y < normalAccessor.Height; y++)
|
||||||
|
{
|
||||||
|
var normalSpan = normalAccessor.GetRowSpan(y);
|
||||||
|
|
||||||
|
for (var x = 0; x < normalSpan.Length; x++)
|
||||||
|
normalSpan[x].B = byte.MaxValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return BuildSharedBase(material, name)
|
||||||
|
.WithBaseColor(BuildImage(diffuse, name, "basecolor"))
|
||||||
|
.WithNormal(BuildImage(normal, name, "normal"))
|
||||||
|
.WithAlpha(isFace ? AlphaMode.MASK : AlphaMode.OPAQUE, 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Build a material from a source with unknown semantics. </summary>
|
||||||
|
/// <remarks> Will make a loose effort to fetch common / simple textures. </remarks>
|
||||||
|
private static MaterialBuilder BuildFallback(Material material, string name)
|
||||||
|
{
|
||||||
|
Penumbra.Log.Warning($"Unhandled shader package: {material.Mtrl.ShaderPackage.Name}");
|
||||||
|
|
||||||
|
var materialBuilder = BuildSharedBase(material, name)
|
||||||
|
.WithMetallicRoughnessShader()
|
||||||
|
.WithBaseColor(Vector4.One);
|
||||||
|
|
||||||
|
if (material.Textures.TryGetValue(TextureUsage.SamplerDiffuse, out var diffuse))
|
||||||
|
materialBuilder.WithBaseColor(BuildImage(diffuse, name, "basecolor"));
|
||||||
|
|
||||||
|
if (material.Textures.TryGetValue(TextureUsage.SamplerNormal, out var normal))
|
||||||
|
materialBuilder.WithNormal(BuildImage(normal, name, "normal"));
|
||||||
|
|
||||||
|
return materialBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Build a material pre-configured with settings common to all XIV materials/shaders. </summary>
|
||||||
|
private static MaterialBuilder BuildSharedBase(Material material, string name)
|
||||||
|
{
|
||||||
|
// TODO: Move this and potentially the other known stuff into MtrlFile?
|
||||||
|
const uint backfaceMask = 0x1;
|
||||||
|
var showBackfaces = (material.Mtrl.ShaderPackage.Flags & backfaceMask) == 0;
|
||||||
|
|
||||||
|
return new MaterialBuilder(name)
|
||||||
|
.WithDoubleSide(showBackfaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Convert an ImageSharp Image into an ImageBuilder for use with SharpGLTF. </summary>
|
||||||
|
private static ImageBuilder BuildImage(Image image, string materialName, string suffix)
|
||||||
|
{
|
||||||
|
var name = materialName.Replace("/", "").Replace(".mtrl", "") + $"_{suffix}";
|
||||||
|
|
||||||
|
byte[] textureBytes;
|
||||||
|
using (var memoryStream = new MemoryStream())
|
||||||
|
{
|
||||||
|
image.Save(memoryStream, PngFormat.Instance);
|
||||||
|
textureBytes = memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageBuilder = ImageBuilder.From(textureBytes, name);
|
||||||
|
imageBuilder.AlternateWriteFileName = $"{name}.*";
|
||||||
|
return imageBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,10 +23,10 @@ public class ModelExporter
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. </summary>
|
/// <summary> Export a model in preparation for usage in a glTF file. If provided, skeleton will be used to skin the resulting meshes where appropriate. </summary>
|
||||||
public static Model Export(MdlFile mdl, IEnumerable<XivSkeleton>? xivSkeleton)
|
public static Model Export(MdlFile mdl, IEnumerable<XivSkeleton>? xivSkeleton, Dictionary<string, MaterialExporter.Material> rawMaterials)
|
||||||
{
|
{
|
||||||
var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null;
|
var gltfSkeleton = xivSkeleton != null ? ConvertSkeleton(xivSkeleton) : null;
|
||||||
var materials = ConvertMaterials(mdl);
|
var materials = ConvertMaterials(mdl, rawMaterials);
|
||||||
var meshes = ConvertMeshes(mdl, materials, gltfSkeleton);
|
var meshes = ConvertMeshes(mdl, materials, gltfSkeleton);
|
||||||
return new Model(meshes, gltfSkeleton);
|
return new Model(meshes, gltfSkeleton);
|
||||||
}
|
}
|
||||||
|
|
@ -51,16 +51,12 @@ public class ModelExporter
|
||||||
return meshes;
|
return meshes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Compose textures for use with these materials
|
/// <summary> Build materials for each of the material slots in the .mdl. </summary>
|
||||||
/// <summary> Build placeholder materials for each of the material slots in the .mdl. </summary>
|
private static MaterialBuilder[] ConvertMaterials(MdlFile mdl, Dictionary<string, MaterialExporter.Material> rawMaterials)
|
||||||
private static MaterialBuilder[] ConvertMaterials(MdlFile mdl)
|
|
||||||
=> mdl.Materials
|
=> mdl.Materials
|
||||||
.Select(name =>
|
// TODO: material generation should be fallible, which means this lookup should be a tryget, with a fallback.
|
||||||
new MaterialBuilder(name)
|
// fallback can likely be a static on the material exporter.
|
||||||
.WithMetallicRoughnessShader()
|
.Select(name => MaterialExporter.Export(rawMaterials[name], name))
|
||||||
.WithDoubleSide(true)
|
|
||||||
.WithChannelParam(KnownChannel.BaseColor, KnownProperty.RGBA, Vector4.One)
|
|
||||||
)
|
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
/// <summary> Convert XIV skeleton data into a glTF-compatible node tree, with mappings. </summary>
|
/// <summary> Convert XIV skeleton data into a glTF-compatible node tree, with mappings. </summary>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Dalamud.Plugin.Services;
|
using Dalamud.Plugin.Services;
|
||||||
|
using Lumina.Data.Parsing;
|
||||||
using OtterGui;
|
using OtterGui;
|
||||||
using OtterGui.Tasks;
|
using OtterGui.Tasks;
|
||||||
using Penumbra.Collections.Manager;
|
using Penumbra.Collections.Manager;
|
||||||
|
|
@ -9,12 +10,17 @@ using Penumbra.GameData.Files;
|
||||||
using Penumbra.GameData.Structs;
|
using Penumbra.GameData.Structs;
|
||||||
using Penumbra.Import.Models.Export;
|
using Penumbra.Import.Models.Export;
|
||||||
using Penumbra.Import.Models.Import;
|
using Penumbra.Import.Models.Import;
|
||||||
|
using Penumbra.Import.Textures;
|
||||||
using Penumbra.Meta.Manipulations;
|
using Penumbra.Meta.Manipulations;
|
||||||
using SharpGLTF.Scenes;
|
using SharpGLTF.Scenes;
|
||||||
using SharpGLTF.Schema2;
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
|
||||||
namespace Penumbra.Import.Models;
|
namespace Penumbra.Import.Models;
|
||||||
|
|
||||||
|
using Schema2 = SharpGLTF.Schema2;
|
||||||
|
using LuminaMaterial = Lumina.Models.Materials.Material;
|
||||||
|
|
||||||
public sealed class ModelManager(IFramework framework, ActiveCollections collections, GamePathParser parser) : SingleTaskQueue, IDisposable
|
public sealed class ModelManager(IFramework framework, ActiveCollections collections, GamePathParser parser) : SingleTaskQueue, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IFramework _framework = framework;
|
private readonly IFramework _framework = framework;
|
||||||
|
|
@ -31,19 +37,21 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
_tasks.Clear();
|
_tasks.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ExportToGltf(MdlFile mdl, IEnumerable<SklbFile> sklbs, string outputPath)
|
public Task ExportToGltf(MdlFile mdl, IEnumerable<string> sklbPaths, Func<string, byte[]> read, string outputPath)
|
||||||
=> Enqueue(new ExportToGltfAction(this, mdl, sklbs, outputPath));
|
=> Enqueue(new ExportToGltfAction(this, mdl, sklbPaths, read, outputPath));
|
||||||
|
|
||||||
public Task<MdlFile?> ImportGltf(string inputPath)
|
public Task<MdlFile?> ImportGltf(string inputPath)
|
||||||
{
|
{
|
||||||
var action = new ImportGltfAction(inputPath);
|
var action = new ImportGltfAction(inputPath);
|
||||||
return Enqueue(action).ContinueWith(task =>
|
return Enqueue(action).ContinueWith(task =>
|
||||||
{
|
{
|
||||||
if (task.IsFaulted && task.Exception != null)
|
if (task is { IsFaulted: true, Exception: not null })
|
||||||
throw task.Exception;
|
throw task.Exception;
|
||||||
|
|
||||||
return action.Out;
|
return action.Out;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Try to find the .sklb paths for a .mdl file. </summary>
|
/// <summary> Try to find the .sklb paths for a .mdl file. </summary>
|
||||||
/// <param name="mdlPath"> .mdl file to look up the skeletons for. </param>
|
/// <param name="mdlPath"> .mdl file to look up the skeletons for. </param>
|
||||||
/// <param name="estManipulations"> Modified extra skeleton template parameters. </param>
|
/// <param name="estManipulations"> Modified extra skeleton template parameters. </param>
|
||||||
|
|
@ -61,8 +69,8 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
=> [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Body, info, estManipulations)],
|
=> [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Body, info, estManipulations)],
|
||||||
ObjectType.Equipment when info.EquipSlot.ToSlot() is EquipSlot.Head
|
ObjectType.Equipment when info.EquipSlot.ToSlot() is EquipSlot.Head
|
||||||
=> [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Head, info, estManipulations)],
|
=> [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Head, info, estManipulations)],
|
||||||
ObjectType.Equipment => [baseSkeleton],
|
ObjectType.Equipment => [baseSkeleton],
|
||||||
ObjectType.Accessory => [baseSkeleton],
|
ObjectType.Accessory => [baseSkeleton],
|
||||||
ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => [baseSkeleton],
|
ObjectType.Character when info.BodySlot is BodySlot.Body or BodySlot.Tail => [baseSkeleton],
|
||||||
ObjectType.Character when info.BodySlot is BodySlot.Hair
|
ObjectType.Character when info.BodySlot is BodySlot.Hair
|
||||||
=> [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Hair, info, estManipulations)],
|
=> [baseSkeleton, ..ResolveEstSkeleton(EstManipulation.EstType.Hair, info, estManipulations)],
|
||||||
|
|
@ -83,15 +91,15 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
var modEst = estManipulations
|
var modEst = estManipulations
|
||||||
.FirstOrNull(est =>
|
.FirstOrNull(est =>
|
||||||
est.Gender == gender
|
est.Gender == gender
|
||||||
&& est.Race == race
|
&& est.Race == race
|
||||||
&& est.Slot == type
|
&& est.Slot == type
|
||||||
&& est.SetId == info.PrimaryId
|
&& est.SetId == info.PrimaryId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Try to use an entry from provided manipulations, falling back to the current collection.
|
// Try to use an entry from provided manipulations, falling back to the current collection.
|
||||||
var targetId = modEst?.Entry
|
var targetId = modEst?.Entry
|
||||||
?? collections.Current.MetaCache?.GetEstEntry(type, info.GenderRace, info.PrimaryId)
|
?? collections.Current.MetaCache?.GetEstEntry(type, info.GenderRace, info.PrimaryId)
|
||||||
?? 0;
|
?? 0;
|
||||||
|
|
||||||
// If there's no entries, we can assume that there's no additional skeleton.
|
// If there's no entries, we can assume that there's no additional skeleton.
|
||||||
if (targetId == 0)
|
if (targetId == 0)
|
||||||
|
|
@ -100,6 +108,40 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
return [GamePaths.Skeleton.Sklb.Path(info.GenderRace, EstManipulation.ToName(type), targetId)];
|
return [GamePaths.Skeleton.Sklb.Path(info.GenderRace, EstManipulation.ToName(type), targetId)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary> Try to resolve the absolute path to a .mtrl from the potentially-partial path provided by a model. </summary>
|
||||||
|
private string ResolveMtrlPath(string rawPath)
|
||||||
|
{
|
||||||
|
// TODO: this should probably be chosen in the export settings
|
||||||
|
var variantId = 1;
|
||||||
|
|
||||||
|
// Get standardised paths
|
||||||
|
var absolutePath = rawPath.StartsWith('/')
|
||||||
|
? LuminaMaterial.ResolveRelativeMaterialPath(rawPath, variantId)
|
||||||
|
: rawPath;
|
||||||
|
var relativePath = rawPath.StartsWith('/')
|
||||||
|
? rawPath
|
||||||
|
: '/' + Path.GetFileName(rawPath);
|
||||||
|
|
||||||
|
// TODO: this should be a recoverable warning
|
||||||
|
if (absolutePath == null)
|
||||||
|
throw new Exception("Failed to resolve material path.");
|
||||||
|
|
||||||
|
var info = parser.GetFileInfo(absolutePath);
|
||||||
|
if (info.FileType is not FileType.Material)
|
||||||
|
throw new Exception($"Material path {rawPath} does not conform to material conventions.");
|
||||||
|
|
||||||
|
var resolvedPath = info.ObjectType switch
|
||||||
|
{
|
||||||
|
ObjectType.Character => GamePaths.Character.Mtrl.Path(
|
||||||
|
info.GenderRace, info.BodySlot, info.PrimaryId, relativePath, out _, out _, info.Variant),
|
||||||
|
_ => absolutePath,
|
||||||
|
};
|
||||||
|
|
||||||
|
Penumbra.Log.Debug($"Resolved material {rawPath} to {resolvedPath}");
|
||||||
|
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
private Task Enqueue(IAction action)
|
private Task Enqueue(IAction action)
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|
@ -126,17 +168,29 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
private class ExportToGltfAction(ModelManager manager, MdlFile mdl, IEnumerable<SklbFile> sklbs, string outputPath)
|
private class ExportToGltfAction(
|
||||||
|
ModelManager manager,
|
||||||
|
MdlFile mdl,
|
||||||
|
IEnumerable<string> sklbPaths,
|
||||||
|
Func<string, byte[]> read,
|
||||||
|
string outputPath)
|
||||||
: IAction
|
: IAction
|
||||||
{
|
{
|
||||||
public void Execute(CancellationToken cancel)
|
public void Execute(CancellationToken cancel)
|
||||||
{
|
{
|
||||||
Penumbra.Log.Debug($"[GLTF Export] Exporting model to {outputPath}...");
|
Penumbra.Log.Debug($"[GLTF Export] Exporting model to {outputPath}...");
|
||||||
|
|
||||||
Penumbra.Log.Debug("[GLTF Export] Reading skeletons...");
|
Penumbra.Log.Debug("[GLTF Export] Reading skeletons...");
|
||||||
var xivSkeletons = BuildSkeletons(cancel);
|
var xivSkeletons = BuildSkeletons(cancel);
|
||||||
|
|
||||||
|
Penumbra.Log.Debug("[GLTF Export] Reading materials...");
|
||||||
|
var materials = mdl.Materials.ToDictionary(
|
||||||
|
path => path,
|
||||||
|
path => BuildMaterial(path, cancel)
|
||||||
|
);
|
||||||
|
|
||||||
Penumbra.Log.Debug("[GLTF Export] Converting model...");
|
Penumbra.Log.Debug("[GLTF Export] Converting model...");
|
||||||
var model = ModelExporter.Export(mdl, xivSkeletons);
|
var model = ModelExporter.Export(mdl, xivSkeletons, materials);
|
||||||
|
|
||||||
Penumbra.Log.Debug("[GLTF Export] Building scene...");
|
Penumbra.Log.Debug("[GLTF Export] Building scene...");
|
||||||
var scene = new SceneBuilder();
|
var scene = new SceneBuilder();
|
||||||
|
|
@ -148,10 +202,11 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
Penumbra.Log.Debug("[GLTF Export] Done.");
|
Penumbra.Log.Debug("[GLTF Export] Done.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Attempt to read out the pertinent information from a .sklb. </summary>
|
/// <summary> Attempt to read out the pertinent information from the sklb file paths provided. </summary>
|
||||||
private IEnumerable<XivSkeleton> BuildSkeletons(CancellationToken cancel)
|
private IEnumerable<XivSkeleton> BuildSkeletons(CancellationToken cancel)
|
||||||
{
|
{
|
||||||
var havokTasks = sklbs
|
var havokTasks = sklbPaths
|
||||||
|
.Select(path => new SklbFile(read(path)))
|
||||||
.WithIndex()
|
.WithIndex()
|
||||||
.Select(CreateHavokTask)
|
.Select(CreateHavokTask)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
@ -163,12 +218,46 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
// finicky at the best of times, and can outright cause a CTD if they
|
// finicky at the best of times, and can outright cause a CTD if they
|
||||||
// get upset. Running each conversion on its own tick seems to make
|
// get upset. Running each conversion on its own tick seems to make
|
||||||
// this consistently non-crashy across my testing.
|
// this consistently non-crashy across my testing.
|
||||||
Task<string> CreateHavokTask((SklbFile Sklb, int Index) pair) =>
|
Task<string> CreateHavokTask((SklbFile Sklb, int Index) pair)
|
||||||
manager._framework.RunOnTick(
|
=> manager._framework.RunOnTick(
|
||||||
() => HavokConverter.HkxToXml(pair.Sklb.Skeleton),
|
() => HavokConverter.HkxToXml(pair.Sklb.Skeleton),
|
||||||
delayTicks: pair.Index, cancellationToken: cancel);
|
delayTicks: pair.Index, cancellationToken: cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary> Read a .mtrl and populate its textures. </summary>
|
||||||
|
private MaterialExporter.Material BuildMaterial(string relativePath, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var path = manager.ResolveMtrlPath(relativePath);
|
||||||
|
var mtrl = new MtrlFile(read(path));
|
||||||
|
|
||||||
|
return new MaterialExporter.Material
|
||||||
|
{
|
||||||
|
Mtrl = mtrl,
|
||||||
|
Textures = mtrl.ShaderPackage.Samplers.ToDictionary(
|
||||||
|
sampler => (TextureUsage)sampler.SamplerId,
|
||||||
|
sampler => ConvertImage(mtrl.Textures[sampler.TextureIndex], cancel)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary> Read a texture referenced by a .mtrl and convert it into an ImageSharp image. </summary>
|
||||||
|
private Image<Rgba32> ConvertImage(MtrlFile.Texture texture, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
// Work out the texture's path - the DX11 material flag controls a file name prefix.
|
||||||
|
var texturePath = texture.Path;
|
||||||
|
if (texture.DX11)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(texturePath);
|
||||||
|
if (!fileName.StartsWith("--"))
|
||||||
|
texturePath = $"{Path.GetDirectoryName(texturePath)}/--{fileName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
using var textureData = new MemoryStream(read(texturePath));
|
||||||
|
var image = TexFileParser.Parse(textureData);
|
||||||
|
var pngImage = TextureManager.ConvertToPng(image, cancel).AsPng;
|
||||||
|
return pngImage ?? throw new Exception("Failed to convert texture to png.");
|
||||||
|
}
|
||||||
|
|
||||||
public bool Equals(IAction? other)
|
public bool Equals(IAction? other)
|
||||||
{
|
{
|
||||||
if (other is not ExportToGltfAction rhs)
|
if (other is not ExportToGltfAction rhs)
|
||||||
|
|
@ -185,7 +274,7 @@ public sealed class ModelManager(IFramework framework, ActiveCollections collect
|
||||||
|
|
||||||
public void Execute(CancellationToken cancel)
|
public void Execute(CancellationToken cancel)
|
||||||
{
|
{
|
||||||
var model = ModelRoot.Load(inputPath);
|
var model = Schema2.ModelRoot.Load(inputPath);
|
||||||
|
|
||||||
Out = ModelImporter.Import(model);
|
Out = ModelImporter.Import(model);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -116,11 +116,10 @@ public partial class ModEditWindow
|
||||||
/// <param name="mdlPath"> .mdl game path to resolve satellite files such as skeletons relative to. </param>
|
/// <param name="mdlPath"> .mdl game path to resolve satellite files such as skeletons relative to. </param>
|
||||||
public void Export(string outputPath, Utf8GamePath mdlPath)
|
public void Export(string outputPath, Utf8GamePath mdlPath)
|
||||||
{
|
{
|
||||||
IEnumerable<SklbFile> skeletons;
|
IEnumerable<string> sklbPaths;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var sklbPaths = _edit._models.ResolveSklbsForMdl(mdlPath.ToString(), GetCurrentEstManipulations());
|
sklbPaths = _edit._models.ResolveSklbsForMdl(mdlPath.ToString(), GetCurrentEstManipulations());
|
||||||
skeletons = sklbPaths.Select(ReadSklb).ToArray();
|
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|
@ -129,7 +128,7 @@ public partial class ModEditWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
PendingIo = true;
|
PendingIo = true;
|
||||||
_edit._models.ExportToGltf(Mdl, skeletons, outputPath)
|
_edit._models.ExportToGltf(Mdl, sklbPaths, ReadFile, outputPath)
|
||||||
.ContinueWith(task =>
|
.ContinueWith(task =>
|
||||||
{
|
{
|
||||||
RecordIoExceptions(task.Exception);
|
RecordIoExceptions(task.Exception);
|
||||||
|
|
@ -182,9 +181,9 @@ public partial class ModEditWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Merge attribute configuration from the source onto the target. </summary>
|
/// <summary> Merge attribute configuration from the source onto the target. </summary>
|
||||||
/// <param name="target" Model that will be update. ></param>
|
/// <param name="target"> Model that will be updated. ></param>
|
||||||
/// <param name="source"> Model to copy attribute configuration from. </param>
|
/// <param name="source"> Model to copy attribute configuration from. </param>
|
||||||
public void MergeAttributes(MdlFile target, MdlFile source)
|
public static void MergeAttributes(MdlFile target, MdlFile source)
|
||||||
{
|
{
|
||||||
target.Attributes = source.Attributes;
|
target.Attributes = source.Attributes;
|
||||||
|
|
||||||
|
|
@ -198,7 +197,7 @@ public partial class ModEditWindow
|
||||||
target.SubMeshes[subMeshIndex].AttributeIndexMask = 0u;
|
target.SubMeshes[subMeshIndex].AttributeIndexMask = 0u;
|
||||||
|
|
||||||
// Rather than comparing sub-meshes directly, we're grouping by parent mesh in an attempt
|
// Rather than comparing sub-meshes directly, we're grouping by parent mesh in an attempt
|
||||||
// to maintain semantic connection betwen mesh index and submesh attributes.
|
// to maintain semantic connection between mesh index and sub mesh attributes.
|
||||||
if (meshIndex >= source.Meshes.Length)
|
if (meshIndex >= source.Meshes.Length)
|
||||||
continue;
|
continue;
|
||||||
var sourceMesh = source.Meshes[meshIndex];
|
var sourceMesh = source.Meshes[meshIndex];
|
||||||
|
|
@ -215,26 +214,29 @@ public partial class ModEditWindow
|
||||||
{
|
{
|
||||||
IoExceptions = exception switch {
|
IoExceptions = exception switch {
|
||||||
null => [],
|
null => [],
|
||||||
AggregateException ae => ae.Flatten().InnerExceptions.ToList(),
|
AggregateException ae => [.. ae.Flatten().InnerExceptions],
|
||||||
Exception other => [other],
|
_ => [exception],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Read a .sklb from the active collection or game. </summary>
|
/// <summary> Read a file from the active collection or game. </summary>
|
||||||
/// <param name="sklbPath"> Game path to the .sklb to load. </param>
|
/// <param name="path"> Game path to the file to load. </param>
|
||||||
private SklbFile ReadSklb(string sklbPath)
|
// TODO: Also look up files within the current mod regardless of mod state?
|
||||||
|
private byte[] ReadFile(string path)
|
||||||
{
|
{
|
||||||
// TODO: if cross-collection lookups are turned off, this conversion can be skipped
|
// TODO: if cross-collection lookups are turned off, this conversion can be skipped
|
||||||
if (!Utf8GamePath.FromString(sklbPath, out var utf8SklbPath, true))
|
if (!Utf8GamePath.FromString(path, out var utf8Path, true))
|
||||||
throw new Exception($"Resolved skeleton path {sklbPath} could not be converted to a game path.");
|
throw new Exception($"Resolved path {path} could not be converted to a game path.");
|
||||||
|
|
||||||
var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8SklbPath);
|
var resolvedPath = _edit._activeCollections.Current.ResolvePath(utf8Path);
|
||||||
// TODO: is it worth trying to use streams for these instead? I'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so...
|
// TODO: is it worth trying to use streams for these instead? I'll need to do this for mtrl/tex too, so might be a good idea. that said, the mtrl reader doesn't accept streams, so...
|
||||||
var bytes = resolvedPath == null ? _edit._gameData.GetFile(sklbPath)?.Data : File.ReadAllBytes(resolvedPath.Value.ToPath());
|
var bytes = resolvedPath == null
|
||||||
return bytes != null
|
? _edit._gameData.GetFile(path)?.Data
|
||||||
? new SklbFile(bytes)
|
: File.ReadAllBytes(resolvedPath.Value.ToPath());
|
||||||
: throw new Exception(
|
|
||||||
$"Resolved skeleton path {sklbPath} could not be found. If modded, is it enabled in the current collection?");
|
// TODO: some callers may not care about failures - handle exceptions separately?
|
||||||
|
return bytes ?? throw new Exception(
|
||||||
|
$"Resolved path {path} could not be found. If modded, is it enabled in the current collection?");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Remove the material given by the index. </summary>
|
/// <summary> Remove the material given by the index. </summary>
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ public partial class ModEditWindow
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawIoExceptions(MdlTab tab)
|
private static void DrawIoExceptions(MdlTab tab)
|
||||||
{
|
{
|
||||||
if (tab.IoExceptions.Count == 0)
|
if (tab.IoExceptions.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
@ -138,18 +138,17 @@ public partial class ModEditWindow
|
||||||
using var frame = ImRaii.FramedGroup("Exceptions", size, headerPreIcon: FontAwesomeIcon.TimesCircle, borderColor: Colors.RegexWarningBorder);
|
using var frame = ImRaii.FramedGroup("Exceptions", size, headerPreIcon: FontAwesomeIcon.TimesCircle, borderColor: Colors.RegexWarningBorder);
|
||||||
|
|
||||||
var spaceAvail = ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X - 100;
|
var spaceAvail = ImGui.GetContentRegionAvail().X - ImGui.GetStyle().ItemSpacing.X - 100;
|
||||||
foreach (var exception in tab.IoExceptions)
|
foreach (var (exception, index) in tab.IoExceptions.WithIndex())
|
||||||
{
|
{
|
||||||
var message = $"{exception.GetType().Name}: {exception.Message}";
|
using var id = ImRaii.PushId(index);
|
||||||
var textSize = ImGui.CalcTextSize(message).X;
|
var message = $"{exception.GetType().Name}: {exception.Message}";
|
||||||
|
var textSize = ImGui.CalcTextSize(message).X;
|
||||||
if (textSize > spaceAvail)
|
if (textSize > spaceAvail)
|
||||||
message = message.Substring(0, (int)Math.Floor(message.Length * (spaceAvail / textSize))) + "...";
|
message = message[..(int)Math.Floor(message.Length * (spaceAvail / textSize))] + "...";
|
||||||
|
|
||||||
using (var exceptionNode = ImRaii.TreeNode(message))
|
using var exceptionNode = ImRaii.TreeNode(message);
|
||||||
{
|
if (exceptionNode)
|
||||||
if (exceptionNode)
|
ImGuiUtil.TextWrapped(exception.ToString());
|
||||||
ImGuiUtil.TextWrapped(exception.ToString());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue