mirror of
https://github.com/sampletext32/ParkanPlayground.git
synced 2026-08-15 02:57:49 +04:00
vibecoded inspector
This commit is contained in:
+4
-3
@@ -95,7 +95,8 @@ public static class Msh0x01
|
|||||||
/// <param name="FallbackKey0x08">[0x06..0x08] Fallback key / index в MSH 0x08.</param>
|
/// <param name="FallbackKey0x08">[0x06..0x08] Fallback key / index в MSH 0x08.</param>
|
||||||
/// <param name="Msh02SlotIndicesByStateAndLOD">
|
/// <param name="Msh02SlotIndicesByStateAndLOD">
|
||||||
/// [0x08..0x26] Индексы geometry slot в MSH 0x02.
|
/// [0x08..0x26] Индексы geometry slot в MSH 0x02.
|
||||||
/// Формула: slot = lod * 5 + group. 0xFFFF значит отсутствует.
|
/// Формула индекса в этой таблице: index = state * MaxLodCount + lod.
|
||||||
|
/// 0xFFFF значит, что для этой пары state/LOD геометрии нет.
|
||||||
/// </param>
|
/// </param>
|
||||||
public sealed record Node(
|
public sealed record Node(
|
||||||
NodeFlags Flags,
|
NodeFlags Flags,
|
||||||
@@ -106,7 +107,7 @@ public static class Msh0x01
|
|||||||
{
|
{
|
||||||
public ushort ResolveSlotIndex(int state, int lod = 0)
|
public ushort ResolveSlotIndex(int state, int lod = 0)
|
||||||
{
|
{
|
||||||
|
// State и LOD выбираются для конкретного узла/piece, не для всего файла сразу.
|
||||||
// MODEL_STATE_DEFAULT -1
|
// MODEL_STATE_DEFAULT -1
|
||||||
// MODEL_STATE_REGULAR 0
|
// MODEL_STATE_REGULAR 0
|
||||||
// MODEL_STATE_COLLAPSED 1
|
// MODEL_STATE_COLLAPSED 1
|
||||||
@@ -181,4 +182,4 @@ public enum NodeFlags : ushort
|
|||||||
/// Exact semantic name still provisional.
|
/// Exact semantic name still provisional.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
MSH01_CHECK_PARENT_DESCRIPTION_CENTRAL = 0x0800,
|
MSH01_CHECK_PARENT_DESCRIPTION_CENTRAL = 0x0800,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ public class App
|
|||||||
serviceCollection.AddSingleton(new CpDatSchemeViewModel());
|
serviceCollection.AddSingleton(new CpDatSchemeViewModel());
|
||||||
serviceCollection.AddSingleton(new MaterialViewModel());
|
serviceCollection.AddSingleton(new MaterialViewModel());
|
||||||
serviceCollection.AddSingleton(new ResearchTreeViewModel());
|
serviceCollection.AddSingleton(new ResearchTreeViewModel());
|
||||||
|
serviceCollection.AddSingleton(new MeshViewportViewModel());
|
||||||
|
|
||||||
var serviceProvider = serviceCollection.BuildServiceProvider();
|
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using ImGuiNET;
|
using ImGuiNET;
|
||||||
using NResUI.Abstractions;
|
using NResUI.Abstractions;
|
||||||
|
using NResUI.Models;
|
||||||
|
using NResUI.Rendering.Import.Msh;
|
||||||
|
using NResUI.Rendering.Materials;
|
||||||
using NResUI.Rendering.Viewport;
|
using NResUI.Rendering.Viewport;
|
||||||
using NativeFileDialogSharp;
|
using NativeFileDialogSharp;
|
||||||
using NResUI.Rendering.Viewport.Meshes;
|
using NResUI.Rendering.Viewport.Meshes;
|
||||||
using NResUI.Rendering.Viewport.Msh;
|
|
||||||
using Silk.NET.OpenGL;
|
using Silk.NET.OpenGL;
|
||||||
using Silk.NET.Windowing;
|
using Silk.NET.Windowing;
|
||||||
|
|
||||||
@@ -17,13 +19,14 @@ public sealed class ViewportPanel : IImGuiPanel
|
|||||||
private readonly ViewportCamera _camera = new();
|
private readonly ViewportCamera _camera = new();
|
||||||
private readonly ViewportInputController _inputController = new();
|
private readonly ViewportInputController _inputController = new();
|
||||||
private readonly IConfigProvider _configProvider;
|
private readonly IConfigProvider _configProvider;
|
||||||
|
private readonly MeshViewportViewModel _viewModel;
|
||||||
|
|
||||||
private string? _loadedModelPath;
|
private ViewportMaterialSet _materialSet = ViewportMaterialSet.Empty;
|
||||||
private string? _loadError;
|
|
||||||
|
|
||||||
public ViewportPanel(GL gl, IWindow window, IConfigProvider configProvider)
|
public ViewportPanel(GL gl, IWindow window, IConfigProvider configProvider, MeshViewportViewModel viewModel)
|
||||||
{
|
{
|
||||||
_configProvider = configProvider;
|
_configProvider = configProvider;
|
||||||
|
_viewModel = viewModel;
|
||||||
var cubeMesh = PrimitiveMeshes.CreateCube(gl);
|
var cubeMesh = PrimitiveMeshes.CreateCube(gl);
|
||||||
var gridMesh = PrimitiveMeshes.CreateWorldGrid(gl);
|
var gridMesh = PrimitiveMeshes.CreateWorldGrid(gl);
|
||||||
|
|
||||||
@@ -43,6 +46,8 @@ public sealed class ViewportPanel : IImGuiPanel
|
|||||||
DrawSelectionStatus();
|
DrawSelectionStatus();
|
||||||
DrawViewportToolbar();
|
DrawViewportToolbar();
|
||||||
DrawDebugControls();
|
DrawDebugControls();
|
||||||
|
RebuildSceneIfNeeded();
|
||||||
|
SyncSelectionFromViewModel();
|
||||||
|
|
||||||
var imageSize = ImGui.GetContentRegionAvail();
|
var imageSize = ImGui.GetContentRegionAvail();
|
||||||
if (imageSize.X < 32 || imageSize.Y < 32)
|
if (imageSize.X < 32 || imageSize.Y < 32)
|
||||||
@@ -78,6 +83,9 @@ public sealed class ViewportPanel : IImGuiPanel
|
|||||||
imageMin,
|
imageMin,
|
||||||
imageSize);
|
imageSize);
|
||||||
|
|
||||||
|
if (_viewModel.RenderState.SelectedPieceId != _scene.SelectedPieceId)
|
||||||
|
_viewModel.RenderState.SelectedPieceId = _scene.SelectedPieceId;
|
||||||
|
|
||||||
ImGui.End();
|
ImGui.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,30 +105,34 @@ public sealed class ViewportPanel : IImGuiPanel
|
|||||||
{
|
{
|
||||||
var cubeMesh = PrimitiveMeshes.CreateCube(_renderer.Gl);
|
var cubeMesh = PrimitiveMeshes.CreateCube(_renderer.Gl);
|
||||||
_scene.ReplacePieces([ViewportPiece.CreateUnitCube(0, "Cube", cubeMesh)]);
|
_scene.ReplacePieces([ViewportPiece.CreateUnitCube(0, "Cube", cubeMesh)]);
|
||||||
_loadedModelPath = null;
|
_viewModel.ClearDocument();
|
||||||
_loadError = null;
|
|
||||||
_camera.Reset();
|
_camera.Reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_loadedModelPath != null)
|
if (_viewModel.Document != null)
|
||||||
ImGui.TextDisabled($"Model: {Path.GetFileName(_loadedModelPath)}");
|
ImGui.TextDisabled($"Model: {Path.GetFileName(_viewModel.Document.SourcePath)}");
|
||||||
|
|
||||||
if (_loadError != null)
|
if (_viewModel.LoadError != null)
|
||||||
ImGui.TextColored(new Vector4(1.0f, 0.35f, 0.25f, 1.0f), $"MSH load failed: {_loadError}");
|
ImGui.TextColored(new Vector4(1.0f, 0.35f, 0.25f, 1.0f), $"MSH load failed: {_viewModel.LoadError}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadMsh(string path)
|
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)
|
if (!loadResult.IsSuccess)
|
||||||
{
|
{
|
||||||
_loadError = loadResult.Error ?? "Unknown error.";
|
_viewModel.SetError(loadResult.Error ?? "Unknown error.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_scene.ReplacePieces(loadResult.Pieces);
|
_viewModel.SetDocument(loadResult.Document!);
|
||||||
_loadedModelPath = loadResult.SourcePath;
|
_materialSet = ViewportMaterialResolver.LoadForMsh(
|
||||||
_loadError = null;
|
_renderer.Gl,
|
||||||
|
path,
|
||||||
|
_configProvider,
|
||||||
|
loadResult.Document!.Warnings is ICollection<string> mutableWarnings ? mutableWarnings : null);
|
||||||
|
RebuildSceneIfNeeded();
|
||||||
|
|
||||||
if (_scene.TryGetSceneWorldBounds(out var bounds))
|
if (_scene.TryGetSceneWorldBounds(out var bounds))
|
||||||
_camera.FrameBounds(bounds.Min, bounds.Max);
|
_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()
|
private void DrawViewportToolbar()
|
||||||
{
|
{
|
||||||
if (ImGui.Button("Reset view"))
|
if (ImGui.Button("Reset view"))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<ProjectReference Include="..\TexmLib\TexmLib.csproj" />
|
<ProjectReference Include="..\TexmLib\TexmLib.csproj" />
|
||||||
<ProjectReference Include="..\VarsetLib\VarsetLib.csproj" />
|
<ProjectReference Include="..\VarsetLib\VarsetLib.csproj" />
|
||||||
<ProjectReference Include="..\MaterialLib\MaterialLib.csproj" />
|
<ProjectReference Include="..\MaterialLib\MaterialLib.csproj" />
|
||||||
|
<ProjectReference Include="..\WeaLib\WeaLib.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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
|
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)
|
public static IReadOnlyList<MshPieceRestPose> BuildRestPose(Msh0x01.Msh0x01Component nodesComponent, List<Msh0x08.AnimationDescriptor> animationDescriptors)
|
||||||
{
|
{
|
||||||
var nodeList = nodesComponent.Nodes;
|
var nodeList = nodesComponent.Nodes;
|
||||||
@@ -54,15 +58,12 @@ public static class MshRestPoseBuilder
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
localTransform = Matrix4x4.Identity;
|
localTransform = Matrix4x4.Identity;
|
||||||
|
|
||||||
Console.WriteLine($"Node {nodeIndex} has no fallback");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parentIndex == -1)
|
if (parentIndex == -1)
|
||||||
{
|
{
|
||||||
|
// Root nodes describe object placement in game space; the viewer keeps the model centered.
|
||||||
localTransform.Translation = Vector3.Zero;
|
localTransform.Translation = Vector3.Zero;
|
||||||
|
|
||||||
Console.WriteLine($"Node {nodeIndex} has no parent");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var meshSpaceTransform = localTransform;
|
var meshSpaceTransform = localTransform;
|
||||||
@@ -86,15 +87,7 @@ public static class MshRestPoseBuilder
|
|||||||
|
|
||||||
private static int GetParentIndex(Msh0x01.Node node)
|
private static int GetParentIndex(Msh0x01.Node node)
|
||||||
{
|
{
|
||||||
try
|
return node.ParentIndexOrLink == ushort.MaxValue ? -1 : node.ParentIndexOrLink;
|
||||||
{
|
|
||||||
return Convert.ToInt32(node.ParentIndexOrLink);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
var rawParent = Convert.ToUInt16(node.ParentIndexOrLink);
|
|
||||||
return rawParent == ushort.MaxValue ? -1 : rawParent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int GetFallbackKeyframeIndex(Msh0x01.Node node)
|
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);
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,7 @@ public sealed class ViewportPiece
|
|||||||
public Vector3 BoundsMax { get; }
|
public Vector3 BoundsMax { get; }
|
||||||
|
|
||||||
public ViewportPieceDebugInfo? DebugInfo { get; }
|
public ViewportPieceDebugInfo? DebugInfo { get; }
|
||||||
|
public IReadOnlyList<int> SourceBatchIndices { get; }
|
||||||
|
|
||||||
public ViewportPiece(
|
public ViewportPiece(
|
||||||
int id,
|
int id,
|
||||||
@@ -25,8 +26,9 @@ public sealed class ViewportPiece
|
|||||||
Matrix4x4 localTransform,
|
Matrix4x4 localTransform,
|
||||||
Vector3 boundsMin,
|
Vector3 boundsMin,
|
||||||
Vector3 boundsMax,
|
Vector3 boundsMax,
|
||||||
ViewportPieceDebugInfo? debugInfo = null)
|
ViewportPieceDebugInfo? debugInfo = null,
|
||||||
: this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo)
|
IReadOnlyList<int>? sourceBatchIndices = null)
|
||||||
|
: this(id, name, new[] { mesh }, localTransform, boundsMin, boundsMax, debugInfo, sourceBatchIndices)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +39,8 @@ public sealed class ViewportPiece
|
|||||||
Matrix4x4 localTransform,
|
Matrix4x4 localTransform,
|
||||||
Vector3 boundsMin,
|
Vector3 boundsMin,
|
||||||
Vector3 boundsMax,
|
Vector3 boundsMax,
|
||||||
ViewportPieceDebugInfo? debugInfo = null)
|
ViewportPieceDebugInfo? debugInfo = null,
|
||||||
|
IReadOnlyList<int>? sourceBatchIndices = null)
|
||||||
{
|
{
|
||||||
if (meshes.Count == 0)
|
if (meshes.Count == 0)
|
||||||
throw new ArgumentException("A viewport piece must contain at least one mesh.", nameof(meshes));
|
throw new ArgumentException("A viewport piece must contain at least one mesh.", nameof(meshes));
|
||||||
@@ -49,6 +52,7 @@ public sealed class ViewportPiece
|
|||||||
BoundsMin = boundsMin;
|
BoundsMin = boundsMin;
|
||||||
BoundsMax = boundsMax;
|
BoundsMax = boundsMax;
|
||||||
DebugInfo = debugInfo;
|
DebugInfo = debugInfo;
|
||||||
|
SourceBatchIndices = sourceBatchIndices ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ViewportPiece CreateUnitCube(int id, string name, GpuMesh mesh)
|
public static ViewportPiece CreateUnitCube(int id, string name, GpuMesh mesh)
|
||||||
|
|||||||
@@ -21,4 +21,5 @@
|
|||||||
<Project Path="ScrLib/ScrLib.csproj" />
|
<Project Path="ScrLib/ScrLib.csproj" />
|
||||||
<Project Path="TexmLib/TexmLib.csproj" />
|
<Project Path="TexmLib/TexmLib.csproj" />
|
||||||
<Project Path="VarsetLib/VarsetLib.csproj" />
|
<Project Path="VarsetLib/VarsetLib.csproj" />
|
||||||
</Solution>
|
<Project Path="WeaLib/WeaLib.csproj" />
|
||||||
|
</Solution>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<ProjectReference Include="..\VarsetLib\VarsetLib.csproj" />
|
<ProjectReference Include="..\VarsetLib\VarsetLib.csproj" />
|
||||||
<ProjectReference Include="..\MaterialLib\MaterialLib.csproj" />
|
<ProjectReference Include="..\MaterialLib\MaterialLib.csproj" />
|
||||||
<ProjectReference Include="..\TexmLib\TexmLib.csproj" />
|
<ProjectReference Include="..\TexmLib\TexmLib.csproj" />
|
||||||
|
<ProjectReference Include="..\WeaLib\WeaLib.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Common;
|
using Common;
|
||||||
using ControlLib;
|
using ControlLib;
|
||||||
using MshLib;
|
using MshLib;
|
||||||
|
using NResLib;
|
||||||
|
using WeaLib;
|
||||||
|
|
||||||
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);
|
||||||
//
|
//
|
||||||
var ctl = ControlResourceReader.ReadCtlFile("E:\\ParkanUnpacked\\bases.rlb\\2_CTLD_r_l_03.ctl");
|
// 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 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 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 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");
|
// 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 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");
|
// 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)
|
// 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} " +
|
// $"0x{item.Offset:X4}-0x{item.EndOffset:X4} " +
|
||||||
// $"type={item.ItemType:X} piece={item.LocalPieceIndex} " +
|
// $"type={item.ItemType:X} piece={item.LocalPieceIndex} " +
|
||||||
// $"name='{item.ItemName}' bindings=[{string.Join(", ", item.PieceBindingIndices)}]");
|
// $"name='{item.ItemName}' bindings=[{string.Join(", ", item.PieceBindingIndices)}]");
|
||||||
// }
|
// }
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk" />
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace WeaLib;
|
||||||
|
|
||||||
|
public sealed record WeaParseResult(WeaFile? WeaFile = null, string? Error = null)
|
||||||
|
{
|
||||||
|
public bool IsSuccess => WeaFile != null;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user