From 0ef03836421a77e786b21acf9506c8242acbc5d5 Mon Sep 17 00:00:00 2001 From: Nicolas Gnyra Date: Tue, 28 May 2024 17:22:39 -0400 Subject: [PATCH 1/2] Add initial ClothProxyMapping utilities --- LSLib/Granny/ClothUtils.cs | 370 +++++++++++++++++++++++++++++++++++++ LSLib/LSLib.csproj | 1 + 2 files changed, 371 insertions(+) create mode 100644 LSLib/Granny/ClothUtils.cs diff --git a/LSLib/Granny/ClothUtils.cs b/LSLib/Granny/ClothUtils.cs new file mode 100644 index 00000000..3c117bc4 --- /dev/null +++ b/LSLib/Granny/ClothUtils.cs @@ -0,0 +1,370 @@ +using LSLib.Granny.Model; +using LSLib.LS; +using OpenTK.Mathematics; +using Supercluster.KDTree; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; + +namespace LSLib.Granny +{ + public class ClothUtils + { + // Tested this by incrementally moving an overlapping vertex until the cloth physics broke in-game. Repeated on all 3 dimensions + // to make sure it wasn't connected to precision; all 3 stopped working immediately above this value. On X and Z axes, the actual + // increments could be close but not over 0.0000001 (e.g. 0.00000008940697), while on the Y axis it could only be 0 (not broken) or 0.00000011920929 (broken). + private const double OverlappingVertexSearchRadius = 0.0000001f; + + public static string Serialize(Triplet[] triplets) + { + using var target = new MemoryStream(); + + using (var zlibStream = new ZLibStream(target, CompressionLevel.SmallestSize)) + using (var writer = new BinaryWriter(zlibStream)) + { + BinUtils.WriteStructs(writer, triplets); + } + + return Convert.ToBase64String(target.ToArray()); + } + + public static Triplet[] Deserialize(string str) + { + var compressedData = Convert.FromBase64String(str); + using var target = new MemoryStream(); + + using (var source = new MemoryStream(compressedData)) + using (var zlibStream = new ZLibStream(source, CompressionMode.Decompress)) + { + zlibStream.CopyTo(target); + } + + Triplet[] triplets = new Triplet[target.Length / 6]; + target.Position = 0; + + using var reader = new BinaryReader(target); + BinUtils.ReadStructs(reader, triplets); + + return triplets; + } + + public static Triplet[] Generate(Mesh physicsMesh, Mesh targetMesh) + { + Debug.WriteLine($"Generate Start"); + Stopwatch stopwatch = Stopwatch.StartNew(); + + var physicsClothMesh = ClothMesh.Build(physicsMesh); + Debug.WriteLine($"Build Physics Mesh {stopwatch.Elapsed}"); + + var targetClothMesh = ClothMesh.Build(targetMesh); + Debug.WriteLine($"Build Target Mesh {stopwatch.Elapsed}"); + + var physicsClothVertices = GetPhysicsClothVertices(physicsClothMesh); + Debug.WriteLine($"GetPhysicsClothVertices {stopwatch.Elapsed}"); + + var targetClothVertices = GetTargetClothVertices(targetClothMesh); + Debug.WriteLine($"GetTargetClothVertices {stopwatch.Elapsed}"); + + if (physicsClothVertices.Length == 0 || targetClothVertices.Length == 0) + { + return []; + } + + var kdTree = BuildKdTree(physicsClothVertices); + Debug.WriteLine($"BuildKdTree {stopwatch.Elapsed}"); + + var triplets = new Triplet[targetClothVertices.Length]; + + Parallel.For(0, targetClothVertices.Length, (index) => + { + var vertex = targetClothVertices[index]; + Span<(short PhysicsIndex, float Distance)> triplet = stackalloc (short, float)[3]; + var tripletIndex = 0; + + // TODO: Searching the whole tree every time is slow. Maybe do RadialSearch? Hard to tell if there was a limit used by the game devs. + // Looking at a flame graph, most of the time seems to be spent internally allocating small arrays. + foreach (var physicsVertex in kdTree.NearestNeighbors(vertex.PositionAsArray, kdTree.Count).Select(t => t.Item2)) + { + if (vertex.Mask != 0 && (physicsVertex.Mask & vertex.Mask) == 0) + { + continue; + } + + var distance = (vertex.Position - physicsVertex.Position).Length; + + // TODO: this doesn't make a whole lot of sense but gets us very close to what the game does + if (tripletIndex == 1 && distance > triplet[0].Distance * 2.875) + { + break; + } + else if (tripletIndex == 2 && (distance > (triplet[0].Distance + triplet[1].Distance) || distance > triplet[0].Distance * 2.8)) + { + break; + } + + triplet[tripletIndex++] = (physicsVertex.PhysicsIndex, distance); + + if (tripletIndex == 3) + { + break; + } + } + + while (tripletIndex < 3) + { + triplet[tripletIndex++] = (-1, -1); + } + + triplets[index] = new Triplet(triplet[0].PhysicsIndex, triplet[1].PhysicsIndex, triplet[2].PhysicsIndex); + }); + + Debug.WriteLine($"Generate End {stopwatch.Elapsed}"); + + return triplets; + } + + private static KDTree BuildKdTree(T[] vertices) + where T : BaseVertex + { + // TODO: This kinda sucks. It'd be nice if the KD tree could use the position property directly. Might be worth modifying the package? + var points = new float[vertices.Length][]; + + for (int i = 0; i < points.Length; i++) + { + points[i] = vertices[i].PositionAsArray; + } + + // don't use Math.Sqrt and Math.Pow for the metric since they're relatively slow and this'll be called thousands of times (square distance is fine here) + return new KDTree(3, points, vertices, (a, b) => (b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]) + (b[2] - a[2]) * (b[2] - a[2])); + } + + private static PhysicsVertex[] GetPhysicsClothVertices(ClothMesh mesh) + { + var markedVertices = GetClothVertices(mesh); + var encountered = new HashSet(); + var physicsVertices = new List(markedVertices.Count); + + for (int i = 0; i < mesh.Indices.Count / 3; i++) + { + ClothVertex v1 = mesh.Vertices[mesh.Indices[i * 3]]; + ClothVertex v2 = mesh.Vertices[mesh.Indices[i * 3 + 1]]; + ClothVertex v3 = mesh.Vertices[mesh.Indices[i * 3 + 2]]; + + // skip the triangle if it has a non-cloth vertex + if (!markedVertices.Contains(v1) || !markedVertices.Contains(v2) || !markedVertices.Contains(v3)) + { + continue; + } + + for (int j = 0; j < 3; j++) + { + ClothVertex vertex = mesh.Vertices[mesh.Indices[i * 3 + j]]; + + // overlapping vertices should all map to the first encountered + // TODO: investigate whether this also requires looking around with OverlappingVertexSearchRadius (or some other radius) + if (encountered.Contains(vertex.Position)) + { + continue; + } + + physicsVertices.Add(new PhysicsVertex((short)physicsVertices.Count, vertex)); + encountered.Add(vertex.Position); + } + } + + return [.. physicsVertices]; + } + + private static ClothVertex[] GetTargetClothVertices(ClothMesh mesh) + { + ClothVertex[] clothVertices = [.. GetClothVertices(mesh).OrderBy(v => v.Index)]; + var packedVertices = new ClothVertex[clothVertices.Length]; + + var i = 0; + + for (; i < clothVertices.Length && clothVertices[i].Index < clothVertices.Length; i++) + { + var vertex = clothVertices[i]; + packedVertices[vertex.Index] = vertex; + } + + var current = 0; + + for (; i < clothVertices.Length; i++) + { + while (packedVertices[current] != null) + { + current++; + } + + packedVertices[current++] = clothVertices[i]; + } + + return packedVertices; + } + + private static HashSet GetClothVertices(ClothMesh mesh) + { + var clothVertices = new HashSet(mesh.Vertices.Where(v => v.Weight > 0)); + + AddOverlappingVertices(clothVertices); + AddNeighboringVertices(clothVertices); + AddOverlappingVertices(clothVertices); + AddNeighboringVertices(clothVertices); + AddOverlappingVertices(clothVertices); + + return clothVertices; + } + + private static void AddOverlappingVertices(HashSet clothVertices) + { + foreach (var vertex in clothVertices.SelectMany(v => v.Overlapping).ToList()) + { + clothVertices.Add(vertex); + } + } + + private static void AddNeighboringVertices(HashSet clothVertices) + { + foreach (var vertex in clothVertices.SelectMany(v => v.Neighbors).ToList()) + { + clothVertices.Add(vertex); + } + } + + public readonly struct Triplet(short a, short b, short c) : IEquatable + { + public short A => a; + + public short B => b; + + public short C => c; + + public static bool operator ==(Triplet x, Triplet y) => x.Equals(y); + + public static bool operator !=(Triplet x, Triplet y) => !x.Equals(y); + + public override bool Equals([NotNullWhen(true)] object obj) => obj is Triplet triplet && Equals(triplet); + + public override int GetHashCode() => HashCode.Combine(A, B, C); + + public override string ToString() => $"({A}, {B}, {C})"; + + public bool Equals(Triplet other) => A == other.A && B == other.B && C == other.C; + } + + private record ClothMesh + { + private ClothMesh(IReadOnlyList vertices, IReadOnlyList indices) + { + Vertices = vertices; + Indices = indices; + } + + internal IReadOnlyList Vertices { get; } + + internal IReadOnlyList Indices { get; } + + internal static ClothMesh Build(Mesh mesh) + { + var vertices = new ClothVertex[mesh.PrimaryVertexData.Vertices.Count]; + + for (int i = 0; i < vertices.Length; i++) + { + vertices[i] = new ClothVertex(i, mesh.PrimaryVertexData.Vertices[i]); + } + + var kdTree = BuildKdTree(vertices); + + Parallel.ForEach(vertices, (vertex) => + { + // searchRadius is squared because our tree uses the square distance as its metric + var overlapping = kdTree.RadialSearch(vertex.PositionAsArray, OverlappingVertexSearchRadius * OverlappingVertexSearchRadius); + + // nothing to do if there are no overlapping vertices (we just found the vertex itself) + if (overlapping.Length == 1) + { + return; + } + + // overlapping vertices take the weight of the first vertex by index + vertex.Weight = overlapping.MinBy(v => v.Item2.Index).Item2.Weight; + vertex.Overlapping.AddRange(overlapping.Select(v => v.Item2).Where(v => v != vertex)); + }); + + var indices = mesh.PrimaryTopology.Indices; + + for (var i = 0; i < indices.Count / 3; i++) + { + for (var j = 0; j < 3; j++) + { + var vertex = vertices[indices[i * 3 + j]]; + vertex.Neighbors.Add(vertices[indices[i * 3 + ((j + 1) % 3)]]); + vertex.Neighbors.Add(vertices[indices[i * 3 + ((j + 2) % 3)]]); + } + } + + return new ClothMesh(vertices, indices); + } + } + + private record BaseVertex + { + protected BaseVertex(Vector3 position) + { + Position = position; + PositionAsArray = [position.X, position.Y, position.Z]; + } + + internal Vector3 Position { get; } + + internal float[] PositionAsArray { get; } + } + + private record ClothVertex : BaseVertex + { + internal int Index { get; } + + internal byte Weight { get; set; } + + internal byte Mask { get; } + + internal List Neighbors { get; } = []; + + internal List Overlapping { get; } = []; + + // byte conversion must be the same as VertexSerialization.WriteNormalByteVector4 or things will break! + internal ClothVertex(int index, Vertex vertex) + : this(index, vertex.Position, (byte)(vertex.Color0.X * 255), (byte)(vertex.Color0.Z * 255)) + { + } + + internal ClothVertex(int index, Vector3 position, byte weight, byte mask) + : base(position) + { + Index = index; + Weight = weight; + Mask = mask; + } + } + + private record PhysicsVertex : BaseVertex + { + internal short PhysicsIndex { get; } + + internal byte Mask { get; } + + internal PhysicsVertex(short physicsIndex, ClothVertex clothVertex) + : this(physicsIndex, clothVertex.Position, clothVertex.Mask) + { + } + + internal PhysicsVertex(short physicsIndex, Vector3 position, byte mask) + : base(position) + { + PhysicsIndex = physicsIndex; + Mask = mask; + } + } + } +} diff --git a/LSLib/LSLib.csproj b/LSLib/LSLib.csproj index 5cf39a0e..8c3a0baf 100644 --- a/LSLib/LSLib.csproj +++ b/LSLib/LSLib.csproj @@ -31,6 +31,7 @@ + From 9ce136678ade9a14d57b05897cfa47e6d6622803 Mon Sep 17 00:00:00 2001 From: Nicolas Gnyra Date: Sun, 2 Jun 2024 17:15:21 -0400 Subject: [PATCH 2/2] Add basic UI --- ConverterApp/ClothPane.Designer.cs | 263 +++++++++++++++++++++++++++ ConverterApp/ClothPane.cs | 207 +++++++++++++++++++++ ConverterApp/ClothPane.resx | 123 +++++++++++++ ConverterApp/ConverterAppSettings.cs | 15 ++ ConverterApp/MainForm.Designer.cs | 88 ++++----- ConverterApp/MainForm.cs | 9 + 6 files changed, 663 insertions(+), 42 deletions(-) create mode 100644 ConverterApp/ClothPane.Designer.cs create mode 100644 ConverterApp/ClothPane.cs create mode 100644 ConverterApp/ClothPane.resx diff --git a/ConverterApp/ClothPane.Designer.cs b/ConverterApp/ClothPane.Designer.cs new file mode 100644 index 00000000..90bf7d7a --- /dev/null +++ b/ConverterApp/ClothPane.Designer.cs @@ -0,0 +1,263 @@ +namespace ConverterApp +{ + partial class ClothPane + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.inputPath = new System.Windows.Forms.TextBox(); + this.fileLbl = new System.Windows.Forms.Label(); + this.browseBtn = new System.Windows.Forms.Button(); + this.generatedTextBox = new System.Windows.Forms.TextBox(); + this.generateBtn = new System.Windows.Forms.Button(); + this.physicsMeshComboBox = new System.Windows.Forms.ComboBox(); + this.physicsMeshLbl = new System.Windows.Forms.Label(); + this.meshesLayoutPanel = new System.Windows.Forms.TableLayoutPanel(); + this.physicsPanel = new System.Windows.Forms.Panel(); + this.targetsPanel = new System.Windows.Forms.Panel(); + this.targetMeshesLbl = new System.Windows.Forms.Label(); + this.targetMeshesListView = new System.Windows.Forms.ListView(); + this.nameColumn = new System.Windows.Forms.ColumnHeader(); + this.inputFileDlg = new System.Windows.Forms.OpenFileDialog(); + this.loadBtn = new System.Windows.Forms.Button(); + this.resourceNameTextBox = new System.Windows.Forms.TextBox(); + this.resourceNameLbl = new System.Windows.Forms.Label(); + this.meshesLayoutPanel.SuspendLayout(); + this.physicsPanel.SuspendLayout(); + this.targetsPanel.SuspendLayout(); + this.SuspendLayout(); + // + // inputPath + // + this.inputPath.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.inputPath.Location = new System.Drawing.Point(3, 18); + this.inputPath.Name = "inputPath"; + this.inputPath.Size = new System.Drawing.Size(884, 23); + this.inputPath.TabIndex = 0; + // + // fileLbl + // + this.fileLbl.AutoSize = true; + this.fileLbl.Location = new System.Drawing.Point(3, 0); + this.fileLbl.Name = "fileLbl"; + this.fileLbl.Size = new System.Drawing.Size(79, 15); + this.fileLbl.TabIndex = 1; + this.fileLbl.Text = "GR2 File Path:"; + // + // browseBtn + // + this.browseBtn.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; + this.browseBtn.Location = new System.Drawing.Point(893, 18); + this.browseBtn.Name = "browseBtn"; + this.browseBtn.Size = new System.Drawing.Size(45, 23); + this.browseBtn.TabIndex = 2; + this.browseBtn.Text = "..."; + this.browseBtn.UseVisualStyleBackColor = true; + this.browseBtn.Click += this.browseBtn_Click; + // + // generatedTextBox + // + this.generatedTextBox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.generatedTextBox.Font = new System.Drawing.Font("Courier New", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, 0); + this.generatedTextBox.Location = new System.Drawing.Point(3, 305); + this.generatedTextBox.Multiline = true; + this.generatedTextBox.Name = "generatedTextBox"; + this.generatedTextBox.ScrollBars = System.Windows.Forms.ScrollBars.Both; + this.generatedTextBox.Size = new System.Drawing.Size(994, 242); + this.generatedTextBox.TabIndex = 3; + this.generatedTextBox.WordWrap = false; + // + // generateBtn + // + this.generateBtn.Location = new System.Drawing.Point(3, 276); + this.generateBtn.Name = "generateBtn"; + this.generateBtn.Size = new System.Drawing.Size(75, 23); + this.generateBtn.TabIndex = 4; + this.generateBtn.Text = "Generate"; + this.generateBtn.UseVisualStyleBackColor = true; + this.generateBtn.Click += this.generateBtn_Click; + // + // physicsMeshComboBox + // + this.physicsMeshComboBox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.physicsMeshComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.physicsMeshComboBox.FormattingEnabled = true; + this.physicsMeshComboBox.Location = new System.Drawing.Point(3, 23); + this.physicsMeshComboBox.Name = "physicsMeshComboBox"; + this.physicsMeshComboBox.Size = new System.Drawing.Size(494, 23); + this.physicsMeshComboBox.TabIndex = 5; + this.physicsMeshComboBox.SelectedIndexChanged += this.physicsMeshComboBox_SelectedIndexChanged; + // + // physicsMeshLbl + // + this.physicsMeshLbl.AutoSize = true; + this.physicsMeshLbl.Location = new System.Drawing.Point(3, 4); + this.physicsMeshLbl.Name = "physicsMeshLbl"; + this.physicsMeshLbl.Size = new System.Drawing.Size(81, 15); + this.physicsMeshLbl.TabIndex = 6; + this.physicsMeshLbl.Text = "Physics Mesh:"; + // + // meshesLayoutPanel + // + this.meshesLayoutPanel.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.meshesLayoutPanel.ColumnCount = 2; + this.meshesLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.meshesLayoutPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.meshesLayoutPanel.Controls.Add(this.physicsPanel, 0, 0); + this.meshesLayoutPanel.Controls.Add(this.targetsPanel, 1, 0); + this.meshesLayoutPanel.Location = new System.Drawing.Point(0, 97); + this.meshesLayoutPanel.Name = "meshesLayoutPanel"; + this.meshesLayoutPanel.RowCount = 1; + this.meshesLayoutPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.meshesLayoutPanel.Size = new System.Drawing.Size(1000, 173); + this.meshesLayoutPanel.TabIndex = 7; + // + // physicsPanel + // + this.physicsPanel.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.physicsPanel.Controls.Add(this.physicsMeshComboBox); + this.physicsPanel.Controls.Add(this.physicsMeshLbl); + this.physicsPanel.Location = new System.Drawing.Point(0, 0); + this.physicsPanel.Margin = new System.Windows.Forms.Padding(0); + this.physicsPanel.Name = "physicsPanel"; + this.physicsPanel.Size = new System.Drawing.Size(500, 173); + this.physicsPanel.TabIndex = 0; + // + // targetsPanel + // + this.targetsPanel.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.targetsPanel.Controls.Add(this.targetMeshesLbl); + this.targetsPanel.Controls.Add(this.targetMeshesListView); + this.targetsPanel.Location = new System.Drawing.Point(500, 0); + this.targetsPanel.Margin = new System.Windows.Forms.Padding(0); + this.targetsPanel.Name = "targetsPanel"; + this.targetsPanel.Size = new System.Drawing.Size(500, 173); + this.targetsPanel.TabIndex = 1; + // + // targetMeshesLbl + // + this.targetMeshesLbl.AutoSize = true; + this.targetMeshesLbl.Location = new System.Drawing.Point(3, 4); + this.targetMeshesLbl.Name = "targetMeshesLbl"; + this.targetMeshesLbl.Size = new System.Drawing.Size(85, 15); + this.targetMeshesLbl.TabIndex = 1; + this.targetMeshesLbl.Text = "Target Meshes:"; + // + // targetMeshesListView + // + this.targetMeshesListView.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.targetMeshesListView.CheckBoxes = true; + this.targetMeshesListView.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { this.nameColumn }); + this.targetMeshesListView.HeaderStyle = System.Windows.Forms.ColumnHeaderStyle.Nonclickable; + this.targetMeshesListView.Location = new System.Drawing.Point(3, 23); + this.targetMeshesListView.Name = "targetMeshesListView"; + this.targetMeshesListView.Size = new System.Drawing.Size(494, 147); + this.targetMeshesListView.TabIndex = 0; + this.targetMeshesListView.UseCompatibleStateImageBehavior = false; + this.targetMeshesListView.View = System.Windows.Forms.View.Details; + // + // nameColumn + // + this.nameColumn.Text = "Name"; + this.nameColumn.Width = 400; + // + // inputFileDlg + // + this.inputFileDlg.Filter = "GR2 files|*.gr2;*.lsm"; + // + // loadBtn + // + this.loadBtn.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; + this.loadBtn.Location = new System.Drawing.Point(944, 18); + this.loadBtn.Name = "loadBtn"; + this.loadBtn.Size = new System.Drawing.Size(53, 23); + this.loadBtn.TabIndex = 8; + this.loadBtn.Text = "Load"; + this.loadBtn.UseVisualStyleBackColor = true; + this.loadBtn.Click += this.loadBtn_Click; + // + // resourceNameTextBox + // + this.resourceNameTextBox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + this.resourceNameTextBox.Location = new System.Drawing.Point(3, 68); + this.resourceNameTextBox.Name = "resourceNameTextBox"; + this.resourceNameTextBox.Size = new System.Drawing.Size(994, 23); + this.resourceNameTextBox.TabIndex = 9; + // + // resourceNameLbl + // + this.resourceNameLbl.AutoSize = true; + this.resourceNameLbl.Location = new System.Drawing.Point(3, 50); + this.resourceNameLbl.Name = "resourceNameLbl"; + this.resourceNameLbl.Size = new System.Drawing.Size(93, 15); + this.resourceNameLbl.TabIndex = 10; + this.resourceNameLbl.Text = "Resource Name:"; + // + // ClothPane + // + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Controls.Add(this.resourceNameLbl); + this.Controls.Add(this.resourceNameTextBox); + this.Controls.Add(this.loadBtn); + this.Controls.Add(this.meshesLayoutPanel); + this.Controls.Add(this.generateBtn); + this.Controls.Add(this.generatedTextBox); + this.Controls.Add(this.browseBtn); + this.Controls.Add(this.fileLbl); + this.Controls.Add(this.inputPath); + this.Name = "ClothPane"; + this.Size = new System.Drawing.Size(1000, 550); + this.meshesLayoutPanel.ResumeLayout(false); + this.physicsPanel.ResumeLayout(false); + this.physicsPanel.PerformLayout(); + this.targetsPanel.ResumeLayout(false); + this.targetsPanel.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + } + + #endregion + + private System.Windows.Forms.TextBox inputPath; + private System.Windows.Forms.Label fileLbl; + private System.Windows.Forms.Button browseBtn; + private System.Windows.Forms.TextBox generatedTextBox; + private System.Windows.Forms.Button generateBtn; + private System.Windows.Forms.ComboBox physicsMeshComboBox; + private System.Windows.Forms.Label physicsMeshLbl; + private System.Windows.Forms.TableLayoutPanel meshesLayoutPanel; + private System.Windows.Forms.Panel physicsPanel; + private System.Windows.Forms.Panel targetsPanel; + private System.Windows.Forms.ListView targetMeshesListView; + private System.Windows.Forms.ColumnHeader nameColumn; + private System.Windows.Forms.OpenFileDialog inputFileDlg; + private System.Windows.Forms.Label targetMeshesLbl; + private System.Windows.Forms.Button loadBtn; + private System.Windows.Forms.TextBox resourceNameTextBox; + private System.Windows.Forms.Label resourceNameLbl; + } +} diff --git a/ConverterApp/ClothPane.cs b/ConverterApp/ClothPane.cs new file mode 100644 index 00000000..3bb25135 --- /dev/null +++ b/ConverterApp/ClothPane.cs @@ -0,0 +1,207 @@ +using LSLib.Granny; +using LSLib.Granny.GR2; +using LSLib.Granny.Model; +using LSLib.LS; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Windows.Forms; +using System.Xml.Linq; + +namespace ConverterApp +{ + public partial class ClothPane : UserControl + { + private readonly List physicsMeshes = []; + private readonly List targetMeshes = []; + + public ClothPane(ISettingsDataSource settingsDataSource) + { + InitializeComponent(); + + inputPath.DataBindings.Add("Text", settingsDataSource, "Settings.Cloth.InputPath", true, DataSourceUpdateMode.OnPropertyChanged); + } + + private void browseBtn_Click(object sender, EventArgs e) + { + if (inputFileDlg.ShowDialog(this) == DialogResult.OK) + { + inputPath.Text = inputFileDlg.FileName; + } + } + + private void loadBtn_Click(object sender, EventArgs e) + { + string nl = Environment.NewLine; + + try + { + LoadFile(); + } + catch (ParsingException exc) + { + MessageBox.Show($"Import failed!{nl}{nl}{exc.Message}", "Import Failed"); + } + catch (Exception exc) + { + MessageBox.Show($"Internal error!{nl}{nl}{exc}", "Import Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void physicsMeshComboBox_SelectedIndexChanged(object sender, EventArgs e) + { + if (physicsMeshComboBox.SelectedIndex >= physicsMeshes.Count) + { + return; + } + + string name = physicsMeshes[physicsMeshComboBox.SelectedIndex].Name; + Match match = LODRegex().Match(name); + + var items = targetMeshesListView.Items; + + if (!match.Success) + { + for (int i = 0; i < items.Count; i++) + { + items[i].Checked = !LODRegex().IsMatch(targetMeshes[i].Name); + } + } + else + { + for (int i = 0; i < items.Count; i++) + { + items[i].Checked = targetMeshes[i].Name.EndsWith(match.Value); + } + } + } + + private void generateBtn_Click(object sender, EventArgs e) + { + string nl = Environment.NewLine; + + try + { + Generate(); + } + catch (Exception exc) + { + MessageBox.Show($"Internal error!{nl}{nl}{exc}", "Generation Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void LoadFile() + { + var root = GR2Utils.LoadModel(inputPath.Text, new ExporterOptions + { + InputFormat = ExportFormat.GR2, + }); + + resourceNameTextBox.Text = Path.GetFileNameWithoutExtension(inputPath.Text); + + var physicsItems = physicsMeshComboBox.Items; + var targetItems = targetMeshesListView.Items; + + physicsMeshes.Clear(); + physicsItems.Clear(); + + targetMeshes.Clear(); + targetItems.Clear(); + + foreach (var mesh in root.Meshes) + { + if (!mesh.ExtendedData.UserMeshProperties.MeshFlags.IsCloth()) + { + continue; + } + + if (mesh.ExtendedData.UserMeshProperties.ClothFlags.HasClothPhysics()) + { + physicsMeshes.Add(mesh); + physicsItems.Add(mesh.Name); + } + else + { + targetMeshes.Add(mesh); + targetItems.Add(new ListViewItem(new[] { mesh.Name })); + } + } + + physicsMeshComboBox.SelectedIndex = physicsItems.Count - 1; + } + + private void Generate() + { + if (physicsMeshComboBox.SelectedIndex < 0) + { + return; + } + + Mesh physicsMesh = physicsMeshes[physicsMeshComboBox.SelectedIndex]; + string physicsName = GetMeshName(physicsMesh); + + var (root, children) = CreateObject(physicsName); + var doc = new XDocument(root); + + foreach (int index in targetMeshesListView.CheckedIndices) + { + Mesh targetMesh = targetMeshes[index]; + + ClothUtils.Triplet[] items = ClothUtils.Generate(physicsMesh, targetMesh); + children.Add(CreateElement(GetMeshName(targetMesh), items)); + } + + generatedTextBox.Text = doc.ToString(); + } + + private string GetMeshName(Mesh mesh) => $"{resourceNameTextBox.Text}.{mesh.Name}.{mesh.ExportOrder}"; + + private static XElement CreateElement(string targetName, ClothUtils.Triplet[] data) + { + /* + * + * + * + * + * + * + * + * + * + */ + + string base64 = ClothUtils.Serialize(data); + + return new XElement("node", + new XAttribute("id", "MapValue"), + new XElement("children", + new XElement("node", + new XAttribute("id", "Object"), + new XElement("attribute", new XAttribute("id", "ClosestVertices"), new XAttribute("type", AttributeTypeMaps.IdToType[AttributeType.ScratchBuffer]), new XAttribute("value", base64)), + new XElement("attribute", new XAttribute("id", "Name"), new XAttribute("type", AttributeTypeMaps.IdToType[AttributeType.FixedString]), new XAttribute("value", targetName)), + new XElement("attribute", new XAttribute("id", "NbClosestVertices"), new XAttribute("type", AttributeTypeMaps.IdToType[AttributeType.Int]), new XAttribute("value", data.Length * 3))))); + } + + private static (XElement Root, XElement Children) CreateObject(string physicsName) + { + /* + * + * + * + * + */ + + var children = new XElement("children"); + var root = new XElement("node", + new XAttribute("id", "Object"), + new XElement("attribute", new XAttribute("id", "MapKey"), new XAttribute("type", AttributeTypeMaps.IdToType[AttributeType.FixedString]), new XAttribute("value", physicsName)), + children); + + return (root, children); + } + + [GeneratedRegex(@"_LOD\d+$")] + private static partial Regex LODRegex(); + } +} diff --git a/ConverterApp/ClothPane.resx b/ConverterApp/ClothPane.resx new file mode 100644 index 00000000..86361823 --- /dev/null +++ b/ConverterApp/ClothPane.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + \ No newline at end of file diff --git a/ConverterApp/ConverterAppSettings.cs b/ConverterApp/ConverterAppSettings.cs index e377ce57..3136257e 100644 --- a/ConverterApp/ConverterAppSettings.cs +++ b/ConverterApp/ConverterAppSettings.cs @@ -77,6 +77,8 @@ public DebugPaneSettings Debugging set { debugSettings = value; } } + public ClothPaneSettings Cloth { get; set; } + private Game selectedGame = Game.BaldursGate3; public int SelectedGame @@ -100,6 +102,7 @@ public void SetPropertyChangedEvent(PropertyChangedEventHandler eventHandler) PAK.PropertyChanged += eventHandler; Resources.PropertyChanged += eventHandler; Story.PropertyChanged += eventHandler; + Cloth.PropertyChanged += eventHandler; } public ConverterAppSettings() @@ -110,6 +113,7 @@ public ConverterAppSettings() VirtualTextures = new VirtualTexturesPaneSettings(); Story = new OsirisPaneSettings(); Debugging = new DebugPaneSettings(); + Cloth = new ClothPaneSettings(); } } @@ -343,6 +347,17 @@ public string SavePath } } +public class ClothPaneSettings : SettingsBase +{ + private string inputPath = ""; + + public string InputPath + { + get => inputPath; + set { inputPath = value; OnPropertyChanged(); } + } +} + sealed class PackageVersionConverter : TypeConverter { public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) diff --git a/ConverterApp/MainForm.Designer.cs b/ConverterApp/MainForm.Designer.cs index 6d09e9af..1fdcd31e 100644 --- a/ConverterApp/MainForm.Designer.cs +++ b/ConverterApp/MainForm.Designer.cs @@ -33,19 +33,18 @@ private void InitializeComponent() this.packageTab = new System.Windows.Forms.TabPage(); this.resourceTab = new System.Windows.Forms.TabPage(); this.virtualTextureTab = new System.Windows.Forms.TabPage(); + this.locaTab = new System.Windows.Forms.TabPage(); this.osirisTab = new System.Windows.Forms.TabPage(); this.debugTab = new System.Windows.Forms.TabPage(); + this.clothTab = new System.Windows.Forms.TabPage(); this.gr2Game = new System.Windows.Forms.ComboBox(); this.label7 = new System.Windows.Forms.Label(); - this.locaTab = new System.Windows.Forms.TabPage(); this.tabControl.SuspendLayout(); this.SuspendLayout(); // // tabControl // - this.tabControl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); + this.tabControl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; this.tabControl.Controls.Add(this.gr2Tab); this.tabControl.Controls.Add(this.packageTab); this.tabControl.Controls.Add(this.resourceTab); @@ -53,119 +52,124 @@ private void InitializeComponent() this.tabControl.Controls.Add(this.locaTab); this.tabControl.Controls.Add(this.osirisTab); this.tabControl.Controls.Add(this.debugTab); - this.tabControl.Location = new System.Drawing.Point(16, 52); + this.tabControl.Controls.Add(this.clothTab); + this.tabControl.Location = new System.Drawing.Point(14, 49); this.tabControl.Margin = new System.Windows.Forms.Padding(4); this.tabControl.Name = "tabControl"; this.tabControl.SelectedIndex = 0; - this.tabControl.Size = new System.Drawing.Size(1223, 763); + this.tabControl.Size = new System.Drawing.Size(1070, 715); this.tabControl.TabIndex = 0; // // gr2Tab // - this.gr2Tab.Location = new System.Drawing.Point(4, 25); + this.gr2Tab.Location = new System.Drawing.Point(4, 24); this.gr2Tab.Margin = new System.Windows.Forms.Padding(4); this.gr2Tab.Name = "gr2Tab"; this.gr2Tab.Padding = new System.Windows.Forms.Padding(4); - this.gr2Tab.Size = new System.Drawing.Size(1215, 734); + this.gr2Tab.Size = new System.Drawing.Size(1062, 687); this.gr2Tab.TabIndex = 0; this.gr2Tab.Text = "GR2 Tools"; this.gr2Tab.UseVisualStyleBackColor = true; // // packageTab // - this.packageTab.Location = new System.Drawing.Point(4, 25); + this.packageTab.Location = new System.Drawing.Point(4, 24); this.packageTab.Margin = new System.Windows.Forms.Padding(4); this.packageTab.Name = "packageTab"; this.packageTab.Padding = new System.Windows.Forms.Padding(4); - this.packageTab.Size = new System.Drawing.Size(1215, 734); + this.packageTab.Size = new System.Drawing.Size(1062, 687); this.packageTab.TabIndex = 1; this.packageTab.Text = "PAK / LSV Tools"; this.packageTab.UseVisualStyleBackColor = true; // // resourceTab // - this.resourceTab.Location = new System.Drawing.Point(4, 25); + this.resourceTab.Location = new System.Drawing.Point(4, 24); this.resourceTab.Margin = new System.Windows.Forms.Padding(4); this.resourceTab.Name = "resourceTab"; this.resourceTab.Padding = new System.Windows.Forms.Padding(4); - this.resourceTab.Size = new System.Drawing.Size(1215, 734); + this.resourceTab.Size = new System.Drawing.Size(1062, 687); this.resourceTab.TabIndex = 2; this.resourceTab.Text = "LSX / LSB / LSF / LSJ Tools"; this.resourceTab.UseVisualStyleBackColor = true; // // virtualTextureTab // - this.virtualTextureTab.Location = new System.Drawing.Point(4, 25); + this.virtualTextureTab.Location = new System.Drawing.Point(4, 24); this.virtualTextureTab.Name = "virtualTextureTab"; this.virtualTextureTab.Padding = new System.Windows.Forms.Padding(3); - this.virtualTextureTab.Size = new System.Drawing.Size(1215, 734); + this.virtualTextureTab.Size = new System.Drawing.Size(1062, 687); this.virtualTextureTab.TabIndex = 5; this.virtualTextureTab.Text = "Virtual Textures"; this.virtualTextureTab.UseVisualStyleBackColor = true; // + // locaTab + // + this.locaTab.Location = new System.Drawing.Point(4, 24); + this.locaTab.Name = "locaTab"; + this.locaTab.Padding = new System.Windows.Forms.Padding(3); + this.locaTab.Size = new System.Drawing.Size(1062, 687); + this.locaTab.TabIndex = 6; + this.locaTab.Text = "Localization"; + this.locaTab.UseVisualStyleBackColor = true; + // // osirisTab // - this.osirisTab.Location = new System.Drawing.Point(4, 25); + this.osirisTab.Location = new System.Drawing.Point(4, 24); this.osirisTab.Margin = new System.Windows.Forms.Padding(4); this.osirisTab.Name = "osirisTab"; this.osirisTab.Padding = new System.Windows.Forms.Padding(4); - this.osirisTab.Size = new System.Drawing.Size(1215, 734); + this.osirisTab.Size = new System.Drawing.Size(1062, 687); this.osirisTab.TabIndex = 3; this.osirisTab.Text = "Story (OSI) tools"; this.osirisTab.UseVisualStyleBackColor = true; // // debugTab // - this.debugTab.Location = new System.Drawing.Point(4, 25); + this.debugTab.Location = new System.Drawing.Point(4, 24); this.debugTab.Margin = new System.Windows.Forms.Padding(4); this.debugTab.Name = "debugTab"; - this.debugTab.Size = new System.Drawing.Size(1215, 734); + this.debugTab.Size = new System.Drawing.Size(1062, 687); this.debugTab.TabIndex = 4; this.debugTab.Text = "Savegame Debugging"; this.debugTab.UseVisualStyleBackColor = true; // + // clothTab + // + this.clothTab.Location = new System.Drawing.Point(4, 24); + this.clothTab.Name = "clothTab"; + this.clothTab.Size = new System.Drawing.Size(1062, 687); + this.clothTab.TabIndex = 7; + this.clothTab.Text = "Cloth Tools"; + this.clothTab.UseVisualStyleBackColor = true; + // // gr2Game // this.gr2Game.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; this.gr2Game.FormattingEnabled = true; - this.gr2Game.Items.AddRange(new object[] { - "Divinity: Original Sin (32-bit)", - "Divinity: Original Sin EE (64-bit)", - "Divinity: Original Sin 2 (64-bit)", - "Divinity: Original Sin 2 DE (64-bit)", - "Baldur\'s Gate 3 (64-bit)"}); - this.gr2Game.Location = new System.Drawing.Point(99, 15); + this.gr2Game.Items.AddRange(new object[] { "Divinity: Original Sin (32-bit)", "Divinity: Original Sin EE (64-bit)", "Divinity: Original Sin 2 (64-bit)", "Divinity: Original Sin 2 DE (64-bit)", "Baldur's Gate 3 (64-bit)" }); + this.gr2Game.Location = new System.Drawing.Point(87, 14); this.gr2Game.Margin = new System.Windows.Forms.Padding(4); this.gr2Game.Name = "gr2Game"; - this.gr2Game.Size = new System.Drawing.Size(473, 24); + this.gr2Game.Size = new System.Drawing.Size(414, 23); this.gr2Game.TabIndex = 30; - this.gr2Game.SelectedIndexChanged += new System.EventHandler(this.gr2Game_SelectedIndexChanged); + this.gr2Game.SelectedIndexChanged += this.gr2Game_SelectedIndexChanged; // // label7 // this.label7.AutoSize = true; - this.label7.Location = new System.Drawing.Point(16, 17); + this.label7.Location = new System.Drawing.Point(14, 16); this.label7.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0); this.label7.Name = "label7"; - this.label7.Size = new System.Drawing.Size(47, 16); + this.label7.Size = new System.Drawing.Size(41, 15); this.label7.TabIndex = 29; this.label7.Text = "Game:"; // - // locaTab - // - this.locaTab.Location = new System.Drawing.Point(4, 25); - this.locaTab.Name = "locaTab"; - this.locaTab.Padding = new System.Windows.Forms.Padding(3); - this.locaTab.Size = new System.Drawing.Size(1215, 734); - this.locaTab.TabIndex = 6; - this.locaTab.Text = "Localization"; - this.locaTab.UseVisualStyleBackColor = true; - // // MainForm // - this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F); + this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(1255, 826); + this.ClientSize = new System.Drawing.Size(1098, 774); this.Controls.Add(this.gr2Game); this.Controls.Add(this.label7); this.Controls.Add(this.tabControl); @@ -175,7 +179,6 @@ private void InitializeComponent() this.tabControl.ResumeLayout(false); this.ResumeLayout(false); this.PerformLayout(); - } #endregion @@ -189,6 +192,7 @@ private void InitializeComponent() private System.Windows.Forms.Label label7; private System.Windows.Forms.TabPage debugTab; private System.Windows.Forms.TabPage locaTab; + private System.Windows.Forms.TabPage clothTab; } } diff --git a/ConverterApp/MainForm.cs b/ConverterApp/MainForm.cs index fd173ebd..83ed8a4b 100644 --- a/ConverterApp/MainForm.cs +++ b/ConverterApp/MainForm.cs @@ -16,6 +16,7 @@ public sealed partial class MainForm : Form, ISettingsDataSource LocalizationPane localizationPane; OsirisPane osirisPane; DebugPane debugPane; + ClothPane clothPane; public ConverterAppSettings Settings { get; set; } @@ -90,6 +91,14 @@ public MainForm() }; debugTab.Controls.Add(debugPane); + clothPane = new ClothPane(this) + { + Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right, + Size = clothTab.ClientSize, + Padding = new Padding(4), + }; + clothTab.Controls.Add(clothPane); + Text += $" (LSLib v{Common.LibraryVersion()})"; gr2Game.SelectedIndex = gr2Game.Items.Count - 1;