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;
+ }
+}