vibecoded inspector

This commit is contained in:
bird_egop
2026-06-09 01:46:57 +03:00
parent 70a5d0ef69
commit 325970374a
25 changed files with 1944 additions and 649 deletions
+3 -2
View File
@@ -95,7 +95,8 @@ public static class Msh0x01
/// <param name="FallbackKey0x08">[0x06..0x08] Fallback key / index в MSH 0x08.</param>
/// <param name="Msh02SlotIndicesByStateAndLOD">
/// [0x08..0x26] Индексы geometry slot в MSH 0x02.
/// Формула: slot = lod * 5 + group. 0xFFFF значит отсутствует.
/// Формула индекса в этой таблице: index = state * MaxLodCount + lod.
/// 0xFFFF значит, что для этой пары state/LOD геометрии нет.
/// </param>
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
+1
View File
@@ -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();
+490
View File
@@ -0,0 +1,490 @@
using System.Numerics;
using ImGuiNET;
using NResUI.Abstractions;
using NResUI.Models;
using NResUI.Rendering.Inspection;
namespace NResUI.ImGuiUI;
/// <summary>
/// Инспектор CPU-документа меша. Он не создает OpenGL-ресурсы сам, а только меняет MeshRenderState,
/// после чего ViewportPanel перестраивает видимую геометрию.
/// </summary>
public sealed class MeshInspectorPanel : IImGuiPanel
{
private readonly MeshViewportViewModel _viewModel;
private readonly HashSet<int> _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<int, List<MeshPieceInfo>> 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<int> GetAvailablePieceStates(MeshDocument document, MeshPieceInfo piece, int lod)
{
return Enumerable.Range(0, document.ModelStateCount)
.Where(state => HasSlot(document, piece, state, lod))
.ToList();
}
private static IReadOnlyList<int> 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<int> values,
Func<int, string> 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);
}
}
+60 -15
View File
@@ -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<string> 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"))
+58
View File
@@ -0,0 +1,58 @@
using NResUI.Rendering.Inspection;
namespace NResUI.Models;
/// <summary>
/// Общее состояние mesh viewport и inspector.
/// Панели синхронизируются через эту модель, чтобы выбор piece/batch не жил отдельно в каждом окне.
/// </summary>
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;
/// <summary>Заменяет текущий документ и сбрасывает все viewport-only overrides.</summary>
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;
}
}
+1
View File
@@ -27,6 +27,7 @@
<ProjectReference Include="..\TexmLib\TexmLib.csproj" />
<ProjectReference Include="..\VarsetLib\VarsetLib.csproj" />
<ProjectReference Include="..\MaterialLib\MaterialLib.csproj" />
<ProjectReference Include="..\WeaLib\WeaLib.csproj" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// Читает MSH/NRes с диска и строит CPU-документ для инспектора.
/// OpenGL здесь намеренно не используется: этот слой должен быть проверяемым без viewport.
/// </summary>
public static MeshDocumentLoadResult LoadFromFile(string path)
{
var warnings = new List<string>();
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<string> 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<int, string>();
var pieces = new List<MeshPieceInfo>(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
}
};
}
/// <summary>
/// Собирает batch metadata для всех state/LOD слотов piece.
/// Дубликаты batch убираются, потому что разные состояния могут ссылаться на один и тот же geometry slot.
/// </summary>
private static IReadOnlyList<MeshBatchInfo> BuildPieceBatchInfo(
Msh0x01.Node node,
Msh0x02.Msh0x02Component geometry,
IReadOnlyList<Msh0x0D.Batch> batches,
int indexCount,
IReadOnlyDictionary<int, string> materialNamesById,
ICollection<string> documentWarnings)
{
var result = new List<MeshBatchInfo>();
var seenBatchIndices = new HashSet<int>();
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<string>();
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<string> TryReadNames(FileStream fs, NResArchive archive, ICollection<string> warnings)
{
try
{
return Msh0x0A.ReadComponent(fs, archive);
}
catch (Exception ex)
{
warnings.Add($"MSH 0x0A names are unavailable: {ex.Message}");
return [];
}
}
private static List<Msh04Normal> TryReadNormals(FileStream fs, NResArchive archive, ICollection<string> warnings)
{
try
{
return Msh0x04.ReadComponent(fs, archive);
}
catch (Exception ex)
{
warnings.Add($"MSH 0x04 normals are unavailable: {ex.Message}");
return [];
}
}
private static List<Msh05Uv> TryReadUvs(FileStream fs, NResArchive archive, ICollection<string> 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<string> 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);
}
}
@@ -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
{
/// <summary>
/// Строит OpenGL-меши из уже разобранного MeshDocument.
/// State/LOD выбираются отдельно для каждого piece через 0x01, а не через общий список geometry slots.
/// </summary>
public static IReadOnlyList<ViewportPiece> BuildViewportPieces(
GL gl,
MeshDocument document,
MeshRenderState renderState,
ViewportMaterialSet materialSet)
{
if (document.MshModelGeometry == null)
return [];
var result = new List<ViewportPiece>();
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<ViewportPiece> 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<GpuMesh>();
var batchIndices = new List<int>();
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<float>();
var outIndices = new List<uint>();
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<Msh04Normal> 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<Msh05Uv> 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<float> vertices,
List<uint> 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<Vector3> 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<GpuMesh> Meshes,
Vector3 BoundsMin,
Vector3 BoundsMax,
int BatchCount,
int TriangleCount,
IReadOnlyList<int> BatchIndices);
}
@@ -0,0 +1,93 @@
using System.Numerics;
using MshLib;
namespace NResUI.Rendering.Inspection;
/// <summary>
/// CPU-документ меша: общая модель данных для viewport и inspector до загрузки в OpenGL.
/// Здесь хранятся ссылки на исходные MSH-компоненты и уже удобные для UI metadata.
/// </summary>
public sealed class MeshDocument
{
/// <summary>Путь к MSH/NRes ресурсу, из которого построен документ.</summary>
public required string SourcePath { get; init; }
/// <summary>Тип MSH по набору компонентов. Сейчас inspector поддерживает только обычный Model.</summary>
public required MshType MeshType { get; init; }
/// <summary>Список pieces из 0x01. Каждый piece сам выбирает slot для state/LOD.</summary>
public IReadOnlyList<MeshPieceInfo> Pieces { get; init; } = [];
/// <summary>Материалы, пришедшие из WEA. Текстуры резолвятся отдельно, потому что это уже UI/OpenGL слой.</summary>
public IReadOnlyList<MeshMaterialInfo> Materials { get; init; } = [];
/// <summary>Нефатальные проблемы загрузки: отсутствующие optional-компоненты, WEA/material/texture misses.</summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
/// <summary>Количество известных model states в 0x01 таблице.</summary>
public int ModelStateCount { get; init; } = Msh0x01.StateCount;
/// <summary>Количество LOD-уровней на каждый state в 0x01 таблице.</summary>
public int LodCount { get; init; } = Msh0x01.MaxLodCount;
/// <summary>Сырые MSH-потоки, нужные для перестройки GPU-мешей при смене state/LOD.</summary>
public MshModelGeometry? MshModelGeometry { get; init; }
}
/// <summary>
/// Информация об одном piece/node из 0x01.
/// Важно: state/LOD не глобальны, поэтому этот объект хранит собственную slot-таблицу.
/// </summary>
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<ushort> GeometrySlotsByStateAndLod { get; init; }
public required IReadOnlyList<MeshBatchInfo> 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; }
/// <summary>
/// Возвращает geometry slot 0x02 для пары state/LOD именно этого piece.
/// 0xFFFF означает, что piece в такой комбинации не должен рендериться.
/// </summary>
public ushort ResolveSlotIndex(int state, int lod)
{
var index = state * Msh0x01.MaxLodCount + lod;
return index >= 0 && index < GeometrySlotsByStateAndLod.Count
? GeometrySlotsByStateAndLod[index]
: ushort.MaxValue;
}
}
/// <summary>
/// Inspector metadata для batch 0x0D. Геометрия остается в MshModelGeometry, здесь только диапазоны и флаги.
/// </summary>
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<string> Warnings { get; init; } = [];
}
/// <summary>Материал из WEA по id. Это еще не ViewportMaterial и не OpenGL texture.</summary>
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;
}
@@ -0,0 +1,71 @@
namespace NResUI.Rendering.Inspection;
/// <summary>
/// Состояние просмотра меша. State/LOD задаются только для конкретных pieces,
/// потому что 0x01 хранит таблицу slot indices отдельно для каждого узла.
/// </summary>
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<int> HiddenPieceIds { get; } = [];
public Dictionary<int, int> PieceStateOverrides { get; } = [];
public Dictionary<int, int> 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;
}
}
@@ -0,0 +1,30 @@
using MshLib;
namespace NResUI.Rendering.Inspection;
/// <summary>
/// Parsed MSH component data retained so state/LOD changes can rebuild GPU meshes without reparsing the file.
/// </summary>
public sealed class MshModelGeometry
{
/// <summary>Таблица узлов 0x01: именно она связывает каждый piece с geometry slot для пары state/LOD.</summary>
public required Msh0x01.Msh0x01Component Nodes { get; init; }
/// <summary>Слоты геометрии 0x02. Один piece выбирает один слот через 0x01.ResolveSlotIndex.</summary>
public required Msh0x02.Msh0x02Component GeometrySlots { get; init; }
/// <summary>Позиции вершин из 0x03.</summary>
public required IReadOnlyList<Common.Vector3> Positions { get; init; }
/// <summary>Нормали из 0x04. Компонент опционален, поэтому фабрика мешей обязана иметь fallback.</summary>
public required IReadOnlyList<Msh04Normal> Normals { get; init; }
/// <summary>UV из 0x05. Компонент опционален для части ресурсов.</summary>
public required IReadOnlyList<Msh05Uv> Uvs { get; init; }
/// <summary>Индексный буфер 0x06 для обычных моделей.</summary>
public required IReadOnlyList<ushort> Indices { get; init; }
/// <summary>Render batches 0x0D: диапазоны индексов, material id и render flags.</summary>
public required IReadOnlyList<Msh0x0D.Batch> Batches { get; init; }
}
@@ -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;
/// <summary>Набор материалов, готовых для viewport. Ключ — material id из WEA/MSH batch.</summary>
public sealed class ViewportMaterialSet
{
private readonly Dictionary<int, ViewportMaterial> _materialsById;
public ViewportMaterialSet(Dictionary<int, ViewportMaterial> materialsById)
{
_materialsById = materialsById;
}
public static ViewportMaterialSet Empty { get; } = new([]);
public ViewportMaterial? FindMaterial(int id)
{
return _materialsById.GetValueOrDefault(id);
}
}
/// <summary>
/// Связывает WEA -> Material.lib -> Textures.lib -> OpenGL texture.
/// Этот слой намеренно остается в NResUI, потому что только UI знает GameBasePath и GL context.
/// </summary>
public static class ViewportMaterialResolver
{
/// <summary>
/// Загружает материалы для MSH. Любая ошибка резолва не фатальна: модель должна остаться видимой без текстур.
/// </summary>
public static ViewportMaterialSet LoadForMsh(
GL gl,
string mshPath,
IConfigProvider configProvider,
ICollection<string>? 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<int, ViewportMaterial>();
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<string>? 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);
}
}
@@ -0,0 +1,147 @@
using WeaLib;
namespace NResUI.Rendering.Materials;
/// <summary>Результат эвристического поиска WEA рядом с MSH.</summary>
public sealed class WeaResourceMatch
{
public required string Path { get; init; }
public required string Reason { get; init; }
public required WeaFile File { get; init; }
}
/// <summary>
/// UI-side resolver для связи MSH-файла с WEA-файлом.
/// Это не часть WeaLib, потому что matching зависит от экспортированных имен и расположения ресурсов на диске.
/// </summary>
public static class WeaResourceResolver
{
/// <summary>
/// Ищет WEA рядом с MSH и сразу парсит его.
/// Если файл не найден или битый, viewport все равно может показать модель без материалов.
/// </summary>
public static WeaResourceMatch? TryLoadMatchingWeaForMsh(string mshPath, ICollection<string>? 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;
}
}
@@ -9,6 +9,10 @@ namespace NResUI.Rendering.Viewport.Msh;
public static class MshRestPoseBuilder
{
/// <summary>
/// Собирает bind/rest pose для pieces из fallback keyframes 0x08.
/// Циклы и битые parent-ссылки не должны ломать viewport, поэтому такие узлы остаются с identity transform.
/// </summary>
public static IReadOnlyList<MshPieceRestPose> BuildRestPose(Msh0x01.Msh0x01Component nodesComponent, List<Msh0x08.AnimationDescriptor> 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)
@@ -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<ViewportPiece> Pieces { get; }
private MshViewportLoadResult(
bool isSuccess,
string? error,
string? sourcePath,
IReadOnlyList<ViewportPiece> pieces)
{
IsSuccess = isSuccess;
Error = error;
SourcePath = sourcePath;
Pieces = pieces;
}
public static MshViewportLoadResult Success(string sourcePath, IReadOnlyList<ViewportPiece> 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<ViewportPiece>());
}
}
@@ -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<ViewportPiece> 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<ViewportPiece>();
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<Common.Vector3> positions,
IReadOnlyList<Msh05Uv> uvs,
IReadOnlyList<ushort> indices,
IReadOnlyList<Msh0x0D.Batch> batches,
WeaMaterialLibrary materialLibrary)
{
var meshes = new List<GpuMesh>();
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<float>();
var outIndices = new List<uint>();
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<Msh05Uv> 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<float> vertices,
List<uint> 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<string> TryReadNames(FileStream fs, NResArchive archive)
{
try
{
return Msh0x0A.ReadComponent(fs, archive);
}
catch
{
return new List<string>();
}
}
private static List<Msh05Uv> TryReadUvs(FileStream fs, NResArchive archive)
{
try
{
return Msh0x05.ReadComponent(fs, archive);
}
catch
{
return new List<Msh05Uv>();
}
}
private static string ResolvePieceName(IReadOnlyList<string> 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<Vector3> 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<GpuMesh> Meshes,
Vector3 BoundsMin,
Vector3 BoundsMax,
int BatchCount,
int TriangleCount);
}
@@ -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<int, ViewportMaterial> _materialsById;
private WeaMaterialLibrary(Dictionary<int, ViewportMaterial> materialsById)
{
_materialsById = materialsById;
}
public static WeaMaterialLibrary Empty { get; } = new(new Dictionary<int, ViewportMaterial>());
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<int, ViewportMaterial>();
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<WeaMaterialRef> 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<WeaMaterialRef>();
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);
}
+7 -3
View File
@@ -17,6 +17,7 @@ public sealed class ViewportPiece
public Vector3 BoundsMax { get; }
public ViewportPieceDebugInfo? DebugInfo { get; }
public IReadOnlyList<int> 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<int>? 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<int>? 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)
+1
View File
@@ -21,4 +21,5 @@
<Project Path="ScrLib/ScrLib.csproj" />
<Project Path="TexmLib/TexmLib.csproj" />
<Project Path="VarsetLib/VarsetLib.csproj" />
<Project Path="WeaLib/WeaLib.csproj" />
</Solution>
+1
View File
@@ -15,6 +15,7 @@
<ProjectReference Include="..\VarsetLib\VarsetLib.csproj" />
<ProjectReference Include="..\MaterialLib\MaterialLib.csproj" />
<ProjectReference Include="..\TexmLib\TexmLib.csproj" />
<ProjectReference Include="..\WeaLib\WeaLib.csproj" />
</ItemGroup>
<ItemGroup>
+72 -4
View File
@@ -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<string> 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)
// {
+18
View File
@@ -0,0 +1,18 @@
namespace WeaLib;
/// <summary>
/// Parsed `.wea` material table. The game loads this separately from MSH/CTL data.
/// </summary>
/// <param name="FileName">Source file name or path supplied by the caller.</param>
/// <param name="Materials">Material id-to-name rows before the optional LIGHTMAPS section.</param>
/// <param name="Lightmaps">Optional lightmap id-to-name rows after the LIGHTMAPS marker.</param>
public sealed record WeaFile(
string FileName,
IReadOnlyList<WeaMaterialRef> Materials,
IReadOnlyList<WeaLightmapRef> Lightmaps);
/// <summary>Material row from a `.wea` file: `{id} {material_name}`.</summary>
public readonly record struct WeaMaterialRef(int Id, string Name);
/// <summary>Lightmap row from the optional `.wea` LIGHTMAPS section.</summary>
public readonly record struct WeaLightmapRef(int Id, string Name);
+1
View File
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk" />
+6
View File
@@ -0,0 +1,6 @@
namespace WeaLib;
public sealed record WeaParseResult(WeaFile? WeaFile = null, string? Error = null)
{
public bool IsSuccess => WeaFile != null;
}
+117
View File
@@ -0,0 +1,117 @@
namespace WeaLib;
/// <summary>
/// Парсер текстовых `.wea` таблиц. Библиотека не знает про Material.lib, Textures.lib и OpenGL:
/// она только возвращает id/name строки из файла.
/// </summary>
public static class WeaParser
{
/// <summary>Читает `.wea` с диска. Ошибки IO возвращаются как WeaParseResult.Error.</summary>
public static WeaParseResult ReadFile(string path)
{
try
{
return ReadLines(File.ReadLines(path), path);
}
catch (Exception ex)
{
return new WeaParseResult(Error: ex.Message);
}
}
/// <summary>Читает `.wea` из stream, оставляя stream открытым для вызывающего кода.</summary>
public static WeaParseResult ReadFromStream(Stream stream, string fileName)
{
using var reader = new StreamReader(stream, leaveOpen: true);
return ReadLines(ReadAllLines(reader), fileName);
}
/// <summary>Удобный вход для тестов и анализа маленьких WEA-фрагментов.</summary>
public static WeaParseResult ReadText(string text, string fileName = "<memory>")
{
return ReadLines(text.Split(["\r\n", "\n"], StringSplitOptions.None), fileName);
}
private static WeaParseResult ReadLines(IEnumerable<string> 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<WeaMaterialRef>(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<WeaLightmapRef>();
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<string> 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;
}
}