diff --git a/MshLib/Msh0x01.cs b/MshLib/Msh0x01.cs index 85183fc..7cc58d0 100644 --- a/MshLib/Msh0x01.cs +++ b/MshLib/Msh0x01.cs @@ -95,7 +95,8 @@ public static class Msh0x01 /// [0x06..0x08] Fallback key / index в MSH 0x08. /// /// [0x08..0x26] Индексы geometry slot в MSH 0x02. - /// Формула: slot = lod * 5 + group. 0xFFFF значит отсутствует. + /// Формула индекса в этой таблице: index = state * MaxLodCount + lod. + /// 0xFFFF значит, что для этой пары state/LOD геометрии нет. /// public sealed record Node( NodeFlags Flags, @@ -106,7 +107,7 @@ public static class Msh0x01 { public ushort ResolveSlotIndex(int state, int lod = 0) { - + // State и LOD выбираются для конкретного узла/piece, не для всего файла сразу. // MODEL_STATE_DEFAULT -1 // MODEL_STATE_REGULAR 0 // MODEL_STATE_COLLAPSED 1 @@ -181,4 +182,4 @@ public enum NodeFlags : ushort /// Exact semantic name still provisional. /// MSH01_CHECK_PARENT_DESCRIPTION_CENTRAL = 0x0800, -} \ No newline at end of file +} diff --git a/NResUI/App.cs b/NResUI/App.cs index 1d68397..393b2df 100644 --- a/NResUI/App.cs +++ b/NResUI/App.cs @@ -71,6 +71,7 @@ public class App serviceCollection.AddSingleton(new CpDatSchemeViewModel()); serviceCollection.AddSingleton(new MaterialViewModel()); serviceCollection.AddSingleton(new ResearchTreeViewModel()); + serviceCollection.AddSingleton(new MeshViewportViewModel()); var serviceProvider = serviceCollection.BuildServiceProvider(); diff --git a/NResUI/ImGuiUI/MeshInspectorPanel.cs b/NResUI/ImGuiUI/MeshInspectorPanel.cs new file mode 100644 index 0000000..f61d317 --- /dev/null +++ b/NResUI/ImGuiUI/MeshInspectorPanel.cs @@ -0,0 +1,490 @@ +using System.Numerics; +using ImGuiNET; +using NResUI.Abstractions; +using NResUI.Models; +using NResUI.Rendering.Inspection; + +namespace NResUI.ImGuiUI; + +/// +/// Инспектор CPU-документа меша. Он не создает OpenGL-ресурсы сам, а только меняет MeshRenderState, +/// после чего ViewportPanel перестраивает видимую геометрию. +/// +public sealed class MeshInspectorPanel : IImGuiPanel +{ + private readonly MeshViewportViewModel _viewModel; + private readonly HashSet _expandedPieceDetails = []; + private static readonly string[] ModelStateLabels = ["Обычное", "Collapsed", "Неизвестное 2"]; + private static readonly string[] LodLabels = ["LOD 0", "LOD -1", "LOD -2", "LOD -3", "LOD -4"]; + + public MeshInspectorPanel(MeshViewportViewModel viewModel) + { + _viewModel = viewModel; + } + + public void OnImGuiRender() + { + if (!ImGui.Begin("Инспектор меша")) + { + ImGui.End(); + return; + } + + var document = _viewModel.Document; + if (document == null) + { + ImGui.TextDisabled("Меш не загружен."); + if (_viewModel.LoadError != null) + ImGui.TextColored(new Vector4(1.0f, 0.35f, 0.25f, 1.0f), _viewModel.LoadError); + ImGui.End(); + return; + } + + DrawHeader(document); + DrawRenderStateControls(document); + DrawPieceTree(document); + DrawWarnings(document); + + ImGui.End(); + } + + private void DrawHeader(MeshDocument document) + { + ImGui.Text($"Файл: {Path.GetFileName(document.SourcePath)}"); + ImGui.TextDisabled($"тип: {GetMeshTypeLabel(document.MeshType.ToString())} | частей: {document.Pieces.Count} | материалов: {document.Materials.Count}"); + ImGui.Separator(); + } + + private void DrawRenderStateControls(MeshDocument document) + { + var state = _viewModel.RenderState; + + var useStoredNormals = state.UseStoredNormals; + if (ImGui.Checkbox("Нормали из MSH", ref useStoredNormals)) + { + state.UseStoredNormals = useStoredNormals; + _viewModel.MarkSceneRebuildNeeded(); + } + DrawTooltip("Если включено, нормали вершин берутся из компонента MSH 0x04. Если выключено, нормали считаются из треугольников по позициям вершин, поэтому модель выглядит более граненой и показывает сырую геометрию."); + + ImGui.SameLine(); + + var showEmptyPieces = state.ShowEmptyPieces; + if (ImGui.Checkbox("Показывать пустые части", ref showEmptyPieces)) + { + state.ShowEmptyPieces = showEmptyPieces; + _viewModel.MarkSceneRebuildNeeded(); + } + DrawTooltip("Показывает части без активного слота геометрии, без батчей или без валидных треугольников маленькими маркерами осей, чтобы их можно было выбрать."); + + var isolatePiece = state.IsolateSelectedPiece; + if (ImGui.Checkbox("Изолировать выбранную часть", ref isolatePiece)) + { + state.IsolateSelectedPiece = isolatePiece; + _viewModel.MarkSceneRebuildNeeded(); + } + DrawTooltip("Рендерит только выбранную часть. Если выбрать другую часть, изоляция переключится на нее."); + + ImGui.SameLine(); + + var isolateBatch = state.IsolateSelectedBatch; + if (ImGui.Checkbox("Изолировать выбранный батч", ref isolateBatch)) + { + state.IsolateSelectedBatch = isolateBatch; + _viewModel.MarkSceneRebuildNeeded(); + } + DrawTooltip("Рендерит только выбранный батч из компонента 0x0D. Батч выбирается в таблице свойств части."); + + if (ImGui.Button("Показать все")) + { + state.HiddenPieceIds.Clear(); + state.IsolateSelectedPiece = false; + state.IsolateSelectedBatch = false; + state.ClearAllPieceOverrides(); + _viewModel.MarkSceneRebuildNeeded(); + } + + ImGui.Separator(); + } + + private void DrawPieceTree(MeshDocument document) + { + if (!ImGui.CollapsingHeader("Дерево частей", ImGuiTreeNodeFlags.DefaultOpen)) + return; + + var childrenByParent = document.Pieces + .GroupBy(x => x.ParentId) + .ToDictionary(x => x.Key, x => x.OrderBy(p => p.Id).ToList()); + + // Parent -1 приходит из 0xFFFF в 0x01 и означает корневой piece. + if (childrenByParent.TryGetValue(-1, out var roots)) + { + foreach (var root in roots) + DrawPieceNode(document, root, childrenByParent); + } + + // Если parent index битый или указывает за пределы таблицы, показываем piece как orphan, а не теряем его. + foreach (var orphan in document.Pieces.Where(x => x.ParentId != -1 && !document.Pieces.Any(p => p.Id == x.ParentId))) + DrawPieceNode(document, orphan, childrenByParent); + } + + private void DrawPieceNode( + MeshDocument document, + MeshPieceInfo piece, + IReadOnlyDictionary> childrenByParent) + { + var state = _viewModel.RenderState; + var childCount = childrenByParent.TryGetValue(piece.Id, out var children) ? children.Count : 0; + var hasChildren = childCount > 0; + var flags = ImGuiTreeNodeFlags.OpenOnArrow | ImGuiTreeNodeFlags.SpanAvailWidth; + if (!hasChildren) + flags |= ImGuiTreeNodeFlags.Leaf; + if (state.SelectedPieceId == piece.Id) + flags |= ImGuiTreeNodeFlags.Selected; + + var hidden = state.HiddenPieceIds.Contains(piece.Id); + var childMarker = childCount > 0 ? $" | дочерних: {childCount}" : ""; + var hiddenMarker = hidden ? " | скрыта" : ""; + var label = $"{piece.Name}{childMarker}{hiddenMarker}##piece_{piece.Id}"; + var open = ImGui.TreeNodeEx(label, flags); + var nodeClicked = ImGui.IsItemClicked(); + if (nodeClicked) + { + state.SelectedPieceId = piece.Id; + if (state.IsolateSelectedPiece) + _viewModel.MarkSceneRebuildNeeded(); + } + + if (ImGui.BeginPopupContextItem($"piece_context_{piece.Id}")) + { + if (ImGui.MenuItem(hidden ? "Показать часть" : "Скрыть часть")) + { + if (hidden) + state.HiddenPieceIds.Remove(piece.Id); + else + state.HiddenPieceIds.Add(piece.Id); + _viewModel.MarkSceneRebuildNeeded(); + } + + if (ImGui.MenuItem("Изолировать часть")) + { + state.SelectedPieceId = piece.Id; + state.IsolateSelectedPiece = true; + _viewModel.MarkSceneRebuildNeeded(); + } + + ImGui.EndPopup(); + } + + if (open) + { + if (state.SelectedPieceId == piece.Id) + DrawPieceDetailsToggle(piece); + + if (_expandedPieceDetails.Contains(piece.Id)) + DrawPieceDetailsChild(document, piece); + + if (childrenByParent.TryGetValue(piece.Id, out var childNodes)) + { + foreach (var child in childNodes) + DrawPieceNode(document, child, childrenByParent); + } + + ImGui.TreePop(); + } + } + + private void DrawPieceDetailsToggle(MeshPieceInfo piece) + { + var detailsOpen = _expandedPieceDetails.Contains(piece.Id); + var buttonText = detailsOpen ? "Скрыть свойства" : "Свойства"; + if (ImGui.SmallButton($"{buttonText}##piece_details_button_{piece.Id}")) + { + if (detailsOpen) + _expandedPieceDetails.Remove(piece.Id); + else + _expandedPieceDetails.Add(piece.Id); + } + } + + private void DrawPieceDetailsChild(MeshDocument document, MeshPieceInfo piece) + { + ImGui.Indent(); + ImGui.PushID(piece.Id); + // Child-панель держит длинные таблицы внутри выбранного узла и не превращает дерево в прыгающий список. + var height = MathF.Min(ImGui.GetTextLineHeightWithSpacing() * 24.0f, MathF.Max(220.0f, ImGui.GetContentRegionAvail().Y * 0.55f)); + if (ImGui.BeginChild("piece_details_child", new Vector2(0, height), ImGuiChildFlags.Border, ImGuiWindowFlags.None)) + { + DrawSelectedPieceDetails(document, piece); + } + ImGui.EndChild(); + ImGui.PopID(); + ImGui.Unindent(); + } + + private void DrawSelectedPieceDetails(MeshDocument document, MeshPieceInfo piece) + { + var state = _viewModel.RenderState; + ImGui.SeparatorText("Свойства части"); + + DrawSelectedPieceStateControls(document, piece); + + var pieceState = state.GetPieceState(piece.Id); + var pieceLod = state.GetPieceLod(piece.Id); + var slot = piece.ResolveSlotIndex(pieceState, pieceLod); + ImGui.Text($"Имя: {piece.Name}"); + ImGui.Text($"Родитель: {piece.ParentId}"); + ImGui.Text($"Флаги: {piece.Flags} (0x{(ushort)piece.Flags:X4})"); + ImGui.Text($"Состояние/LOD: {GetStateLabel(pieceState)}, {GetLodLabel(pieceLod)}"); + ImGui.Text($"Активный слот: {(slot == ushort.MaxValue ? "нет" : slot.ToString())}"); + ImGui.Text($"Поза покоя: {(piece.HasRestPose ? $"fallback-ключ {piece.FallbackKeyframeIndex}" : "нет")}"); + ImGui.Text($"Минимум границ: {piece.BoundsMin.X:F2}, {piece.BoundsMin.Y:F2}, {piece.BoundsMin.Z:F2}"); + ImGui.Text($"Максимум границ: {piece.BoundsMax.X:F2}, {piece.BoundsMax.Y:F2}, {piece.BoundsMax.Z:F2}"); + + DrawSlotMatrix(document, piece); + DrawBatchTable(piece); + } + + private void DrawSelectedPieceStateControls(MeshDocument document, MeshPieceInfo piece) + { + var state = _viewModel.RenderState; + NormalizePieceStateLod(document, piece); + + var availableStates = GetAvailablePieceStates(document, piece, state.GetPieceLod(piece.Id)); + var pieceState = state.GetPieceState(piece.Id); + if (DrawValueCombo("Состояние части", pieceState, availableStates, GetStateLabel, out pieceState)) + { + // Override хранится на piece, потому что 0x01 содержит отдельную state/LOD таблицу для каждого узла. + state.SetPieceStateOverride(piece.Id, pieceState); + state.SetPieceLodOverride(piece.Id, GetAvailablePieceLods(document, piece, pieceState).FirstOrDefault(state.GetPieceLod(piece.Id))); + _viewModel.MarkSceneRebuildNeeded(); + } + + var availableLods = GetAvailablePieceLods(document, piece, state.GetPieceState(piece.Id)); + var pieceLod = state.GetPieceLod(piece.Id); + if (DrawValueCombo("LOD части", pieceLod, availableLods, GetLodLabel, out pieceLod)) + { + state.SetPieceStateOverride(piece.Id, state.GetPieceState(piece.Id)); + state.SetPieceLodOverride(piece.Id, pieceLod); + _viewModel.MarkSceneRebuildNeeded(); + } + + if (ImGui.Button("Сбросить переопределение части")) + { + state.ClearPieceOverrides(piece.Id); + _viewModel.MarkSceneRebuildNeeded(); + } + } + + private static void DrawSlotMatrix(MeshDocument document, MeshPieceInfo piece) + { + if (!ImGui.CollapsingHeader("0x01 слоты состояния/LOD")) + return; + + if (!ImGui.BeginTable("slot_matrix", document.LodCount + 1, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg)) + return; + + ImGui.TableSetupColumn("Состояние"); + for (var lod = 0; lod < document.LodCount; lod++) + ImGui.TableSetupColumn(GetLodLabel(lod)); + ImGui.TableHeadersRow(); + + for (var state = 0; state < document.ModelStateCount; state++) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.Text(GetStateLabel(state)); + + for (var lod = 0; lod < document.LodCount; lod++) + { + ImGui.TableNextColumn(); + var slot = piece.ResolveSlotIndex(state, lod); + // 0xFFFF в 0x01 значит "нет геометрии" для этой пары state/LOD. + ImGui.Text(slot == ushort.MaxValue ? "-" : slot.ToString()); + } + } + + ImGui.EndTable(); + } + + private void DrawBatchTable(MeshPieceInfo piece) + { + if (!ImGui.CollapsingHeader($"0x0D батчи ({piece.Batches.Count})", ImGuiTreeNodeFlags.DefaultOpen)) + return; + + if (!ImGui.BeginTable("mesh_batches", 7, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.Resizable)) + return; + + ImGui.TableSetupColumn("#"); + ImGui.TableSetupColumn("Материал"); + ImGui.TableSetupColumn("Флаги"); + ImGui.TableSetupColumn("Треуг."); + ImGui.TableSetupColumn("Диапазон индексов"); + ImGui.TableSetupColumn("Базовая вершина"); + ImGui.TableSetupColumn("Предупреждения"); + ImGui.TableHeadersRow(); + + foreach (var batch in piece.Batches) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + var selected = _viewModel.RenderState.SelectedBatchIndex == batch.BatchIndex; + if (ImGui.Selectable(batch.BatchIndex.ToString(), selected, ImGuiSelectableFlags.SpanAllColumns)) + { + _viewModel.RenderState.SelectedBatchIndex = batch.BatchIndex; + _viewModel.RenderState.IsolateSelectedBatch = true; + _viewModel.MarkSceneRebuildNeeded(); + } + + ImGui.TableNextColumn(); + ImGui.Text(batch.MaterialName ?? $"#{batch.MaterialId}"); + ImGui.TableNextColumn(); + ImGui.Text($"0x{(ushort)batch.Flags:X4}"); + ImGui.TableNextColumn(); + ImGui.Text(batch.TriangleCount.ToString()); + ImGui.TableNextColumn(); + ImGui.Text($"{batch.IndexStart}..{batch.IndexStart + batch.IndexCount}"); + ImGui.TableNextColumn(); + ImGui.Text(batch.BaseVertex.ToString()); + ImGui.TableNextColumn(); + ImGui.Text(batch.Warnings.Count == 0 ? "" : string.Join("; ", batch.Warnings)); + } + + ImGui.EndTable(); + } + + private void NormalizePieceStateLod(MeshDocument document, MeshPieceInfo piece) + { + var renderState = _viewModel.RenderState; + var pieceState = renderState.GetPieceState(piece.Id); + var pieceLod = renderState.GetPieceLod(piece.Id); + if (HasSlot(document, piece, pieceState, pieceLod)) + return; + + var firstPair = EnumerateStateLodPairs(document, piece).FirstOrDefault(); + renderState.SetPieceStateOverride(piece.Id, firstPair.State); + renderState.SetPieceLodOverride(piece.Id, firstPair.Lod); + } + + private static IReadOnlyList GetAvailablePieceStates(MeshDocument document, MeshPieceInfo piece, int lod) + { + return Enumerable.Range(0, document.ModelStateCount) + .Where(state => HasSlot(document, piece, state, lod)) + .ToList(); + } + + private static IReadOnlyList GetAvailablePieceLods(MeshDocument document, MeshPieceInfo piece, int state) + { + return Enumerable.Range(0, document.LodCount) + .Where(lod => HasSlot(document, piece, state, lod)) + .ToList(); + } + + private static IEnumerable<(int State, int Lod)> EnumerateStateLodPairs(MeshDocument document, MeshPieceInfo piece) + { + for (var state = 0; state < document.ModelStateCount; state++) + { + for (var lod = 0; lod < document.LodCount; lod++) + { + if (HasSlot(document, piece, state, lod)) + yield return (state, lod); + } + } + } + + private static bool HasSlot(MeshDocument document, MeshPieceInfo piece, int state, int lod) + { + var slot = piece.ResolveSlotIndex(state, lod); + if (slot == ushort.MaxValue) + return false; + + return document.MshModelGeometry == null || slot < document.MshModelGeometry.GeometrySlots.Slots.Count; + } + + private static bool DrawValueCombo( + string label, + int currentValue, + IReadOnlyList values, + Func getLabel, + out int selectedValue) + { + selectedValue = currentValue; + if (values.Count == 0) + { + ImGui.BeginDisabled(); + ImGui.Text($"{label}: нет"); + ImGui.EndDisabled(); + return false; + } + + var preview = values.Contains(currentValue) ? getLabel(currentValue) : getLabel(values[0]); + var changed = false; + if (!ImGui.BeginCombo(label, preview)) + return false; + + foreach (var value in values) + { + var isSelected = value == currentValue; + if (ImGui.Selectable(getLabel(value), isSelected)) + { + selectedValue = value; + changed = value != currentValue; + } + + if (isSelected) + ImGui.SetItemDefaultFocus(); + } + + ImGui.EndCombo(); + return changed; + } + + private static string GetStateLabel(int state) + { + return state >= 0 && state < ModelStateLabels.Length + ? ModelStateLabels[state] + : $"Состояние {state}"; + } + + private static string GetLodLabel(int lod) + { + return lod >= 0 && lod < LodLabels.Length + ? LodLabels[lod] + : $"LOD {lod}"; + } + + private static string GetMeshTypeLabel(string meshType) + { + return meshType switch + { + "Model" => "модель", + "Landscape" => "ландшафт", + "Unknown" => "неизвестно", + _ => meshType + }; + } + + private static void DrawTooltip(string text) + { + if (!ImGui.IsItemHovered()) + return; + + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32.0f); + ImGui.TextUnformatted(text); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + private static void DrawWarnings(MeshDocument document) + { + if (document.Warnings.Count == 0) + return; + + if (!ImGui.CollapsingHeader($"Предупреждения ({document.Warnings.Count})")) + return; + + foreach (var warning in document.Warnings) + ImGui.TextWrapped(warning); + } +} diff --git a/NResUI/ImGuiUI/ViewportPanel.cs b/NResUI/ImGuiUI/ViewportPanel.cs index e5ba576..1e764f3 100644 --- a/NResUI/ImGuiUI/ViewportPanel.cs +++ b/NResUI/ImGuiUI/ViewportPanel.cs @@ -1,10 +1,12 @@ using System.Numerics; using ImGuiNET; using NResUI.Abstractions; +using NResUI.Models; +using NResUI.Rendering.Import.Msh; +using NResUI.Rendering.Materials; using NResUI.Rendering.Viewport; using NativeFileDialogSharp; using NResUI.Rendering.Viewport.Meshes; -using NResUI.Rendering.Viewport.Msh; using Silk.NET.OpenGL; using Silk.NET.Windowing; @@ -17,13 +19,14 @@ public sealed class ViewportPanel : IImGuiPanel private readonly ViewportCamera _camera = new(); private readonly ViewportInputController _inputController = new(); private readonly IConfigProvider _configProvider; + private readonly MeshViewportViewModel _viewModel; - private string? _loadedModelPath; - private string? _loadError; + private ViewportMaterialSet _materialSet = ViewportMaterialSet.Empty; - public ViewportPanel(GL gl, IWindow window, IConfigProvider configProvider) + public ViewportPanel(GL gl, IWindow window, IConfigProvider configProvider, MeshViewportViewModel viewModel) { _configProvider = configProvider; + _viewModel = viewModel; var cubeMesh = PrimitiveMeshes.CreateCube(gl); var gridMesh = PrimitiveMeshes.CreateWorldGrid(gl); @@ -43,6 +46,8 @@ public sealed class ViewportPanel : IImGuiPanel DrawSelectionStatus(); DrawViewportToolbar(); DrawDebugControls(); + RebuildSceneIfNeeded(); + SyncSelectionFromViewModel(); var imageSize = ImGui.GetContentRegionAvail(); if (imageSize.X < 32 || imageSize.Y < 32) @@ -78,6 +83,9 @@ public sealed class ViewportPanel : IImGuiPanel imageMin, imageSize); + if (_viewModel.RenderState.SelectedPieceId != _scene.SelectedPieceId) + _viewModel.RenderState.SelectedPieceId = _scene.SelectedPieceId; + ImGui.End(); } @@ -97,30 +105,34 @@ public sealed class ViewportPanel : IImGuiPanel { var cubeMesh = PrimitiveMeshes.CreateCube(_renderer.Gl); _scene.ReplacePieces([ViewportPiece.CreateUnitCube(0, "Cube", cubeMesh)]); - _loadedModelPath = null; - _loadError = null; + _viewModel.ClearDocument(); _camera.Reset(); } - if (_loadedModelPath != null) - ImGui.TextDisabled($"Model: {Path.GetFileName(_loadedModelPath)}"); + if (_viewModel.Document != null) + ImGui.TextDisabled($"Model: {Path.GetFileName(_viewModel.Document.SourcePath)}"); - if (_loadError != null) - ImGui.TextColored(new Vector4(1.0f, 0.35f, 0.25f, 1.0f), $"MSH load failed: {_loadError}"); + if (_viewModel.LoadError != null) + ImGui.TextColored(new Vector4(1.0f, 0.35f, 0.25f, 1.0f), $"MSH load failed: {_viewModel.LoadError}"); } private void LoadMsh(string path) { - var loadResult = MshViewportLoader.LoadFromFile(_renderer.Gl, path, _configProvider); + // Сначала строим CPU-документ для inspector, затем отдельно резолвим материалы и OpenGL textures. + var loadResult = MshMeshDocumentImporter.LoadFromFile(path); if (!loadResult.IsSuccess) { - _loadError = loadResult.Error ?? "Unknown error."; + _viewModel.SetError(loadResult.Error ?? "Unknown error."); return; } - _scene.ReplacePieces(loadResult.Pieces); - _loadedModelPath = loadResult.SourcePath; - _loadError = null; + _viewModel.SetDocument(loadResult.Document!); + _materialSet = ViewportMaterialResolver.LoadForMsh( + _renderer.Gl, + path, + _configProvider, + loadResult.Document!.Warnings is ICollection mutableWarnings ? mutableWarnings : null); + RebuildSceneIfNeeded(); if (_scene.TryGetSceneWorldBounds(out var bounds)) _camera.FrameBounds(bounds.Min, bounds.Max); @@ -149,6 +161,39 @@ public sealed class ViewportPanel : IImGuiPanel } } + private void RebuildSceneIfNeeded() + { + if (!_viewModel.NeedsSceneRebuild) + return; + + var document = _viewModel.Document; + if (document == null) + { + _viewModel.MarkSceneRebuilt(); + return; + } + + // Перестройка нужна при смене state/LOD, hide/isolate и batch filter: все это меняет состав GPU-мешей. + var pieces = MshViewportMeshFactory.BuildViewportPieces( + _renderer.Gl, + document, + _viewModel.RenderState, + _materialSet); + + _scene.ReplacePieces(pieces); + _scene.SelectedPieceId = _viewModel.RenderState.SelectedPieceId; + _viewModel.MarkSceneRebuilt(); + } + + private void SyncSelectionFromViewModel() + { + if (_viewModel.Document == null) + return; + + // Inspector может выбрать piece без клика по viewport; renderer должен подсветить тот же id. + _scene.SelectedPieceId = _viewModel.RenderState.SelectedPieceId; + } + private void DrawViewportToolbar() { if (ImGui.Button("Reset view")) diff --git a/NResUI/Models/MeshViewportViewModel.cs b/NResUI/Models/MeshViewportViewModel.cs new file mode 100644 index 0000000..3e009ab --- /dev/null +++ b/NResUI/Models/MeshViewportViewModel.cs @@ -0,0 +1,58 @@ +using NResUI.Rendering.Inspection; + +namespace NResUI.Models; + +/// +/// Общее состояние mesh viewport и inspector. +/// Панели синхронизируются через эту модель, чтобы выбор piece/batch не жил отдельно в каждом окне. +/// +public sealed class MeshViewportViewModel +{ + public MeshDocument? Document { get; private set; } + public MeshRenderState RenderState { get; } = new(); + public string? LoadError { get; private set; } + public bool NeedsSceneRebuild { get; private set; } + + public bool HasDocument => Document != null; + + /// Заменяет текущий документ и сбрасывает все viewport-only overrides. + public void SetDocument(MeshDocument document) + { + Document = document; + LoadError = null; + RenderState.HiddenPieceIds.Clear(); + RenderState.ClearAllPieceOverrides(); + RenderState.ClearSelection(); + NeedsSceneRebuild = true; + } + + public void SetError(string error) + { + Document = null; + LoadError = error; + RenderState.HiddenPieceIds.Clear(); + RenderState.ClearAllPieceOverrides(); + RenderState.ClearSelection(); + NeedsSceneRebuild = true; + } + + public void ClearDocument() + { + Document = null; + LoadError = null; + RenderState.HiddenPieceIds.Clear(); + RenderState.ClearAllPieceOverrides(); + RenderState.ClearSelection(); + NeedsSceneRebuild = true; + } + + public void MarkSceneRebuildNeeded() + { + NeedsSceneRebuild = true; + } + + public void MarkSceneRebuilt() + { + NeedsSceneRebuild = false; + } +} diff --git a/NResUI/NResUI.csproj b/NResUI/NResUI.csproj index 345f3d7..99ad2c7 100644 --- a/NResUI/NResUI.csproj +++ b/NResUI/NResUI.csproj @@ -27,6 +27,7 @@ + diff --git a/NResUI/Rendering/Import/Msh/MshMeshDocumentImporter.cs b/NResUI/Rendering/Import/Msh/MshMeshDocumentImporter.cs new file mode 100644 index 0000000..e0f928c --- /dev/null +++ b/NResUI/Rendering/Import/Msh/MshMeshDocumentImporter.cs @@ -0,0 +1,269 @@ +using System.Numerics; +using MshLib; +using NResLib; +using NResUI.Rendering.Inspection; +using NResUI.Rendering.Materials; +using NResUI.Rendering.Viewport.Msh; + +namespace NResUI.Rendering.Import.Msh; + +public static class MshMeshDocumentImporter +{ + private const int DefaultModelState = 0; + private const int DefaultLod = 0; + + /// + /// Читает MSH/NRes с диска и строит CPU-документ для инспектора. + /// OpenGL здесь намеренно не используется: этот слой должен быть проверяемым без viewport. + /// + public static MeshDocumentLoadResult LoadFromFile(string path) + { + var warnings = new List(); + + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var parseResult = NResParser.ReadFile(fs); + if (parseResult.Archive == null) + return MeshDocumentLoadResult.Failure(parseResult.Error ?? "Failed to parse MSH/NRes file.", path); + + var archive = parseResult.Archive; + var meshType = MshConverter.DetectMeshType(archive); + if (meshType != MshType.Model) + { + return MeshDocumentLoadResult.Failure( + $"Only normal MSH models are supported by the mesh inspector. Detected: {meshType}.", + path); + } + + fs.Seek(0, SeekOrigin.Begin); + var document = LoadModelDocument(fs, archive, path, warnings); + return MeshDocumentLoadResult.Success(document); + } + catch (Exception ex) + { + return MeshDocumentLoadResult.Failure(ex.Message, path); + } + } + + private static MeshDocument LoadModelDocument( + FileStream fs, + NResArchive archive, + string path, + List warnings) + { + var nodes = Msh0x01.ReadComponent(fs, archive); + var geometry = Msh0x02.ReadComponent(fs, archive); + var positions = Msh0x03.ReadComponent(fs, archive); + var normals = TryReadNormals(fs, archive, warnings); + var uvs = TryReadUvs(fs, archive, warnings); + var indices = Msh0x06.ReadComponent(fs, archive); + var batches = Msh0x0D.ReadComponent(fs, archive); + var animationDescriptors = Msh0x08.ReadComponent(fs, archive); + var restPoses = MshRestPoseBuilder.BuildRestPose(nodes, animationDescriptors); + var names = TryReadNames(fs, archive, warnings); + + var weaMatch = WeaResourceResolver.TryLoadMatchingWeaForMsh(path, warnings); + if (weaMatch != null) + warnings.Add($"Loaded WEA '{Path.GetFileName(weaMatch.Path)}' ({weaMatch.Reason})"); + + var materialNamesById = weaMatch?.File.Materials.ToDictionary(x => x.Id, x => x.Name) + ?? new Dictionary(); + + var pieces = new List(nodes.Nodes.Count); + for (var nodeIndex = 0; nodeIndex < nodes.Nodes.Count; nodeIndex++) + { + var node = nodes.Nodes[nodeIndex]; + var restPose = restPoses[nodeIndex]; + var activeSlotIndex = node.ResolveSlotIndex(DefaultModelState, DefaultLod); + var boundsMin = Vector3.Zero; + var boundsMax = Vector3.Zero; + + if (activeSlotIndex != ushort.MaxValue && activeSlotIndex < geometry.Slots.Count) + { + var slot = geometry.Slots[activeSlotIndex]; + boundsMin = ToNumericsVector3(slot.LocalMinimum); + boundsMax = ToNumericsVector3(slot.LocalMaximum); + } + + pieces.Add(new MeshPieceInfo + { + Id = nodeIndex, + Name = ResolvePieceName(names, nodeIndex), + ParentId = restPose.ParentIndex, + Flags = node.Flags, + GeometrySlotsByStateAndLod = node.Msh02SlotIndicesByStateAndLOD.ToArray(), + Batches = BuildPieceBatchInfo(node, geometry, batches, indices.Count, materialNamesById, warnings), + LocalTransform = restPose.LocalTransform, + MeshSpaceTransform = restPose.MeshSpaceTransform, + BoundsMin = boundsMin, + BoundsMax = boundsMax, + FallbackKeyframeIndex = restPose.FallbackKeyframeIndex, + HasRestPose = restPose.HasFallbackPose + }); + } + + return new MeshDocument + { + SourcePath = path, + MeshType = MshType.Model, + Pieces = pieces, + Materials = materialNamesById + .Select(x => new MeshMaterialInfo { Id = x.Key, Name = x.Value }) + .OrderBy(x => x.Id) + .ToList(), + Warnings = warnings, + MshModelGeometry = new MshModelGeometry + { + Nodes = nodes, + GeometrySlots = geometry, + Positions = positions, + Normals = normals, + Uvs = uvs, + Indices = indices, + Batches = batches + } + }; + } + + /// + /// Собирает batch metadata для всех state/LOD слотов piece. + /// Дубликаты batch убираются, потому что разные состояния могут ссылаться на один и тот же geometry slot. + /// + private static IReadOnlyList BuildPieceBatchInfo( + Msh0x01.Node node, + Msh0x02.Msh0x02Component geometry, + IReadOnlyList batches, + int indexCount, + IReadOnlyDictionary materialNamesById, + ICollection documentWarnings) + { + var result = new List(); + var seenBatchIndices = new HashSet(); + + foreach (var slotIndex in node.Msh02SlotIndicesByStateAndLOD.Distinct()) + { + if (slotIndex == ushort.MaxValue) + continue; + + if (slotIndex >= geometry.Slots.Count) + { + documentWarnings.Add($"Geometry slot {slotIndex} is out of range."); + continue; + } + + var slot = geometry.Slots[slotIndex]; + for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++) + { + if (batchIndex < 0 || batchIndex >= batches.Count || !seenBatchIndices.Add(batchIndex)) + continue; + + var batch = batches[batchIndex]; + var warnings = new List(); + if (batch.IndexStart0x06 + batch.IndexCount0x06 > indexCount) + warnings.Add("Index range exceeds MSH 0x06 index buffer."); + + var materialName = materialNamesById.GetValueOrDefault(batch.MaterialIndexLo); + + if (materialName == "B_PLT_06") + { + _ = 5; + } + + result.Add(new MeshBatchInfo + { + BatchIndex = batchIndex, + MaterialId = batch.MaterialIndexLo, + MaterialName = materialName, + Flags = batch.Flags, + IndexStart = batch.IndexStart0x06, + IndexCount = batch.IndexCount0x06, + BaseVertex = batch.BaseVertex0x03, + VertexCount = batch.VertexCount0x03, + TriangleCount = batch.IndexCount0x06 / 3, + Warnings = warnings + }); + } + } + + return result; + } + + private static List TryReadNames(FileStream fs, NResArchive archive, ICollection warnings) + { + try + { + return Msh0x0A.ReadComponent(fs, archive); + } + catch (Exception ex) + { + warnings.Add($"MSH 0x0A names are unavailable: {ex.Message}"); + return []; + } + } + + private static List TryReadNormals(FileStream fs, NResArchive archive, ICollection warnings) + { + try + { + return Msh0x04.ReadComponent(fs, archive); + } + catch (Exception ex) + { + warnings.Add($"MSH 0x04 normals are unavailable: {ex.Message}"); + return []; + } + } + + private static List TryReadUvs(FileStream fs, NResArchive archive, ICollection warnings) + { + try + { + return Msh0x05.ReadComponent(fs, archive); + } + catch (Exception ex) + { + warnings.Add($"MSH 0x05 UVs are unavailable: {ex.Message}"); + return []; + } + } + + private static string ResolvePieceName(IReadOnlyList names, int nodeIndex) + { + if (nodeIndex >= 0 && nodeIndex < names.Count && !string.IsNullOrWhiteSpace(names[nodeIndex])) + return $"{nodeIndex}: {names[nodeIndex]}"; + + return $"{nodeIndex}: piece_{nodeIndex:D3}"; + } + + private static Vector3 ToNumericsVector3(Common.Vector3 position) + { + return new Vector3(position.X, position.Y, position.Z); + } +} + +public sealed class MeshDocumentLoadResult +{ + public bool IsSuccess { get; } + public string? Error { get; } + public string? SourcePath { get; } + public MeshDocument? Document { get; } + + private MeshDocumentLoadResult(bool isSuccess, string? error, string? sourcePath, MeshDocument? document) + { + IsSuccess = isSuccess; + Error = error; + SourcePath = sourcePath; + Document = document; + } + + public static MeshDocumentLoadResult Success(MeshDocument document) + { + return new MeshDocumentLoadResult(true, null, document.SourcePath, document); + } + + public static MeshDocumentLoadResult Failure(string error, string? sourcePath = null) + { + return new MeshDocumentLoadResult(false, error, sourcePath, null); + } +} diff --git a/NResUI/Rendering/Import/Msh/MshViewportMeshFactory.cs b/NResUI/Rendering/Import/Msh/MshViewportMeshFactory.cs new file mode 100644 index 0000000..e9d9d3c --- /dev/null +++ b/NResUI/Rendering/Import/Msh/MshViewportMeshFactory.cs @@ -0,0 +1,325 @@ +using System.Numerics; +using MshLib; +using NResUI.Rendering.Inspection; +using NResUI.Rendering.Materials; +using NResUI.Rendering.Viewport; +using NResUI.Rendering.Viewport.Meshes; +using Silk.NET.OpenGL; + +namespace NResUI.Rendering.Import.Msh; + +public static class MshViewportMeshFactory +{ + /// + /// Строит OpenGL-меши из уже разобранного MeshDocument. + /// State/LOD выбираются отдельно для каждого piece через 0x01, а не через общий список geometry slots. + /// + public static IReadOnlyList BuildViewportPieces( + GL gl, + MeshDocument document, + MeshRenderState renderState, + ViewportMaterialSet materialSet) + { + if (document.MshModelGeometry == null) + return []; + + var result = new List(); + var modelGeometry = document.MshModelGeometry; + var mshToViewportTransform = Matrix4x4.CreateRotationX(-MathF.PI * 0.5f); + + foreach (var pieceInfo in document.Pieces) + { + if (!renderState.IsPieceVisible(pieceInfo.Id)) + continue; + + var (pieceState, pieceLod) = ResolvePieceStateLod(document, pieceInfo, renderState); + var slotIndex = pieceInfo.ResolveSlotIndex(pieceState, pieceLod); + if (slotIndex == ushort.MaxValue || slotIndex >= modelGeometry.GeometrySlots.Slots.Count) + { + if (renderState.ShowEmptyPieces) + AddEmptyPieceMarker(gl, result, pieceInfo, mshToViewportTransform, "No geometry slot for selected state/LOD"); + continue; + } + + var slot = modelGeometry.GeometrySlots.Slots[slotIndex]; + if (!slot.HasBatches) + { + if (renderState.ShowEmptyPieces) + AddEmptyPieceMarker(gl, result, pieceInfo, mshToViewportTransform, "Geometry slot has no batches", slotIndex); + continue; + } + + var meshBuildResult = BuildPieceMeshes( + gl, + pieceInfo.Id, + slot, + modelGeometry, + renderState, + materialSet); + + if (meshBuildResult == null) + { + if (renderState.ShowEmptyPieces) + AddEmptyPieceMarker(gl, result, pieceInfo, mshToViewportTransform, "Geometry slot produced no valid triangles", slotIndex); + continue; + } + + result.Add(new ViewportPiece( + id: pieceInfo.Id, + name: pieceInfo.Name, + meshes: meshBuildResult.Meshes, + localTransform: pieceInfo.MeshSpaceTransform * mshToViewportTransform, + boundsMin: meshBuildResult.BoundsMin, + boundsMax: meshBuildResult.BoundsMax, + debugInfo: new ViewportPieceDebugInfo + { + SourceKind = "MSH 0x01 piece", + SourcePieceIndex = pieceInfo.Id, + SourceParentIndex = pieceInfo.ParentId, + GeometrySlotIndex = slotIndex, + Msh01Flags = (uint)pieceInfo.Flags, + BatchCount = meshBuildResult.BatchCount, + TriangleCount = meshBuildResult.TriangleCount, + FallbackKeyframeIndex = pieceInfo.FallbackKeyframeIndex, + HasRestPose = pieceInfo.HasRestPose + }, + sourceBatchIndices: meshBuildResult.BatchIndices)); + } + + return result; + } + + private static (int State, int Lod) ResolvePieceStateLod( + MeshDocument document, + MeshPieceInfo pieceInfo, + MeshRenderState renderState) + { + if (renderState.PieceStateOverrides.ContainsKey(pieceInfo.Id) || + renderState.PieceLodOverrides.ContainsKey(pieceInfo.Id)) + { + return (renderState.GetPieceState(pieceInfo.Id), renderState.GetPieceLod(pieceInfo.Id)); + } + + // Без явного override piece использует первую реально присутствующую пару state/LOD из своей 0x01 таблицы. + for (var state = 0; state < document.ModelStateCount; state++) + { + for (var lod = 0; lod < document.LodCount; lod++) + { + var slot = pieceInfo.ResolveSlotIndex(state, lod); + if (slot != ushort.MaxValue && + (document.MshModelGeometry == null || slot < document.MshModelGeometry.GeometrySlots.Slots.Count)) + { + return (state, lod); + } + } + } + + return (0, 0); + } + + private static void AddEmptyPieceMarker( + GL gl, + List pieces, + MeshPieceInfo pieceInfo, + Matrix4x4 mshToViewportTransform, + string reason, + int geometrySlotIndex = -1) + { + // Пустые pieces все равно важны: это могут быть сокеты, логические узлы или placeholders поврежденных состояний. + var marker = PrimitiveMeshes.CreateAxes(gl, 0.35f); + pieces.Add(new ViewportPiece( + id: pieceInfo.Id, + name: pieceInfo.Name, + mesh: marker, + localTransform: pieceInfo.MeshSpaceTransform * mshToViewportTransform, + boundsMin: new Vector3(-0.35f, -0.35f, -0.35f), + boundsMax: new Vector3(0.35f, 0.35f, 0.35f), + debugInfo: new ViewportPieceDebugInfo + { + SourceKind = reason, + SourcePieceIndex = pieceInfo.Id, + SourceParentIndex = pieceInfo.ParentId, + GeometrySlotIndex = geometrySlotIndex, + Msh01Flags = (uint)pieceInfo.Flags, + BatchCount = 0, + TriangleCount = 0, + FallbackKeyframeIndex = pieceInfo.FallbackKeyframeIndex, + HasRestPose = pieceInfo.HasRestPose + })); + } + + private static PieceMeshBuildResult? BuildPieceMeshes( + GL gl, + int pieceId, + Msh0x02.GeometrySlot slot, + MshModelGeometry modelGeometry, + MeshRenderState renderState, + ViewportMaterialSet materialSet) + { + var meshes = new List(); + var batchIndices = new List(); + var debugColor = PickDebugColor(pieceId); + var batchCount = 0; + var triangleCount = 0; + + var boundsMin = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); + var boundsMax = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); + + for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++) + { + if (batchIndex < 0 || batchIndex >= modelGeometry.Batches.Count || !renderState.IsBatchVisible(batchIndex)) + continue; + + var batch = modelGeometry.Batches[batchIndex]; + if (batch.IndexStart0x06 + batch.IndexCount0x06 > modelGeometry.Indices.Count) + continue; + + var vertices = new List(); + var outIndices = new List(); + var batchTriangleCount = 0; + + var material = materialSet.FindMaterial(batch.MaterialIndexLo) ?? ViewportMaterial.Untextured; + var vertexColor = material.HasTexture ? Vector3.One : debugColor; + + for (var i = 0; i + 2 < batch.IndexCount0x06; i += 3) + { + var indexBase = (int)batch.IndexStart0x06 + i; + var vertexIndex0 = checked((int)batch.BaseVertex0x03 + modelGeometry.Indices[indexBase + 0]); + var vertexIndex1 = checked((int)batch.BaseVertex0x03 + modelGeometry.Indices[indexBase + 1]); + var vertexIndex2 = checked((int)batch.BaseVertex0x03 + modelGeometry.Indices[indexBase + 2]); + + if (!IsValidTriangle(modelGeometry.Positions.Count, vertexIndex0, vertexIndex1, vertexIndex2)) + continue; + + var p0 = ToNumericsVector3(modelGeometry.Positions[vertexIndex0]); + var p1 = ToNumericsVector3(modelGeometry.Positions[vertexIndex1]); + var p2 = ToNumericsVector3(modelGeometry.Positions[vertexIndex2]); + + var fallbackNormal = BuildFaceNormal(p0, p1, p2); + + AddTriangleVertex(p0, vertexColor, GetNormal(modelGeometry.Normals, vertexIndex0, fallbackNormal, renderState.UseStoredNormals), GetUv(modelGeometry.Uvs, vertexIndex0), vertices, outIndices, ref boundsMin, ref boundsMax); + AddTriangleVertex(p1, vertexColor, GetNormal(modelGeometry.Normals, vertexIndex1, fallbackNormal, renderState.UseStoredNormals), GetUv(modelGeometry.Uvs, vertexIndex1), vertices, outIndices, ref boundsMin, ref boundsMax); + AddTriangleVertex(p2, vertexColor, GetNormal(modelGeometry.Normals, vertexIndex2, fallbackNormal, renderState.UseStoredNormals), GetUv(modelGeometry.Uvs, vertexIndex2), vertices, outIndices, ref boundsMin, ref boundsMax); + batchTriangleCount++; + } + + if (batchTriangleCount == 0) + continue; + + batchCount++; + batchIndices.Add(batchIndex); + triangleCount += batchTriangleCount; + meshes.Add(PrimitiveMeshes.CreateColoredIndexedMesh(gl, vertices, outIndices, PrimitiveType.Triangles, material)); + } + + if (triangleCount == 0 || meshes.Count == 0) + return null; + + return new PieceMeshBuildResult(meshes, boundsMin, boundsMax, batchCount, triangleCount, batchIndices); + } + + private static Vector3 BuildFaceNormal(Vector3 p0, Vector3 p1, Vector3 p2) + { + var normal = Vector3.Cross(p1 - p0, p2 - p0); + return normal.LengthSquared() < 1e-8f ? Vector3.UnitY : Vector3.Normalize(normal); + } + + private static Vector3 GetNormal( + IReadOnlyList normals, + int vertexIndex, + Vector3 fallbackNormal, + bool useStoredNormals) + { + if (!useStoredNormals || vertexIndex < 0 || vertexIndex >= normals.Count) + return fallbackNormal; + + var packed = normals[vertexIndex]; + var normal = new Vector3( + Math.Clamp(packed.X / 127.0f, -1.0f, 1.0f), + Math.Clamp(packed.Y / 127.0f, -1.0f, 1.0f), + Math.Clamp(packed.Z / 127.0f, -1.0f, 1.0f)); + + // У битых/нулевых normal entries оставляем face normal, чтобы освещение не превращалось в NaN. + return normal.LengthSquared() < 1e-8f ? fallbackNormal : Vector3.Normalize(normal); + } + + private static Vector2 GetUv(IReadOnlyList uvs, int vertexIndex) + { + if (vertexIndex < 0 || vertexIndex >= uvs.Count) + return Vector2.Zero; + + var uv = uvs[vertexIndex]; + return new Vector2(uv.U / 1024.0f, uv.V / 1024.0f); + } + + private static Vector3 ToNumericsVector3(Common.Vector3 position) + { + return new Vector3(position.X, position.Y, position.Z); + } + + private static void AddTriangleVertex( + Vector3 position, + Vector3 color, + Vector3 normal, + Vector2 uv, + List vertices, + List indices, + ref Vector3 boundsMin, + ref Vector3 boundsMax) + { + var vertexIndex = (uint)(vertices.Count / PrimitiveMeshes.FloatsPerVertex); + + vertices.Add(position.X); + vertices.Add(position.Y); + vertices.Add(position.Z); + vertices.Add(color.X); + vertices.Add(color.Y); + vertices.Add(color.Z); + vertices.Add(normal.X); + vertices.Add(normal.Y); + vertices.Add(normal.Z); + vertices.Add(uv.X); + vertices.Add(uv.Y); + + indices.Add(vertexIndex); + boundsMin = Vector3.Min(boundsMin, position); + boundsMax = Vector3.Max(boundsMax, position); + } + + private static bool IsValidTriangle(int vertexCount, int vertexIndex0, int vertexIndex1, int vertexIndex2) + { + return IsValidVertexIndex(vertexCount, vertexIndex0) && + IsValidVertexIndex(vertexCount, vertexIndex1) && + IsValidVertexIndex(vertexCount, vertexIndex2); + } + + private static bool IsValidVertexIndex(int vertexCount, int vertexIndex) + { + return vertexIndex >= 0 && vertexIndex < vertexCount; + } + + private static Vector3 PickDebugColor(int index) + { + ReadOnlySpan palette = + [ + new Vector3(0.90f, 0.36f, 0.30f), + new Vector3(0.30f, 0.68f, 0.95f), + new Vector3(0.42f, 0.82f, 0.42f), + new Vector3(0.95f, 0.78f, 0.32f), + new Vector3(0.78f, 0.48f, 0.95f), + new Vector3(0.36f, 0.86f, 0.78f), + new Vector3(0.95f, 0.55f, 0.78f), + new Vector3(0.72f, 0.72f, 0.72f), + ]; + + return palette[index % palette.Length]; + } + + private sealed record PieceMeshBuildResult( + IReadOnlyList Meshes, + Vector3 BoundsMin, + Vector3 BoundsMax, + int BatchCount, + int TriangleCount, + IReadOnlyList BatchIndices); +} diff --git a/NResUI/Rendering/Inspection/MeshDocument.cs b/NResUI/Rendering/Inspection/MeshDocument.cs new file mode 100644 index 0000000..45029b6 --- /dev/null +++ b/NResUI/Rendering/Inspection/MeshDocument.cs @@ -0,0 +1,93 @@ +using System.Numerics; +using MshLib; + +namespace NResUI.Rendering.Inspection; + +/// +/// CPU-документ меша: общая модель данных для viewport и inspector до загрузки в OpenGL. +/// Здесь хранятся ссылки на исходные MSH-компоненты и уже удобные для UI metadata. +/// +public sealed class MeshDocument +{ + /// Путь к MSH/NRes ресурсу, из которого построен документ. + public required string SourcePath { get; init; } + + /// Тип MSH по набору компонентов. Сейчас inspector поддерживает только обычный Model. + public required MshType MeshType { get; init; } + + /// Список pieces из 0x01. Каждый piece сам выбирает slot для state/LOD. + public IReadOnlyList Pieces { get; init; } = []; + + /// Материалы, пришедшие из WEA. Текстуры резолвятся отдельно, потому что это уже UI/OpenGL слой. + public IReadOnlyList Materials { get; init; } = []; + + /// Нефатальные проблемы загрузки: отсутствующие optional-компоненты, WEA/material/texture misses. + public IReadOnlyList Warnings { get; init; } = []; + + /// Количество известных model states в 0x01 таблице. + public int ModelStateCount { get; init; } = Msh0x01.StateCount; + + /// Количество LOD-уровней на каждый state в 0x01 таблице. + public int LodCount { get; init; } = Msh0x01.MaxLodCount; + + /// Сырые MSH-потоки, нужные для перестройки GPU-мешей при смене state/LOD. + public MshModelGeometry? MshModelGeometry { get; init; } +} + +/// +/// Информация об одном piece/node из 0x01. +/// Важно: state/LOD не глобальны, поэтому этот объект хранит собственную slot-таблицу. +/// +public sealed class MeshPieceInfo +{ + public required int Id { get; init; } + public required string Name { get; init; } + public required int ParentId { get; init; } + public required NodeFlags Flags { get; init; } + public required IReadOnlyList GeometrySlotsByStateAndLod { get; init; } + public required IReadOnlyList Batches { get; init; } + public required Matrix4x4 LocalTransform { get; init; } + public required Matrix4x4 MeshSpaceTransform { get; init; } + public required Vector3 BoundsMin { get; init; } + public required Vector3 BoundsMax { get; init; } + public int FallbackKeyframeIndex { get; init; } = -1; + public bool HasRestPose { get; init; } + + /// + /// Возвращает geometry slot 0x02 для пары state/LOD именно этого piece. + /// 0xFFFF означает, что piece в такой комбинации не должен рендериться. + /// + public ushort ResolveSlotIndex(int state, int lod) + { + var index = state * Msh0x01.MaxLodCount + lod; + return index >= 0 && index < GeometrySlotsByStateAndLod.Count + ? GeometrySlotsByStateAndLod[index] + : ushort.MaxValue; + } +} + +/// +/// Inspector metadata для batch 0x0D. Геометрия остается в MshModelGeometry, здесь только диапазоны и флаги. +/// +public sealed class MeshBatchInfo +{ + public required int BatchIndex { get; init; } + public required int MaterialId { get; init; } + public string? MaterialName { get; init; } + public required BatchFlags Flags { get; init; } + public required uint IndexStart { get; init; } + public required int IndexCount { get; init; } + public required uint BaseVertex { get; init; } + public required int VertexCount { get; init; } + public required int TriangleCount { get; init; } + public IReadOnlyList Warnings { get; init; } = []; +} + +/// Материал из WEA по id. Это еще не ViewportMaterial и не OpenGL texture. +public sealed class MeshMaterialInfo +{ + public required int Id { get; init; } + public required string Name { get; init; } + public string? TextureName { get; init; } + public bool HasTexture => TextureName != null; +} diff --git a/NResUI/Rendering/Inspection/MeshRenderState.cs b/NResUI/Rendering/Inspection/MeshRenderState.cs new file mode 100644 index 0000000..ba7c7bb --- /dev/null +++ b/NResUI/Rendering/Inspection/MeshRenderState.cs @@ -0,0 +1,71 @@ +namespace NResUI.Rendering.Inspection; + +/// +/// Состояние просмотра меша. State/LOD задаются только для конкретных pieces, +/// потому что 0x01 хранит таблицу slot indices отдельно для каждого узла. +/// +public sealed class MeshRenderState +{ + public bool UseStoredNormals { get; set; } = true; + public bool ShowEmptyPieces { get; set; } = true; + public int SelectedPieceId { get; set; } = -1; + public int SelectedBatchIndex { get; set; } = -1; + public bool IsolateSelectedPiece { get; set; } + public bool IsolateSelectedBatch { get; set; } + public HashSet HiddenPieceIds { get; } = []; + public Dictionary PieceStateOverrides { get; } = []; + public Dictionary PieceLodOverrides { get; } = []; + + public void ClearSelection() + { + SelectedPieceId = -1; + SelectedBatchIndex = -1; + IsolateSelectedPiece = false; + IsolateSelectedBatch = false; + } + + public int GetPieceState(int pieceId) + { + return PieceStateOverrides.GetValueOrDefault(pieceId, 0); + } + + public int GetPieceLod(int pieceId) + { + return PieceLodOverrides.GetValueOrDefault(pieceId, 0); + } + + public void SetPieceStateOverride(int pieceId, int state) + { + PieceStateOverrides[pieceId] = state; + } + + public void SetPieceLodOverride(int pieceId, int lod) + { + PieceLodOverrides[pieceId] = lod; + } + + public void ClearPieceOverrides(int pieceId) + { + PieceStateOverrides.Remove(pieceId); + PieceLodOverrides.Remove(pieceId); + } + + public void ClearAllPieceOverrides() + { + PieceStateOverrides.Clear(); + PieceLodOverrides.Clear(); + } + + public bool IsPieceVisible(int pieceId) + { + if (HiddenPieceIds.Contains(pieceId)) + return false; + + return !IsolateSelectedPiece || SelectedPieceId < 0 || pieceId == SelectedPieceId; + } + + public bool IsBatchVisible(int batchIndex) + { + return !IsolateSelectedBatch || SelectedBatchIndex < 0 || batchIndex == SelectedBatchIndex; + } +} diff --git a/NResUI/Rendering/Inspection/MshModelGeometry.cs b/NResUI/Rendering/Inspection/MshModelGeometry.cs new file mode 100644 index 0000000..3019965 --- /dev/null +++ b/NResUI/Rendering/Inspection/MshModelGeometry.cs @@ -0,0 +1,30 @@ +using MshLib; + +namespace NResUI.Rendering.Inspection; + +/// +/// Parsed MSH component data retained so state/LOD changes can rebuild GPU meshes without reparsing the file. +/// +public sealed class MshModelGeometry +{ + /// Таблица узлов 0x01: именно она связывает каждый piece с geometry slot для пары state/LOD. + public required Msh0x01.Msh0x01Component Nodes { get; init; } + + /// Слоты геометрии 0x02. Один piece выбирает один слот через 0x01.ResolveSlotIndex. + public required Msh0x02.Msh0x02Component GeometrySlots { get; init; } + + /// Позиции вершин из 0x03. + public required IReadOnlyList Positions { get; init; } + + /// Нормали из 0x04. Компонент опционален, поэтому фабрика мешей обязана иметь fallback. + public required IReadOnlyList Normals { get; init; } + + /// UV из 0x05. Компонент опционален для части ресурсов. + public required IReadOnlyList Uvs { get; init; } + + /// Индексный буфер 0x06 для обычных моделей. + public required IReadOnlyList Indices { get; init; } + + /// Render batches 0x0D: диапазоны индексов, material id и render flags. + public required IReadOnlyList Batches { get; init; } +} diff --git a/NResUI/Rendering/Materials/ViewportMaterialResolver.cs b/NResUI/Rendering/Materials/ViewportMaterialResolver.cs new file mode 100644 index 0000000..aa32ad7 --- /dev/null +++ b/NResUI/Rendering/Materials/ViewportMaterialResolver.cs @@ -0,0 +1,164 @@ +using MaterialLib; +using NResLib; +using NResUI.Abstractions; +using NResUI.Rendering.Viewport; +using NResUI.Rendering.Viewport.OpenGL; +using Silk.NET.OpenGL; +using TexmLib; + +namespace NResUI.Rendering.Materials; + +/// Набор материалов, готовых для viewport. Ключ — material id из WEA/MSH batch. +public sealed class ViewportMaterialSet +{ + private readonly Dictionary _materialsById; + + public ViewportMaterialSet(Dictionary materialsById) + { + _materialsById = materialsById; + } + + public static ViewportMaterialSet Empty { get; } = new([]); + + public ViewportMaterial? FindMaterial(int id) + { + return _materialsById.GetValueOrDefault(id); + } +} + +/// +/// Связывает WEA -> Material.lib -> Textures.lib -> OpenGL texture. +/// Этот слой намеренно остается в NResUI, потому что только UI знает GameBasePath и GL context. +/// +public static class ViewportMaterialResolver +{ + /// + /// Загружает материалы для MSH. Любая ошибка резолва не фатальна: модель должна остаться видимой без текстур. + /// + public static ViewportMaterialSet LoadForMsh( + GL gl, + string mshPath, + IConfigProvider configProvider, + ICollection? warnings = null) + { + var weaMatch = WeaResourceResolver.TryLoadMatchingWeaForMsh(mshPath, warnings); + if (weaMatch == null || weaMatch.File.Materials.Count == 0) + return ViewportMaterialSet.Empty; + + var materialLibPath = Path.Combine(configProvider.GetConfig().GameBasePath, "Material.lib"); + if (!File.Exists(materialLibPath)) + { + warnings?.Add($"Material.lib was not found at '{materialLibPath}'."); + return ViewportMaterialSet.Empty; + } + + using var materialFs = new FileStream(materialLibPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var materialArchiveResult = NResParser.ReadFile(materialFs); + if (materialArchiveResult.Archive == null) + { + warnings?.Add($"Failed to parse Material.lib: {materialArchiveResult.Error}"); + return ViewportMaterialSet.Empty; + } + + var result = new Dictionary(); + foreach (var materialRef in weaMatch.File.Materials) + { + var texture = TryLoadMaterialTexture( + gl, + materialFs, + materialArchiveResult.Archive, + materialRef.Name, + configProvider, + warnings, + out var texm); + + result[materialRef.Id] = texture != null + ? new ViewportMaterial(materialRef.Name, texture.Value, texm) + : new ViewportMaterial(materialRef.Name); + } + + return new ViewportMaterialSet(result); + } + + private static uint? TryLoadMaterialTexture( + GL gl, + FileStream materialFs, + NResArchive materialArchive, + string materialName, + IConfigProvider configProvider, + ICollection? warnings, + out TexmFile? texm) + { + texm = null; + var materialEntry = FindEntry(materialArchive, materialName); + if (materialEntry == null) + { + warnings?.Add($"Material '{materialName}' referenced by WEA was not found in Material.lib."); + return null; + } + + materialFs.Seek(materialEntry.OffsetInFile, SeekOrigin.Begin); + var materialData = new byte[materialEntry.FileLength]; + materialFs.ReadExactly(materialData, 0, materialData.Length); + + using var ms = new MemoryStream(materialData, writable: false); + var materialFile = MaterialParser.ReadFromStream( + ms, + materialName, + materialEntry.ElementCount, + materialEntry.Magic1); + + var textureName = materialFile.Stages.FirstOrDefault()?.TextureName; + if (string.IsNullOrWhiteSpace(textureName)) + return null; + + // Текстуры живут в отдельной NRes-библиотеке, поэтому material parsing и texture parsing разделены. + var textureLibPath = Path.Combine(configProvider.GetConfig().GameBasePath, "Textures.lib"); + if (!File.Exists(textureLibPath)) + { + warnings?.Add($"Textures.lib was not found at '{textureLibPath}'."); + return null; + } + + using var textureFs = new FileStream(textureLibPath, FileMode.Open, FileAccess.Read, FileShare.Read); + var textureArchiveResult = NResParser.ReadFile(textureFs); + if (textureArchiveResult.Archive == null) + { + warnings?.Add($"Failed to parse Textures.lib: {textureArchiveResult.Error}"); + return null; + } + + var textureEntry = FindEntry(textureArchiveResult.Archive, textureName); + if (textureEntry == null) + { + warnings?.Add($"Texture '{textureName}' referenced by material '{materialName}' was not found in Textures.lib."); + return null; + } + + textureFs.Seek(textureEntry.OffsetInFile, SeekOrigin.Begin); + var textureData = new byte[textureEntry.FileLength]; + textureFs.ReadExactly(textureData, 0, textureData.Length); + + using var textureMs = new MemoryStream(textureData, writable: false); + var texmResult = TexmParser.ReadFromStream(textureMs, textureEntry.FileName); + if (texmResult.TexmFile == null) + { + warnings?.Add($"Failed to parse TEXM '{textureName}': {texmResult.Error}"); + return null; + } + + texm = texmResult.TexmFile; + var rgba = texm.GetRgba32BytesFromMipmap(0, out var width, out var height); + return ViewportTextureLoader.CreateRgbaTexture(gl, rgba, width, height); + } + + private static ListMetadataItem? FindEntry(NResArchive archive, string name) + { + static string Normalize(string value) => value.Trim().ToLowerInvariant(); + + var normalized = Normalize(name); + return archive.Files.FirstOrDefault(x => + string.Equals(x.FileName, name, StringComparison.OrdinalIgnoreCase) || + Normalize(x.FileName) == normalized); + } +} diff --git a/NResUI/Rendering/Materials/WeaResourceResolver.cs b/NResUI/Rendering/Materials/WeaResourceResolver.cs new file mode 100644 index 0000000..48ed3c0 --- /dev/null +++ b/NResUI/Rendering/Materials/WeaResourceResolver.cs @@ -0,0 +1,147 @@ +using WeaLib; + +namespace NResUI.Rendering.Materials; + +/// Результат эвристического поиска WEA рядом с MSH. +public sealed class WeaResourceMatch +{ + public required string Path { get; init; } + public required string Reason { get; init; } + public required WeaFile File { get; init; } +} + +/// +/// UI-side resolver для связи MSH-файла с WEA-файлом. +/// Это не часть WeaLib, потому что matching зависит от экспортированных имен и расположения ресурсов на диске. +/// +public static class WeaResourceResolver +{ + /// + /// Ищет WEA рядом с MSH и сразу парсит его. + /// Если файл не найден или битый, viewport все равно может показать модель без материалов. + /// + public static WeaResourceMatch? TryLoadMatchingWeaForMsh(string mshPath, ICollection? warnings = null) + { + var match = FindMatchingWeaPath(mshPath, out var reason); + if (match == null) + return null; + + var parseResult = WeaParser.ReadFile(match); + if (parseResult.WeaFile == null) + { + warnings?.Add($"Failed to parse WEA '{match}': {parseResult.Error}"); + return null; + } + + return new WeaResourceMatch + { + Path = match, + Reason = reason, + File = parseResult.WeaFile + }; + } + + private static string? FindMatchingWeaPath(string mshPath, out string reason) + { + reason = "No matching WEA found."; + var directory = Path.GetDirectoryName(mshPath); + if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) + return null; + + var mshResourceName = GetExportedResourceNameWithoutExtension(mshPath); + var mshObjectStem = ExtractObjectStem(mshResourceName); + + var candidates = Directory.EnumerateFiles(directory, "*.wea", SearchOption.TopDirectoryOnly) + .Select(path => new + { + Path = path, + ResourceName = GetExportedResourceNameWithoutExtension(path), + }) + .Select(x => new + { + x.Path, + x.ResourceName, + ObjectStem = ExtractObjectStem(x.ResourceName), + }) + .ToList(); + + var exact = candidates.FirstOrDefault(x => string.Equals(x.ObjectStem, mshObjectStem, + StringComparison.OrdinalIgnoreCase)); + + // Лучший случай: MESH_* и WEAR_* после нормализации указывают на один объект. + if (exact != null) + { + reason = "Object stem matched exactly."; + return exact.Path; + } + + var best = candidates + .Select(x => new + { + x.Path, + Score = CommonPrefixTokenCount( + SplitResourceTokens(mshObjectStem), + SplitResourceTokens(x.ObjectStem)) + }) + .OrderByDescending(x => x.Score) + .FirstOrDefault(); + + // Fallback нужен для экспортов, где суффиксы MESH/WEAR отличаются, но начало имени объекта совпадает. + if (best?.Score > 0) + { + reason = $"Shared first {best.Score} resource token(s)."; + return best.Path; + } + + return null; + } + + private static string GetExportedResourceNameWithoutExtension(string path) + { + var name = Path.GetFileNameWithoutExtension(path); + var firstUnderscore = name.IndexOf('_'); + // Экспортер часто добавляет numeric prefix: 58_MESH_..., 81_WEAR_... + if (firstUnderscore > 0 && name[..firstUnderscore].All(char.IsDigit)) + name = name[(firstUnderscore + 1)..]; + + return name; + } + + private static string ExtractObjectStem(string resourceName) + { + var normalized = resourceName; + // Тип ресурса не является частью имени объекта, поэтому MESH_o_x и WEAR_o_x должны совпасть. + foreach (var prefix in new[] { "MESH_", "WEAR_", "TEXT_", "ANIM_" }) + { + if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[prefix.Length..]; + break; + } + } + + var tokens = normalized.Split('_', StringSplitOptions.RemoveEmptyEntries); + return string.Join('_', tokens); + } + + private static string[] SplitResourceTokens(string value) + { + return value.Split('_', StringSplitOptions.RemoveEmptyEntries); + } + + private static int CommonPrefixTokenCount(string[] a, string[] b) + { + var count = Math.Min(a.Length, b.Length); + var result = 0; + + for (var i = 0; i < count; i++) + { + if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase)) + break; + + result++; + } + + return result; + } +} diff --git a/NResUI/Rendering/Viewport/Msh/MshRestPoseBuilder.cs b/NResUI/Rendering/Viewport/Msh/MshRestPoseBuilder.cs index 24a1619..003c345 100644 --- a/NResUI/Rendering/Viewport/Msh/MshRestPoseBuilder.cs +++ b/NResUI/Rendering/Viewport/Msh/MshRestPoseBuilder.cs @@ -9,6 +9,10 @@ namespace NResUI.Rendering.Viewport.Msh; public static class MshRestPoseBuilder { + /// + /// Собирает bind/rest pose для pieces из fallback keyframes 0x08. + /// Циклы и битые parent-ссылки не должны ломать viewport, поэтому такие узлы остаются с identity transform. + /// public static IReadOnlyList BuildRestPose(Msh0x01.Msh0x01Component nodesComponent, List animationDescriptors) { var nodeList = nodesComponent.Nodes; @@ -54,15 +58,12 @@ public static class MshRestPoseBuilder else { localTransform = Matrix4x4.Identity; - - Console.WriteLine($"Node {nodeIndex} has no fallback"); } if (parentIndex == -1) { + // Root nodes describe object placement in game space; the viewer keeps the model centered. localTransform.Translation = Vector3.Zero; - - Console.WriteLine($"Node {nodeIndex} has no parent"); } var meshSpaceTransform = localTransform; @@ -86,15 +87,7 @@ public static class MshRestPoseBuilder private static int GetParentIndex(Msh0x01.Node node) { - try - { - return Convert.ToInt32(node.ParentIndexOrLink); - } - catch - { - var rawParent = Convert.ToUInt16(node.ParentIndexOrLink); - return rawParent == ushort.MaxValue ? -1 : rawParent; - } + return node.ParentIndexOrLink == ushort.MaxValue ? -1 : node.ParentIndexOrLink; } private static int GetFallbackKeyframeIndex(Msh0x01.Node node) diff --git a/NResUI/Rendering/Viewport/Msh/MshViewportLoadResult.cs b/NResUI/Rendering/Viewport/Msh/MshViewportLoadResult.cs deleted file mode 100644 index 8177542..0000000 --- a/NResUI/Rendering/Viewport/Msh/MshViewportLoadResult.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace NResUI.Rendering.Viewport.Msh; - -public sealed class MshViewportLoadResult -{ - public bool IsSuccess { get; } - public string? Error { get; } - public string? SourcePath { get; } - public IReadOnlyList Pieces { get; } - - private MshViewportLoadResult( - bool isSuccess, - string? error, - string? sourcePath, - IReadOnlyList pieces) - { - IsSuccess = isSuccess; - Error = error; - SourcePath = sourcePath; - Pieces = pieces; - } - - public static MshViewportLoadResult Success(string sourcePath, IReadOnlyList pieces) - { - return new MshViewportLoadResult(true, null, sourcePath, pieces); - } - - public static MshViewportLoadResult Failure(string error, string? sourcePath = null) - { - return new MshViewportLoadResult(false, error, sourcePath, Array.Empty()); - } -} diff --git a/NResUI/Rendering/Viewport/Msh/MshViewportLoader.cs b/NResUI/Rendering/Viewport/Msh/MshViewportLoader.cs deleted file mode 100644 index 2110dad..0000000 --- a/NResUI/Rendering/Viewport/Msh/MshViewportLoader.cs +++ /dev/null @@ -1,294 +0,0 @@ -using System.Numerics; -using MshLib; -using NResLib; -using NResUI.Abstractions; -using NResUI.Rendering.Viewport.Meshes; -using Silk.NET.OpenGL; - -namespace NResUI.Rendering.Viewport.Msh; - -public static class MshViewportLoader -{ - private const int DefaultModelState = 0; - private const int DefaultLod = 0; - - public static MshViewportLoadResult LoadFromFile(GL gl, string path, IConfigProvider configProvider) - { - try - { - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); - var parseResult = NResParser.ReadFile(fs); - if (parseResult.Archive == null) - return MshViewportLoadResult.Failure(parseResult.Error ?? "Failed to parse MSH/NRes file.", path); - - var archive = parseResult.Archive; - var meshType = MshConverter.DetectMeshType(archive); - if (meshType != MshType.Model) - { - return MshViewportLoadResult.Failure( - $"Only normal MSH models are supported for this viewport pass. Detected: {meshType}.", - path); - } - - fs.Seek(0, SeekOrigin.Begin); - var pieces = LoadModelPieces(gl, fs, archive, path, configProvider); - if (pieces.Count == 0) - return MshViewportLoadResult.Failure("MSH was parsed, but no renderable geometry pieces were found.", path); - - return MshViewportLoadResult.Success(path, pieces); - } - catch (Exception ex) - { - return MshViewportLoadResult.Failure(ex.Message, path); - } - } - - private static List LoadModelPieces( - GL gl, FileStream fs, NResArchive archive, string path, IConfigProvider configProvider - ) - { - var nodes = Msh0x01.ReadComponent(fs, archive); - var geometry = Msh0x02.ReadComponent(fs, archive); - var positions = Msh0x03.ReadComponent(fs, archive); - var uvs = TryReadUvs(fs, archive); - var indices = Msh0x06.ReadComponent(fs, archive); - var batches = Msh0x0D.ReadComponent(fs, archive); - var animationDescriptors = Msh0x08.ReadComponent(fs, archive); - var restPoses = MshRestPoseBuilder.BuildRestPose(nodes, animationDescriptors); - var mshToViewportTransform = Matrix4x4.CreateRotationX(-MathF.PI * 0.5f); - var names = TryReadNames(fs, archive); - var materialLibrary = WeaMaterialLibrary.TryLoadForMsh(gl, path, configProvider); - - var pieces = new List(); - - for (var nodeIndex = 0; nodeIndex < nodes.Nodes.Count; nodeIndex++) - { - var node = nodes.Nodes[nodeIndex]; - var restPose = restPoses[nodeIndex]; - var slotIndex = node.ResolveSlotIndex(DefaultModelState, DefaultLod); - if (slotIndex == ushort.MaxValue) - continue; - - if (slotIndex >= geometry.Slots.Count) - continue; - - var slot = geometry.Slots[slotIndex]; - if (!slot.HasBatches) - continue; - - var meshBuildResult = BuildPieceMeshes(gl, nodeIndex, slot, positions, uvs, indices, batches, materialLibrary); - if (meshBuildResult == null) - continue; - - var name = ResolvePieceName(names, nodeIndex); - pieces.Add(new ViewportPiece( - id: nodeIndex, - name: name, - meshes: meshBuildResult.Meshes, - localTransform: restPose.MeshSpaceTransform * mshToViewportTransform, - boundsMin: meshBuildResult.BoundsMin, - boundsMax: meshBuildResult.BoundsMax, - debugInfo: new ViewportPieceDebugInfo - { - SourceKind = "MSH 0x01 piece", - SourcePieceIndex = nodeIndex, - SourceParentIndex = restPose.ParentIndex, - GeometrySlotIndex = slotIndex, - Msh01Flags = (uint)node.Flags, - BatchCount = meshBuildResult.BatchCount, - TriangleCount = meshBuildResult.TriangleCount, - FallbackKeyframeIndex = restPose.FallbackKeyframeIndex, - HasRestPose = restPose.HasFallbackPose - })); - } - - return pieces; - } - - private static PieceMeshBuildResult? BuildPieceMeshes( - GL gl, - int nodeIndex, - Msh0x02.GeometrySlot slot, - IReadOnlyList positions, - IReadOnlyList uvs, - IReadOnlyList indices, - IReadOnlyList batches, - WeaMaterialLibrary materialLibrary) - { - var meshes = new List(); - var debugColor = PickDebugColor(nodeIndex); - - var batchCount = 0; - var triangleCount = 0; - - var boundsMin = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity); - var boundsMax = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity); - - for (var batchIndex = slot.BatchStart0x0D; batchIndex < slot.BatchEndExclusive0x0D; batchIndex++) - { - if (batchIndex < 0 || batchIndex >= batches.Count) - continue; - - var batch = batches[batchIndex]; - if (batch.IndexStart0x06 + batch.IndexCount0x06 > indices.Count) - continue; - - var vertices = new List(); - var outIndices = new List(); - var batchTriangleCount = 0; - - var material = materialLibrary.FindMaterial(batch.MaterialIndexLo) ?? ViewportMaterial.Untextured; - var vertexColor = material.HasTexture ? Vector3.One : debugColor; - - for (var i = 0; i + 2 < batch.IndexCount0x06; i += 3) - { - var indexBase = (int)batch.IndexStart0x06 + i; - var vertexIndex0 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 0]); - var vertexIndex1 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 1]); - var vertexIndex2 = checked((int)batch.BaseVertex0x03 + indices[indexBase + 2]); - - if (!IsValidTriangle(positions.Count, vertexIndex0, vertexIndex1, vertexIndex2)) - continue; - - var p0 = ToNumericsVector3(positions[vertexIndex0]); - var p1 = ToNumericsVector3(positions[vertexIndex1]); - var p2 = ToNumericsVector3(positions[vertexIndex2]); - - var normal = Vector3.Cross(p1 - p0, p2 - p0); - if (normal.LengthSquared() < 1e-8f) - normal = Vector3.UnitY; - else - normal = Vector3.Normalize(normal); - - AddTriangleVertex(p0, vertexColor, normal, GetUv(uvs, vertexIndex0), vertices, outIndices, ref boundsMin, ref boundsMax); - AddTriangleVertex(p1, vertexColor, normal, GetUv(uvs, vertexIndex1), vertices, outIndices, ref boundsMin, ref boundsMax); - AddTriangleVertex(p2, vertexColor, normal, GetUv(uvs, vertexIndex2), vertices, outIndices, ref boundsMin, ref boundsMax); - batchTriangleCount++; - } - - if (batchTriangleCount == 0) - continue; - - batchCount++; - triangleCount += batchTriangleCount; - meshes.Add(PrimitiveMeshes.CreateColoredIndexedMesh(gl, vertices, outIndices, PrimitiveType.Triangles, material)); - } - - if (triangleCount == 0 || meshes.Count == 0) - return null; - - return new PieceMeshBuildResult(meshes, boundsMin, boundsMax, batchCount, triangleCount); - } - - private static Vector2 GetUv(IReadOnlyList uvs, int vertexIndex) - { - if (vertexIndex < 0 || vertexIndex >= uvs.Count) - return Vector2.Zero; - - var uv = uvs[vertexIndex]; - return new Vector2(uv.U / 1024.0f, uv.V / 1024.0f); - } - - private static Vector3 ToNumericsVector3(Common.Vector3 position) - { - return new Vector3(position.X, position.Y, position.Z); - } - - private static void AddTriangleVertex( - Vector3 position, - Vector3 color, - Vector3 normal, - Vector2 uv, - List vertices, - List indices, - ref Vector3 boundsMin, - ref Vector3 boundsMax) - { - var vertexIndex = (uint)(vertices.Count / PrimitiveMeshes.FloatsPerVertex); - - vertices.Add(position.X); - vertices.Add(position.Y); - vertices.Add(position.Z); - vertices.Add(color.X); - vertices.Add(color.Y); - vertices.Add(color.Z); - vertices.Add(normal.X); - vertices.Add(normal.Y); - vertices.Add(normal.Z); - vertices.Add(uv.X); - vertices.Add(uv.Y); - - indices.Add(vertexIndex); - - boundsMin = Vector3.Min(boundsMin, position); - boundsMax = Vector3.Max(boundsMax, position); - } - - private static bool IsValidTriangle(int vertexCount, int vertexIndex0, int vertexIndex1, int vertexIndex2) - { - return IsValidVertexIndex(vertexCount, vertexIndex0) && - IsValidVertexIndex(vertexCount, vertexIndex1) && - IsValidVertexIndex(vertexCount, vertexIndex2); - } - - private static bool IsValidVertexIndex(int vertexCount, int vertexIndex) - { - return vertexIndex >= 0 && vertexIndex < vertexCount; - } - - private static List TryReadNames(FileStream fs, NResArchive archive) - { - try - { - return Msh0x0A.ReadComponent(fs, archive); - } - catch - { - return new List(); - } - } - - private static List TryReadUvs(FileStream fs, NResArchive archive) - { - try - { - return Msh0x05.ReadComponent(fs, archive); - } - catch - { - return new List(); - } - } - - private static string ResolvePieceName(IReadOnlyList names, int nodeIndex) - { - if (nodeIndex >= 0 && nodeIndex < names.Count && !string.IsNullOrWhiteSpace(names[nodeIndex])) - return $"{nodeIndex}: {names[nodeIndex]}"; - - return $"{nodeIndex}: piece_{nodeIndex:D3}"; - } - - private static Vector3 PickDebugColor(int index) - { - ReadOnlySpan palette = - [ - new Vector3(0.90f, 0.36f, 0.30f), - new Vector3(0.30f, 0.68f, 0.95f), - new Vector3(0.42f, 0.82f, 0.42f), - new Vector3(0.95f, 0.78f, 0.32f), - new Vector3(0.78f, 0.48f, 0.95f), - new Vector3(0.36f, 0.86f, 0.78f), - new Vector3(0.95f, 0.55f, 0.78f), - new Vector3(0.72f, 0.72f, 0.72f), - ]; - - return palette[index % palette.Length]; - } - - private sealed record PieceMeshBuildResult( - IReadOnlyList Meshes, - Vector3 BoundsMin, - Vector3 BoundsMax, - int BatchCount, - int TriangleCount); -} diff --git a/NResUI/Rendering/Viewport/Msh/WeaMaterialLibrary.cs b/NResUI/Rendering/Viewport/Msh/WeaMaterialLibrary.cs deleted file mode 100644 index 4b57318..0000000 --- a/NResUI/Rendering/Viewport/Msh/WeaMaterialLibrary.cs +++ /dev/null @@ -1,284 +0,0 @@ -using MaterialLib; -using NResLib; -using NResUI.Abstractions; -using NResUI.Rendering.Viewport.OpenGL; -using Silk.NET.OpenGL; -using TexmLib; - -namespace NResUI.Rendering.Viewport.Msh; - -public sealed class WeaMaterialLibrary -{ - private readonly Dictionary _materialsById; - - private WeaMaterialLibrary(Dictionary materialsById) - { - _materialsById = materialsById; - } - - public static WeaMaterialLibrary Empty { get; } = new(new Dictionary()); - - public ViewportMaterial? FindMaterial(int id) - { - return _materialsById.TryGetValue(id, out var material) - ? material - : null; - } - - public static WeaMaterialLibrary TryLoadForMsh(GL gl, string mshPath, IConfigProvider configProvider) - { - var weaPath = FindMatchingWeaPath(mshPath); - if (!File.Exists(weaPath)) - return Empty; - - var materialRefs = ParseMaterialRefs(weaPath); - if (materialRefs.Count == 0) - return Empty; - - var materialLibFs = Path.Combine(configProvider.GetConfig().GameBasePath, "Material.lib"); - - if (!File.Exists(materialLibFs)) - { - return new WeaMaterialLibrary([]); - } - - using var materialFs = new FileStream(materialLibFs, FileMode.Open, FileAccess.Read, FileShare.Read); - var parseResult = NResParser.ReadFile(materialFs); - if (parseResult.Archive == null) - return Empty; - - var result = new Dictionary(); - - foreach (var materialRef in materialRefs) - { - var texture = TryLoadMaterialTexture(gl, materialFs, parseResult.Archive, materialRef.Name, configProvider, out var texm); - result[materialRef.Id] = texture != null - ? new ViewportMaterial(materialRef.Name, texture.Value, texm) - : new ViewportMaterial(materialRef.Name); - } - - return new WeaMaterialLibrary(result); - } - - private static string? FindMatchingWeaPath(string mshPath) - { - var directory = Path.GetDirectoryName(mshPath); - if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory)) - return null; - - var mshResourceName = GetExportedResourceNameWithoutExtension(mshPath); - var mshObjectStem = ExtractObjectStem(mshResourceName); - - var candidates = Directory.EnumerateFiles(directory, "*.wea", SearchOption.TopDirectoryOnly) - .Select(path => new - { - Path = path, - ResourceName = GetExportedResourceNameWithoutExtension(path), - }) - .Select(x => new - { - x.Path, - x.ResourceName, - ObjectStem = ExtractObjectStem(x.ResourceName), - }) - .ToList(); - - // Best case: exact resource stem match after type prefix normalization. - var exact = candidates.FirstOrDefault(x => string.Equals(x.ObjectStem, mshObjectStem, - StringComparison.OrdinalIgnoreCase)); - - if (exact != null) - return exact.Path; - - // Fallback: choose a WEA whose resource name shares the longest token prefix. - var best = candidates - .Select(x => new - { - x.Path, - Score = CommonPrefixTokenCount( - SplitResourceTokens(mshObjectStem), - SplitResourceTokens(x.ObjectStem)) - }) - .OrderByDescending(x => x.Score) - .FirstOrDefault(); - - return best?.Score > 0 ? best.Path : null; - } - - private static string GetExportedResourceNameWithoutExtension(string path) - { - var name = Path.GetFileNameWithoutExtension(path); - - // Exported files are usually like: - // 58_MESH_o_tur_ba_06 - // 81_WEAR_o_tur_ba_02 - // - // Strip numeric export prefix. - var firstUnderscore = name.IndexOf('_'); - if (firstUnderscore > 0 && - name[..firstUnderscore].All(char.IsDigit)) - { - name = name[(firstUnderscore + 1)..]; - } - - return name; - } - - private static string ExtractObjectStem(string resourceName) - { - // Normalize known resource kind prefixes. - // MESH_o_tur_ba_06 -> o_tur_ba - // WEAR_o_tur_ba_02 -> o_tur_ba - var normalized = resourceName; - - foreach (var prefix in new[] { "MESH_", "WEAR_", "TEXT_", "ANIM_" }) - { - if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - normalized = normalized[prefix.Length..]; - break; - } - } - - var tokens = normalized.Split('_', StringSplitOptions.RemoveEmptyEntries); - - return string.Join('_', tokens); - } - - private static string[] SplitResourceTokens(string value) - { - return value.Split('_', StringSplitOptions.RemoveEmptyEntries); - } - - private static int CommonPrefixTokenCount(string[] a, string[] b) - { - var count = Math.Min(a.Length, b.Length); - var result = 0; - - for (var i = 0; i < count; i++) - { - if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase)) - break; - - result++; - } - - return result; - } - - private static IReadOnlyList ParseMaterialRefs(string weaPath) - { - var lines = File.ReadAllLines(weaPath) - .Select(x => x.Trim()) - .Where(x => x.Length != 0) - .ToList(); - - if (lines.Count == 0 || !int.TryParse(lines[0], out var materialCount) || materialCount <= 0) - return []; - - var result = new List(); - - for (var i = 0; i < materialCount && i + 1 < lines.Count; i++) - { - var line = lines[i + 1]; - if (line.Equals("LIGHTMAPS", StringComparison.OrdinalIgnoreCase)) - break; - - var parts = line.Split((char[]?)null, 2, - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length < 2) - continue; - - if (!int.TryParse(parts[0], out var id)) - continue; - - result.Add(new WeaMaterialRef(id, parts[1])); - } - - return result; - } - - private static uint? TryLoadMaterialTexture( - GL gl, FileStream materialFs, NResArchive materialArchive, string materialName, IConfigProvider configProvider, - out TexmFile? texm - ) - { - texm = null; - var entry = FindMaterialEntry(materialArchive, materialName); - if (entry == null) - return null; - - materialFs.Seek(entry.OffsetInFile, SeekOrigin.Begin); - var materialData = new byte[entry.FileLength]; - materialFs.ReadExactly(materialData, 0, materialData.Length); - - using var ms = new MemoryStream(materialData, writable: false); - - var materialFile = MaterialParser.ReadFromStream(ms, materialName, entry.ElementCount, entry.Magic1); - - var texture = materialFile.Stages[0].TextureName; - - var textureLibFs = Path.Combine(configProvider.GetConfig().GameBasePath, "Textures.lib"); - - if (!File.Exists(textureLibFs)) - { - return null; - } - - using var textureFs = new FileStream(textureLibFs, FileMode.Open, FileAccess.Read, FileShare.Read); - var textureResult = NResParser.ReadFile(textureFs); - if (textureResult.Archive == null) - return 0; - - var textureEntry = FindTextureEntry(textureResult.Archive, texture); - if (textureEntry is null) - { - Console.WriteLine($"Didnt find texture {texture}"); - return 0; - } - - textureFs.Seek(textureEntry.OffsetInFile, SeekOrigin.Begin); - var textureData = new byte[textureEntry.FileLength]; - textureFs.ReadExactly(textureData, 0, textureData.Length); - - using var textureMs = new MemoryStream(textureData, writable: false); - - var texmResult = TexmParser.ReadFromStream(textureMs, entry.FileName); - if (texmResult.TexmFile == null) - return null; - - texm = texmResult.TexmFile; - var rgba = texmResult.TexmFile.GetRgba32BytesFromMipmap(0, out var width, out var height); - return ViewportTextureLoader.CreateRgbaTexture(gl, rgba, width, height); - } - - private static ListMetadataItem? FindMaterialEntry(NResArchive materialArchive, string materialName) - { - static string Normalize(string value) - { - return value.Trim().ToLowerInvariant(); - } - - var normalized = Normalize(materialName); - - return materialArchive.Files.FirstOrDefault(x => - string.Equals(x.FileName, materialName, StringComparison.OrdinalIgnoreCase) || - Normalize(x.FileName) == normalized); - } - - private static ListMetadataItem? FindTextureEntry(NResArchive archive, string name) - { - static string Normalize(string value) - { - return value.Trim().ToLowerInvariant(); - } - - var normalized = Normalize(name); - - return archive.Files.FirstOrDefault(x => - string.Equals(x.FileName, name, StringComparison.OrdinalIgnoreCase) || - Normalize(x.FileName) == normalized); - } - - private readonly record struct WeaMaterialRef(int Id, string Name); -} diff --git a/NResUI/Rendering/Viewport/ViewportPiece.cs b/NResUI/Rendering/Viewport/ViewportPiece.cs index 6b06f70..e6b2fbb 100644 --- a/NResUI/Rendering/Viewport/ViewportPiece.cs +++ b/NResUI/Rendering/Viewport/ViewportPiece.cs @@ -17,6 +17,7 @@ public sealed class ViewportPiece public Vector3 BoundsMax { get; } public ViewportPieceDebugInfo? DebugInfo { get; } + public IReadOnlyList SourceBatchIndices { get; } public ViewportPiece( int id, @@ -25,8 +26,9 @@ public sealed class ViewportPiece Matrix4x4 localTransform, Vector3 boundsMin, Vector3 boundsMax, - ViewportPieceDebugInfo? debugInfo = null) - : this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo) + ViewportPieceDebugInfo? debugInfo = null, + IReadOnlyList? sourceBatchIndices = null) + : this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo, sourceBatchIndices) { } @@ -37,7 +39,8 @@ public sealed class ViewportPiece Matrix4x4 localTransform, Vector3 boundsMin, Vector3 boundsMax, - ViewportPieceDebugInfo? debugInfo = null) + ViewportPieceDebugInfo? debugInfo = null, + IReadOnlyList? sourceBatchIndices = null) { if (meshes.Count == 0) throw new ArgumentException("A viewport piece must contain at least one mesh.", nameof(meshes)); @@ -49,6 +52,7 @@ public sealed class ViewportPiece BoundsMin = boundsMin; BoundsMax = boundsMax; DebugInfo = debugInfo; + SourceBatchIndices = sourceBatchIndices ?? []; } public static ViewportPiece CreateUnitCube(int id, string name, GpuMesh mesh) diff --git a/ParkanPlayground.slnx b/ParkanPlayground.slnx index 76167bb..4527277 100644 --- a/ParkanPlayground.slnx +++ b/ParkanPlayground.slnx @@ -21,4 +21,5 @@ - \ No newline at end of file + + diff --git a/ParkanPlayground/ParkanPlayground.csproj b/ParkanPlayground/ParkanPlayground.csproj index a8b924d..73d5dd9 100644 --- a/ParkanPlayground/ParkanPlayground.csproj +++ b/ParkanPlayground/ParkanPlayground.csproj @@ -15,6 +15,7 @@ + diff --git a/ParkanPlayground/Program.cs b/ParkanPlayground/Program.cs index d1eba72..44eb1f9 100644 --- a/ParkanPlayground/Program.cs +++ b/ParkanPlayground/Program.cs @@ -1,13 +1,16 @@ +using System.Text; using System.Text.Json; using Common; using ControlLib; using MshLib; +using NResLib; +using WeaLib; System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); // -var ctl = ControlResourceReader.ReadCtlFile("E:\\ParkanUnpacked\\bases.rlb\\2_CTLD_r_l_03.ctl"); -var cpt = ControlResourceReader.ReadCptFile("E:\\ParkanUnpacked\\bases.rlb\\18_CTPT_R_L_03.CPT"); -var ndp = ControlResourceReader.ReadNdpFile("E:\\ParkanUnpacked\\bases.rlb\\14_NDPR_r_l_03.ndp"); +// var ctl = ControlResourceReader.ReadCtlFile("E:\\ParkanUnpacked\\bases.rlb\\2_CTLD_r_l_03.ctl"); +// var cpt = ControlResourceReader.ReadCptFile("E:\\ParkanUnpacked\\bases.rlb\\18_CTPT_R_L_03.CPT"); +// var ndp = ControlResourceReader.ReadNdpFile("E:\\ParkanUnpacked\\bases.rlb\\14_NDPR_r_l_03.ndp"); // var ctl = ControlResourceReader.ReadCtlFile("E:\\ParkanUnpacked\\turrets.rlb\\30_CTLD_o_tur_lt_02.ctl"); // var cpt = ControlResourceReader.ReadCptFile("E:\\ParkanUnpacked\\turrets.rlb\\162_CTPT_o_tur_lt_02.cpt"); @@ -17,7 +20,72 @@ var ndp = ControlResourceReader.ReadNdpFile("E:\\ParkanUnpacked\\bases.rlb\\14_N // var cpt = ControlResourceReader.ReadCptFile("E:\\ParkanUnpacked\\guns.rlb\\364_CTPT_o_gun_la_01.cpt"); // var ndp = ControlResourceReader.ReadNdpFile("E:\\ParkanUnpacked\\guns.rlb\\309_NDPR_o_gun_la_01.ndp"); -ControlResourceDump.DumpAll(ctl, cpt, ndp, Console.Out); +// ControlResourceDump.DumpAll(ctl, cpt, ndp, Console.Out); + +List allNres = +[ + "animals.rlb", + "bases.rlb", + "behpsp.res", + "effects.rlb", + "fortif.rlb", + // "gamefont.rlb", // NL + "guns.rlb", + "intsys.rlb", + "lightmap.lib", + "Material.lib", + "objects.dlb", + "objects.rlb", + "Palettes.lib", + "parts.rlb", + "static.rlb", + "sys.lib", + "system.rlb", + "Textures.lib", + "turrets.rlb", + "voices.lib", + "weapon.rlb" +]; + +// CONFIRMED: +// all .msh have .wea in the same archive with the same name, so no cross-referencing + +foreach (var nres in allNres) +{ + var path = Path.Combine("C:\\IronStrategy", nres); + + using var nResFs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + var archive = NResParser.ReadFile(nResFs).Archive!; + + foreach (var archiveFile in archive.Files) + { + if (archiveFile.FileType != "MESH") continue; + + // Console.WriteLine($"Found mesh {archiveFile.FileName} in {nres}"); + + var entry = Path.GetFileNameWithoutExtension(archiveFile.FileName); + var weaEntry = archive.Files.FirstOrDefault(x => x.FileType == "WEAR" && + string.Equals(x.FileName, $"{entry}.wea", + StringComparison.OrdinalIgnoreCase)); + if (weaEntry is null) + { + Console.WriteLine($"Mesh {archiveFile.FileName} in {nres} has no WEA"); + continue; + } + + // Console.WriteLine($"Has matching wear {weaEntry.FileName}"); + + var weaData = new byte[weaEntry.FileLength]; + + nResFs.Seek(weaEntry.OffsetInFile, SeekOrigin.Begin); + nResFs.ReadExactly(weaData, 0, weaData.Length); + + var weaContent = Encoding.UTF8.GetString(weaData); + var wea = WeaParser.ReadText(weaContent); + + _ = 5; + } +} // foreach (var item in ctl.ItemRecords) // { @@ -25,4 +93,4 @@ ControlResourceDump.DumpAll(ctl, cpt, ndp, Console.Out); // $"0x{item.Offset:X4}-0x{item.EndOffset:X4} " + // $"type={item.ItemType:X} piece={item.LocalPieceIndex} " + // $"name='{item.ItemName}' bindings=[{string.Join(", ", item.PieceBindingIndices)}]"); -// } \ No newline at end of file +// } diff --git a/WeaLib/WeaFile.cs b/WeaLib/WeaFile.cs new file mode 100644 index 0000000..fccc19f --- /dev/null +++ b/WeaLib/WeaFile.cs @@ -0,0 +1,18 @@ +namespace WeaLib; + +/// +/// Parsed `.wea` material table. The game loads this separately from MSH/CTL data. +/// +/// Source file name or path supplied by the caller. +/// Material id-to-name rows before the optional LIGHTMAPS section. +/// Optional lightmap id-to-name rows after the LIGHTMAPS marker. +public sealed record WeaFile( + string FileName, + IReadOnlyList Materials, + IReadOnlyList Lightmaps); + +/// Material row from a `.wea` file: `{id} {material_name}`. +public readonly record struct WeaMaterialRef(int Id, string Name); + +/// Lightmap row from the optional `.wea` LIGHTMAPS section. +public readonly record struct WeaLightmapRef(int Id, string Name); diff --git a/WeaLib/WeaLib.csproj b/WeaLib/WeaLib.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/WeaLib/WeaLib.csproj @@ -0,0 +1 @@ + diff --git a/WeaLib/WeaParseResult.cs b/WeaLib/WeaParseResult.cs new file mode 100644 index 0000000..72c92cc --- /dev/null +++ b/WeaLib/WeaParseResult.cs @@ -0,0 +1,6 @@ +namespace WeaLib; + +public sealed record WeaParseResult(WeaFile? WeaFile = null, string? Error = null) +{ + public bool IsSuccess => WeaFile != null; +} diff --git a/WeaLib/WeaParser.cs b/WeaLib/WeaParser.cs new file mode 100644 index 0000000..cbe8eaf --- /dev/null +++ b/WeaLib/WeaParser.cs @@ -0,0 +1,117 @@ +namespace WeaLib; + +/// +/// Парсер текстовых `.wea` таблиц. Библиотека не знает про Material.lib, Textures.lib и OpenGL: +/// она только возвращает id/name строки из файла. +/// +public static class WeaParser +{ + /// Читает `.wea` с диска. Ошибки IO возвращаются как WeaParseResult.Error. + public static WeaParseResult ReadFile(string path) + { + try + { + return ReadLines(File.ReadLines(path), path); + } + catch (Exception ex) + { + return new WeaParseResult(Error: ex.Message); + } + } + + /// Читает `.wea` из stream, оставляя stream открытым для вызывающего кода. + public static WeaParseResult ReadFromStream(Stream stream, string fileName) + { + using var reader = new StreamReader(stream, leaveOpen: true); + return ReadLines(ReadAllLines(reader), fileName); + } + + /// Удобный вход для тестов и анализа маленьких WEA-фрагментов. + public static WeaParseResult ReadText(string text, string fileName = "") + { + return ReadLines(text.Split(["\r\n", "\n"], StringSplitOptions.None), fileName); + } + + private static WeaParseResult ReadLines(IEnumerable rawLines, string fileName) + { + // Пустые строки в реальных WEA не несут смысла, поэтому игнорируем их до валидации counts. + var lines = rawLines + .Select(x => x.Trim()) + .Where(x => x.Length != 0) + .ToList(); + + if (lines.Count == 0) + return new WeaParseResult(Error: "WEA file is empty."); + + if (!int.TryParse(lines[0], out var materialCount) || materialCount < 0) + return new WeaParseResult(Error: "WEA material count is missing or invalid."); + + var materials = new List(materialCount); + var cursor = 1; + + for (var i = 0; i < materialCount; i++, cursor++) + { + if (cursor >= lines.Count) + return new WeaParseResult(Error: $"WEA expected {materialCount} material rows, but found {i}."); + + if (IsLightmapsMarker(lines[cursor])) + return new WeaParseResult(Error: $"WEA expected material row {i}, but found LIGHTMAPS."); + + if (!TryParseRef(lines[cursor], out var id, out var name)) + return new WeaParseResult(Error: $"WEA material row {i} is malformed: '{lines[cursor]}'."); + + materials.Add(new WeaMaterialRef(id, name)); + } + + var lightmaps = new List(); + if (cursor < lines.Count) + { + if (!IsLightmapsMarker(lines[cursor])) + return new WeaParseResult(Error: $"WEA unexpected content after material rows: '{lines[cursor]}'."); + + // LIGHTMAPS — отдельная секция со своим count; без count секция считается битой. + cursor++; + if (cursor >= lines.Count || !int.TryParse(lines[cursor], out var lightmapCount) || lightmapCount < 0) + return new WeaParseResult(Error: "WEA LIGHTMAPS count is missing or invalid."); + + cursor++; + for (var i = 0; i < lightmapCount; i++, cursor++) + { + if (cursor >= lines.Count) + return new WeaParseResult(Error: $"WEA expected {lightmapCount} lightmap rows, but found {i}."); + + if (!TryParseRef(lines[cursor], out var id, out var name)) + return new WeaParseResult(Error: $"WEA lightmap row {i} is malformed: '{lines[cursor]}'."); + + lightmaps.Add(new WeaLightmapRef(id, name)); + } + } + + return new WeaParseResult(new WeaFile(fileName, materials, lightmaps)); + } + + private static IEnumerable ReadAllLines(TextReader reader) + { + string? line; + while ((line = reader.ReadLine()) != null) + yield return line; + } + + private static bool IsLightmapsMarker(string line) + { + return line.Equals("LIGHTMAPS", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryParseRef(string line, out int id, out string name) + { + id = 0; + name = string.Empty; + + var parts = line.Split((char[]?)null, 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length != 2 || !int.TryParse(parts[0], out id)) + return false; + + name = parts[1]; + return name.Length != 0; + } +}