diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs new file mode 100644 index 00000000..4986963f --- /dev/null +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.MdlTab.cs @@ -0,0 +1,103 @@ +using OtterGui; +using Penumbra.GameData; +using Penumbra.GameData.Files; + +namespace Penumbra.UI.AdvancedWindow; + +public partial class ModEditWindow +{ + private class MdlTab : IWritable + { + public readonly MdlFile Mdl; + + private readonly List[] _attributes; + + public MdlTab(byte[] bytes) + { + Mdl = new MdlFile(bytes); + _attributes = CreateAttributes(Mdl); + } + + /// + public bool Valid + => Mdl.Valid; + + /// + public byte[] Write() + => Mdl.Write(); + + /// Remove the material given by the index. + /// Meshes using the removed material are redirected to material 0, and those after the index are corrected. + public void RemoveMaterial(int materialIndex) + { + for (var meshIndex = 0; meshIndex < Mdl.Meshes.Length; meshIndex++) + { + var newIndex = Mdl.Meshes[meshIndex].MaterialIndex; + if (newIndex == materialIndex) + newIndex = 0; + else if (newIndex > materialIndex) + --newIndex; + + Mdl.Meshes[meshIndex].MaterialIndex = newIndex; + } + + Mdl.Materials = Mdl.Materials.RemoveItems(materialIndex); + } + + /// Create a list of attributes per sub mesh. + private static List[] CreateAttributes(MdlFile mdl) + => mdl.SubMeshes.Select(s => Enumerable.Range(0, 32) + .Where(idx => ((s.AttributeIndexMask >> idx) & 1) == 1) + .Select(idx => mdl.Attributes[idx]) + .ToList() + ).ToArray(); + + /// Obtain the attributes associated with a sub mesh by its index. + public IReadOnlyList GetSubMeshAttributes(int subMeshIndex) + => _attributes[subMeshIndex]; + + /// Remove or add attributes from a sub mesh by its index. + /// The index of the sub mesh to update. + /// If non-null, remove this attribute. + /// If non-null, add this attribute. + public void UpdateSubMeshAttribute(int subMeshIndex, string? old, string? @new) + { + var attributes = _attributes[subMeshIndex]; + + if (old != null) + attributes.Remove(old); + + if (@new != null) + attributes.Add(@new); + + PersistAttributes(); + } + + /// Apply changes to attributes to the file in memory. + private void PersistAttributes() + { + var allAttributes = new List(); + + foreach (var (attributes, subMeshIndex) in _attributes.WithIndex()) + { + var mask = 0u; + + foreach (var attribute in attributes) + { + var attributeIndex = allAttributes.IndexOf(attribute); + if (attributeIndex == -1) + { + allAttributes.Add(attribute); + attributeIndex = allAttributes.Count - 1; + } + + mask |= 1u << attributeIndex; + } + + Mdl.SubMeshes[subMeshIndex].AttributeIndexMask = mask; + } + + Mdl.Attributes = [.. allAttributes]; + } + } +} diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs index b95ba393..25bb012a 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.Models.cs @@ -1,6 +1,9 @@ +using Dalamud.Interface; using ImGuiNET; using OtterGui; using OtterGui.Raii; +using OtterGui.Widgets; +using Penumbra.GameData; using Penumbra.GameData.Files; using Penumbra.String.Classes; @@ -8,30 +11,216 @@ namespace Penumbra.UI.AdvancedWindow; public partial class ModEditWindow { - private readonly FileEditor _modelTab; + private const int MdlMaterialMaximum = 4; - private static bool DrawModelPanel(MdlFile file, bool disabled) + private readonly FileEditor _modelTab; + + private string _modelNewMaterial = string.Empty; + private readonly List _subMeshAttributeTagWidgets = []; + + private bool DrawModelPanel(MdlTab tab, bool disabled) { - var ret = false; - for (var i = 0; i < file.Materials.Length; ++i) + var file = tab.Mdl; + + var subMeshTotal = file.Meshes.Aggregate(0, (count, mesh) => count + mesh.SubMeshCount); + if (_subMeshAttributeTagWidgets.Count != subMeshTotal) { - using var id = ImRaii.PushId(i); - var tmp = file.Materials[i]; - if (ImGui.InputText(string.Empty, ref tmp, Utf8GamePath.MaxGamePathLength, - disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None) - && tmp.Length > 0 - && tmp != file.Materials[i]) - { - file.Materials[i] = tmp; - ret = true; - } + _subMeshAttributeTagWidgets.Clear(); + _subMeshAttributeTagWidgets.AddRange( + Enumerable.Range(0, subMeshTotal).Select(_ => new TagButtons()) + ); } + var ret = false; + + ret |= DrawModelMaterialDetails(tab, disabled); + + if (ImGui.CollapsingHeader($"Meshes ({file.Meshes.Length})###meshes")) + for (var i = 0; i < file.LodCount; ++i) + ret |= DrawModelLodDetails(tab, i, disabled); + ret |= DrawOtherModelDetails(file, disabled); return !disabled && ret; } + private bool DrawModelMaterialDetails(MdlTab tab, bool disabled) + { + if (!ImGui.CollapsingHeader("Materials")) + return false; + + using var table = ImRaii.Table(string.Empty, disabled ? 2 : 3, ImGuiTableFlags.SizingFixedFit); + if (!table) + return false; + + var ret = false; + var materials = tab.Mdl.Materials; + + ImGui.TableSetupColumn("index", ImGuiTableColumnFlags.WidthFixed, 80 * UiHelpers.Scale); + ImGui.TableSetupColumn("path", ImGuiTableColumnFlags.WidthStretch, 1); + if (!disabled) + ImGui.TableSetupColumn("actions", ImGuiTableColumnFlags.WidthFixed, UiHelpers.IconButtonSize.X); + + var inputFlags = disabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None; + for (var materialIndex = 0; materialIndex < materials.Length; materialIndex++) + ret |= DrawMaterialRow(tab, disabled, materials, materialIndex, inputFlags); + + if (materials.Length >= MdlMaterialMaximum || disabled) + return ret; + + ImGui.TableNextColumn(); + + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##newMaterial", "Add new material...", ref _modelNewMaterial, Utf8GamePath.MaxGamePathLength, inputFlags); + var validName = _modelNewMaterial.Length > 0 && _modelNewMaterial[0] == '/'; + ImGui.TableNextColumn(); + if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Plus.ToIconString(), UiHelpers.IconButtonSize, string.Empty, !validName, true)) + return ret; + + tab.Mdl.Materials = materials.AddItem(_modelNewMaterial); + _modelNewMaterial = string.Empty; + return true; + } + + private bool DrawMaterialRow(MdlTab tab, bool disabled, string[] materials, int materialIndex, ImGuiInputTextFlags inputFlags) + { + using var id = ImRaii.PushId(materialIndex); + var ret = false; + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"Material #{materialIndex + 1}"); + + var temp = materials[materialIndex]; + ImGui.TableNextColumn(); + ImGui.SetNextItemWidth(-1); + if (ImGui.InputText($"##material{materialIndex}", ref temp, Utf8GamePath.MaxGamePathLength, inputFlags) + && temp.Length > 0 + && temp != materials[materialIndex] + ) + { + materials[materialIndex] = temp; + ret = true; + } + + if (disabled) + return ret; + + ImGui.TableNextColumn(); + + // Need to have at least one material. + if (materials.Length <= 1) + return ret; + + var tt = "Delete this material.\nAny meshes targeting this material will be updated to use material #1."; + var modifierActive = _config.DeleteModModifier.IsActive(); + if (!modifierActive) + tt += $"\nHold {_config.DeleteModModifier} to delete."; + if (!ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Trash.ToIconString(), UiHelpers.IconButtonSize, tt, !modifierActive, true)) + return ret; + + tab.RemoveMaterial(materialIndex); + return true; + } + + private bool DrawModelLodDetails(MdlTab tab, int lodIndex, bool disabled) + { + using var lodNode = ImRaii.TreeNode($"Level of Detail #{lodIndex + 1}", ImGuiTreeNodeFlags.DefaultOpen); + if (!lodNode) + return false; + + var lod = tab.Mdl.Lods[lodIndex]; + var ret = false; + + for (var meshOffset = 0; meshOffset < lod.MeshCount; meshOffset++) + ret |= DrawModelMeshDetails(tab, lod.MeshIndex + meshOffset, disabled); + + return ret; + } + + private bool DrawModelMeshDetails(MdlTab tab, int meshIndex, bool disabled) + { + using var meshNode = ImRaii.TreeNode($"Mesh #{meshIndex + 1}", ImGuiTreeNodeFlags.DefaultOpen); + if (!meshNode) + return false; + + using var id = ImRaii.PushId(meshIndex); + using var table = ImRaii.Table(string.Empty, 2, ImGuiTableFlags.SizingFixedFit); + if (!table) + return false; + + ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthFixed, 100 * UiHelpers.Scale); + ImGui.TableSetupColumn("field", ImGuiTableColumnFlags.WidthStretch, 1); + + var file = tab.Mdl; + var mesh = file.Meshes[meshIndex]; + + + // Mesh material + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted("Material"); + + ImGui.TableNextColumn(); + var ret = DrawMaterialCombo(tab, meshIndex, disabled); + + // Sub meshes + for (var subMeshOffset = 0; subMeshOffset < mesh.SubMeshCount; subMeshOffset++) + ret |= DrawSubMeshAttributes(tab, meshIndex, disabled, subMeshOffset); + + return ret; + } + + private bool DrawMaterialCombo(MdlTab tab, int meshIndex, bool disabled) + { + var mesh = tab.Mdl.Meshes[meshIndex]; + using var _ = ImRaii.Disabled(disabled); + ImGui.SetNextItemWidth(-1); + using var materialCombo = ImRaii.Combo("##material", tab.Mdl.Materials[mesh.MaterialIndex]); + + if (!materialCombo) + return false; + + var ret = false; + foreach (var (material, materialIndex) in tab.Mdl.Materials.WithIndex()) + { + if (!ImGui.Selectable(material, mesh.MaterialIndex == materialIndex)) + continue; + + tab.Mdl.Meshes[meshIndex].MaterialIndex = (ushort)materialIndex; + ret = true; + } + + return ret; + } + + private bool DrawSubMeshAttributes(MdlTab tab, int meshIndex, bool disabled, int subMeshOffset) + { + using var _ = ImRaii.PushId(subMeshOffset); + + var mesh = tab.Mdl.Meshes[meshIndex]; + var subMeshIndex = mesh.SubMeshIndex + subMeshOffset; + + ImGui.TableNextColumn(); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"Attributes #{subMeshOffset + 1}"); + + ImGui.TableNextColumn(); + var widget = _subMeshAttributeTagWidgets[subMeshIndex]; + var attributes = tab.GetSubMeshAttributes(subMeshIndex); + + var tagIndex = widget.Draw(string.Empty, string.Empty, attributes, + out var editedAttribute, !disabled); + if (tagIndex < 0) + return false; + + var oldName = tagIndex < attributes.Count ? attributes[tagIndex] : null; + var newName = editedAttribute.Length > 0 ? editedAttribute : null; + tab.UpdateSubMeshAttribute(subMeshIndex, oldName, newName); + + return true; + } + private static bool DrawOtherModelDetails(MdlFile file, bool _) { if (!ImGui.CollapsingHeader("Further Content")) @@ -82,6 +271,13 @@ public partial class ModEditWindow } } + using (var materials = ImRaii.TreeNode("Materials", ImGuiTreeNodeFlags.DefaultOpen)) + { + if (materials) + foreach (var material in file.Materials) + ImRaii.TreeNode(material, ImGuiTreeNodeFlags.Leaf).Dispose(); + } + using (var attributes = ImRaii.TreeNode("Attributes", ImGuiTreeNodeFlags.DefaultOpen)) { if (attributes) diff --git a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs index db9201ca..365c4a4a 100644 --- a/Penumbra/UI/AdvancedWindow/ModEditWindow.cs +++ b/Penumbra/UI/AdvancedWindow/ModEditWindow.cs @@ -584,8 +584,8 @@ public partial class ModEditWindow : Window, IDisposable _materialTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Materials", ".mtrl", () => PopulateIsOnPlayer(_editor.Files.Mtrl, ResourceType.Mtrl), DrawMaterialPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, path, writable) => new MtrlTab(this, new MtrlFile(bytes), path, writable)); - _modelTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", - () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new MdlFile(bytes)); + _modelTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Models", ".mdl", + () => PopulateIsOnPlayer(_editor.Files.Mdl, ResourceType.Mdl), DrawModelPanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new MdlTab(bytes)); _shaderPackageTab = new FileEditor(this, gameData, config, _editor.Compactor, _fileDialog, "Shaders", ".shpk", () => PopulateIsOnPlayer(_editor.Files.Shpk, ResourceType.Shpk), DrawShaderPackagePanel, () => _mod?.ModPath.FullName ?? string.Empty, (bytes, _, _) => new ShpkTab(_fileDialog, bytes));