private static SMVertex GetSMVertexFromTriangle(MilkShape milkShape, List <MilkShapeBoneMap> milkShapeBoneMap, ms3d_triangle_t t, int triangleVertex)
        {
            // Only focusing on one of the three triangle vertices
            var triVertIdx = t.VertexIndices[triangleVertex];

            return(new SMVertex
            {
                // Get position of vertex
                Position = milkShape.Vertices[triVertIdx].Position,

                // Get normal of vertex (stored with triangle in Milkshape for some reason)
                Normal = t.VertexNormals[triangleVertex],

                // Texture coordinate needs "reversal" since I "reversed" it earlier
                // Also the (unused?) Z coordinate always appears to reflect the vertex Z, although
                // I don't know if that's a requirement or a quirk, but either way...
                TexCoord = new Vector3(t.TextureCoordinates[triangleVertex].X, 1.0f - t.TextureCoordinates[triangleVertex].Y, milkShape.Vertices[triVertIdx].Position.Z),

                // This uses the bone map just in case the MilkShape indexes don't line up with
                // the intended indexes...
                BoneIndices = milkShape.Vertices[triVertIdx].BoneIdsAndWeights
                              .Where(biw => biw.BoneId != -1)
                              .Select(biw => milkShapeBoneMap.Where(bm => bm.MilkShapeJointIndex == biw.BoneId).Single().SMBoneIndex)
                              .ToArray(),

                // Finally the MilkShape weights are stored as 0-100 byte-sized values, and we
                // must convert back (granted with loss)
                Weights = milkShape.Vertices[triVertIdx].BoneIdsAndWeights
                          .Where(biw => biw.BoneId != -1)
                          .Select(biw => biw.Weight / 100.0f).ToArray()
            });
        }
Esempio n. 2
0
        private static void ExportImportCGX(OperationInfo opInfo, string[] args)
        {
            // The base, input, and output files
            var baseFile = args[1];
            var inFile   = args[args.Length == 4 ? 2 : 1];
            var outFile  = args[args.Length == 4 ? 3 : 2];

            var inFileExt  = Path.GetExtension(inFile).ToLower();
            var outFileExt = Path.GetExtension(outFile).ToLower();

            // As we must have a BCRES for "backing", it will be loaded either way
            // before we do anything else.
            CGFX            cgfxBase;
            SimplifiedModel simplifiedModel;

            using (var br = new BinaryReader(File.Open(baseFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)))
            {
                // Load the BCRES (CGFX)
                cgfxBase = CGFX.Load(br);

                // Create a "SimplifiedModel" out of the base CGFX
                simplifiedModel = new SimplifiedModel(cgfxBase);
            }

            try
            {
                Console.WriteLine($"Converting {inFile} to {outFile}...");

                // If we're converting FROM a BCRES TO another file type...
                if (opInfo.Operation == Operations.ExportFromCGFX)
                {
                    // Convert it and save it to the requested file type
                    using (var bw = new BinaryWriter(File.Open(outFile, FileMode.Create)))
                    {
                        if (outFileExt == ".ms3d")
                        {
                            var milkShape = MilkShapeConverter.ToMilkShape(simplifiedModel);
                            milkShape.Save(bw);
                        }
                        else
                        {
                            throw new NotImplementedException($"Unsupported Destination filetype {outFileExt}");
                        }
                    }

                    // Dump textures
                    if (simplifiedModel.Textures != null)
                    {
                        var dumpTextureDir = Path.GetDirectoryName(outFile);
                        foreach (var texture in simplifiedModel.Textures)
                        {
                            Console.WriteLine($"Exporting texture {texture.Name}...");
                            texture.TextureBitmap.Save(Path.Combine(dumpTextureDir, texture.Name + ".png"));
                        }
                    }

                    Console.WriteLine();
                    Console.WriteLine("Done.");
                    Console.WriteLine();
                    Console.WriteLine("Note, if there are any textures you do NOT want to modify, you can delete the respective PNG files");
                    Console.WriteLine("and they'll be skipped on import.");
                    Console.WriteLine();
                    Console.WriteLine();
                }
                else
                {
                    // Converting from a model file TO a BCRES...

                    if (baseFile == outFile)
                    {
                        // The only reason I'm actually blocking this is lack of trust in that if
                        // there's a bug that causes data loss, it may compound over subsequent
                        // writes. So until I trust this code more, we'll force an "unmodified" base
                        // to be used. (Of course, I can't KNOW the base is "unmodified"...)
                        throw new InvalidOperationException("Currently writing to the base BCRES/CGFX file is prohibited. May lift this restriction in the future.");
                    }

                    using (var br = new BinaryReader(File.Open(inFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)))
                        using (var bw = new BinaryWriter(File.Open(outFile, FileMode.Create)))
                        {
                            if (inFileExt == ".ms3d")
                            {
                                var milkShape = MilkShape.Load(br);
                                MilkShapeConverter.FromMilkShape(simplifiedModel, milkShape);
                            }
                            else
                            {
                                throw new NotImplementedException($"Unsupported Source filetype {inFileExt}");
                            }

                            // Import textures
                            var dumpTextureDir = Path.GetDirectoryName(inFile);

                            // Find WHAT textures we have (user has option to not include any or all)
                            if (simplifiedModel.Textures != null)
                            {
                                var importTextures = simplifiedModel.Textures
                                                     .Select(t => new
                                {
                                    Filename = Path.Combine(dumpTextureDir, t.Name + ".png"),
                                    Texture  = t
                                })
                                                     .Where(t => File.Exists(t.Filename))
                                                     .Select(t => new
                                {
                                    t.Texture.Name,
                                    TextureBitmap = Image.FromFile(t.Filename)
                                })
                                                     .ToList();

                                foreach (var texture in importTextures)
                                {
                                    Console.WriteLine($"Importing texture {texture.Name}...");

                                    // Corresponding texture in SimplifiedModel
                                    var smTexture = simplifiedModel.Textures.Where(t => t.Name == texture.Name).Single();
                                    smTexture.TextureBitmap = (Bitmap)texture.TextureBitmap;
                                }
                            }

                            simplifiedModel.RecomputeVertexNormals();
                            simplifiedModel.ApplyChanges();
                            cgfxBase.Save(bw);
                        }


                    Console.WriteLine();
                    Console.WriteLine("Done.");
                    Console.WriteLine();
                }
            }
            catch
            {
                if (File.Exists(outFile))
                {
                    File.Delete(outFile);
                }

                throw;
            }
        }
Esempio n. 3
0
        public static MilkShape Load(BinaryReader br)
        {
            var ms      = new MilkShape();
            var utility = new Utility(br, null, Endianness.Little);     // Milkshape is tied to Win32, so definitely little endian

            // First comes the header (sizeof(ms3d_header_t) == 14)
            var magic = ReadString(utility, 10);

            if (magic != Magic)
            {
                throw new InvalidOperationException($"Milkshape Load: Bad magic, expected {Magic}, got {magic}");
            }

            var version = utility.ReadU32();

            if (version != 4)
            {
                throw new InvalidOperationException($"Milkshape Load: Unsupported version, expected 4, got {version}");
            }

            // Then comes the number of vertices
            var nNumVertices = utility.ReadU16();

            // Then come nNumVertices times ms3d_vertex_t structs (sizeof(ms3d_vertex_t) == 15)
            ms.Vertices = new List <ms3d_vertex_t>(nNumVertices);
            for (var v = 0; v < nNumVertices; v++)
            {
                var vertex = new ms3d_vertex_t();
                vertex.Flags    = (MilkshapeObjectFlags)utility.ReadByte(); // SELECTED | SELECTED2 | HIDDEN
                vertex.Position = Vector3.Read(utility);

                // NOTE: I'm merging the different specs / extended attributes here; will look confusing
                vertex.BoneIdsAndWeights[0].BoneId = utility.ReadSByte();

                vertex.ReferenceCount = utility.ReadByte();

                ms.Vertices.Add(vertex);
            }

            // Then comes the number of triangles
            var nNumTriangles = utility.ReadU16(); // 2 bytes

            // Then come nNumTriangles times ms3d_triangle_t structs (sizeof(ms3d_triangle_t) == 70)
            ms.Triangles = new List <ms3d_triangle_t>(nNumTriangles);
            for (var t = 0; t < nNumTriangles; t++)
            {
                ms.Triangles.Add(new ms3d_triangle_t
                {
                    Flags              = (MilkshapeObjectFlags)utility.ReadU16(), // SELECTED | SELECTED2 | HIDDEN
                    VertexIndices      = utility.ReadU16Ints(3),
                    VertexNormals      = new[] { Vector3.Read(utility), Vector3.Read(utility), Vector3.Read(utility) },
                    TextureCoordinates = ReadTextureCoordinates(utility),
                    SmoothingGroup     = utility.ReadByte(),                        // 1 - 32
                    GroupIndex         = utility.ReadByte()                         //
                });
            }

            // Then comes the number of groups
            var nNumGroups = utility.ReadU16(); // 2 bytes

            // Then come nNumGroups times groups (the sizeof a group is dynamic, because of triangleIndices is numtriangles long)
            ms.Groups = new List <ms3d_group_t>(nNumGroups);
            for (var g = 0; g < nNumGroups; g++)
            {
                var group = new ms3d_group_t();

                group.Flags = (MilkshapeObjectFlags)utility.ReadByte();     // SELECTED | HIDDEN
                group.Name  = ReadString(utility, 32);

                var numtriangles = utility.ReadU16();
                group.TriangleIndices = utility.ReadU16Ints(numtriangles);
                group.MaterialIndex   = utility.ReadSByte();

                ms.Groups.Add(group);
            }

            // number of materials
            var nNumMaterials = utility.ReadU16(); // 2 bytes

            // Then come nNumMaterials times ms3d_material_t structs (sizeof(ms3d_material_t) == 361)
            ms.Materials = new List <ms3d_material_t>(nNumMaterials);
            for (var m = 0; m < nNumMaterials; m++)
            {
                ms.Materials.Add(new ms3d_material_t
                {
                    Name         = ReadString(utility, 32),
                    Ambient      = utility.ReadFloats(4),
                    Diffuse      = utility.ReadFloats(4),
                    Specular     = utility.ReadFloats(4),
                    Emissive     = utility.ReadFloats(4),
                    Shininess    = utility.ReadFloat(),
                    Transparency = utility.ReadFloat(),
                    Mode         = utility.ReadSByte(),

                    // NOTE: Examining a file written by MilkShape, I saw garbage beyond
                    // the null terminator of these strings. Harmless, just FYI.
                    Texture  = ReadString(utility, 128),
                    Alphamap = ReadString(utility, 128)
                });
            }

            // save some keyframer data
            ms.fAnimationFPS = utility.ReadFloat();
            ms.fCurrentTime  = utility.ReadFloat();
            ms.iTotalFrames  = utility.ReadI32();

            // number of joints
            var nNumJoints = utility.ReadU16(); // 2 bytes

            // Then come nNumJoints joints (the size of joints are dynamic, because each joint has a differnt count of keys
            ms.Joints = new List <ms3d_joint_t>(nNumJoints);
            for (var j = 0; j < nNumJoints; j++)
            {
                var joint = new ms3d_joint_t();

                joint.Flags      = (MilkshapeObjectFlags)utility.ReadByte();
                joint.Name       = ReadString(utility, 32);
                joint.ParentName = ReadString(utility, 32);
                joint.Rotation   = Vector3.Read(utility);
                joint.Position   = Vector3.Read(utility);

                var numKeyFramesRot   = utility.ReadU16();
                var numKeyFramesTrans = utility.ReadU16();

                joint.KeyFramesRot = new ms3d_keyframe_rot_t[numKeyFramesRot];
                for (var r = 0; r < numKeyFramesRot; r++)
                {
                    joint.KeyFramesRot[r] = new ms3d_keyframe_rot_t
                    {
                        Time     = utility.ReadFloat(),
                        Rotation = Vector3.Read(utility)
                    };
                }

                joint.KeyFramesTrans = new ms3d_keyframe_pos_t[numKeyFramesTrans];
                for (var t = 0; t < numKeyFramesTrans; t++)
                {
                    joint.KeyFramesTrans[t] = new ms3d_keyframe_pos_t
                    {
                        Time     = utility.ReadFloat(),
                        Position = Vector3.Read(utility)
                    };
                }

                ms.Joints.Add(joint);
            }

            try
            {
                // subVersion specifying whether comment data exists
                var subVersion = utility.ReadU32();

                if (subVersion == 1)
                {
                    // Group comments
                    ReadComments(utility, ms.Groups);

                    // Material comments
                    ReadComments(utility, ms.Materials);

                    // Joint comments
                    ReadComments(utility, ms.Joints);

                    // Then comes the number of model comments, which is always 0 or 1
                    ReadComments(utility, new[] { ms }, true);
                }

                // subVersion specifying whether extended vertex data exists and to what extent
                subVersion = utility.ReadU32();

                if (subVersion > 0)
                {
                    // Then comes nNumVertices times ms3d_vertex_ex_t structs (sizeof(ms3d_vertex_ex_t) == 14)

                    // NOTE: I'm merging extended vertex data spec stuff from this mess:
                    //    sbyte[] boneIds = new sbyte[3];                                    // index of joint or -1, if -1, then that weight is ignored, since subVersion 1
                    //    byte[] weights = new byte[3];                                    // vertex weight ranging from 0 - 100, last weight is computed by 1.0 - sum(all weights), since subVersion 1
                    //                                                    // weight[0] is the weight for boneId in ms3d_vertex_t
                    //                                                    // weight[1] is the weight for boneIds[0]
                    //                                                    // weight[2] is the weight for boneIds[1]
                    //                                                    // 1.0f - weight[0] - weight[1] - weight[2] is the weight for boneIds[2]

                    // NOTE: "extra" depends in subVersion; 1 element if 2, 2 elements if 3
                    //    uint[] extra = new uint[2];									// vertex extra, which can be used as color or anything else, since subVersion 3

                    for (var v = 0; v < nNumVertices; v++)
                    {
                        var vertex = ms.Vertices[v];

                        // These are ADDITIONAL bone Ids
                        vertex.BoneIdsAndWeights[1].BoneId = utility.ReadSByte();
                        vertex.BoneIdsAndWeights[2].BoneId = utility.ReadSByte();
                        vertex.BoneIdsAndWeights[3].BoneId = utility.ReadSByte();

                        // These are WEIGHTS which were previously unavailable
                        vertex.BoneIdsAndWeights[0].Weight = utility.ReadByte();
                        vertex.BoneIdsAndWeights[1].Weight = utility.ReadByte();
                        vertex.BoneIdsAndWeights[2].Weight = utility.ReadByte();

                        // Final bone weight is computed -- NOTE, spec says 1.0 - [...], but I think it meant 100
                        vertex.BoneIdsAndWeights[3].Weight = (byte)(100 - vertex.BoneIdsAndWeights[0].Weight - vertex.BoneIdsAndWeights[1].Weight - vertex.BoneIdsAndWeights[2].Weight);

                        // How much "extra" data is here depends on subVersion...
                        var extraCount = subVersion - 1;
                        vertex.Extra = utility.ReadUInts(extraCount);
                    }
                }

                // subVersion specifying whether joints have color
                subVersion = utility.ReadU32();

                if (subVersion == 1)
                {
                    for (var j = 0; j < nNumJoints; j++)
                    {
                        var joint = ms.Joints[j];

                        joint.Color = new ColorFloat
                        {
                            R = utility.ReadFloat(),
                            G = utility.ReadFloat(),
                            B = utility.ReadFloat(),
                            A = 1.0f    // Not stored
                        };
                    }
                }

                // subVersion specifying whether model extended data exists
                subVersion = utility.ReadU32();

                if (subVersion == 1)
                {
                    ms.jointSize        = utility.ReadFloat(); // joint size, since subVersion == 1
                    ms.transparencyMode = utility.ReadI32();   // 0 = simple, 1 = depth buffered with alpha ref, 2 = depth sorted triangles, since subVersion == 1
                    ms.alphaRef         = utility.ReadFloat(); // alpha reference value for transparencyMode = 1, since subVersion == 1
                }
            }
            catch (IndexOutOfRangeException)
            {
                // This is a dirty hack because any file that doesn't have the extended data
                // will throw IndexOutOfRangeException but I really should be doing EOF checks
            }

            return(ms);
        }
        // Gets or generates an SMVertex for a vertex on a MilkShape triangle
        private static ushort GetLocalVertexForMSTriangleVertex(List <MilkShapeTempVertex> milkShapeTempVertices, MilkShape milkShape, List <MilkShapeBoneMap> milkShapeBoneMap, ms3d_triangle_t t, int triangleVertex)
        {
            // This will provide the "close enough" rating of texture coordinates
            // to decide that a vertex with the same MilkShape index as well as
            // "close enough" texture coordinates is the same overall vertex.
            const float texCoordEpsilon = 1.0f / 128.0f;    // This is assuming that we're dealing with textures 128x128 and under, which is probably true

            // Get the texture coordinates used by this vertex of the triangle,
            // as it will be part of the consideration of "sameness" of other vertices
            var triangleVertexTexCoord = t.TextureCoordinates[triangleVertex];

            // The primary consideration is MilkShape's own vertex index
            var triangleVertexIndex = t.VertexIndices[triangleVertex];

            var resultVertex = milkShapeTempVertices
                               .Where(tv =>

                                      // Must come from same vertex in MilkShape's pool...
                                      tv.MilkShapeVertexIndex == triangleVertexIndex &&

                                      // ... and be "close enough" with the texture coordinates
                                      Math.Abs(triangleVertexTexCoord.X - tv.SMVertex.TexCoord.X) < texCoordEpsilon &&
                                      Math.Abs((1.0f - triangleVertexTexCoord.Y) - tv.SMVertex.TexCoord.Y) < texCoordEpsilon
                                      ).SingleOrDefault();

            if (resultVertex == null)
            {
                // If we don't have one quite like this, then we need to create it!
                resultVertex = new MilkShapeTempVertex
                {
                    MilkShapeVertexIndex = triangleVertexIndex,
                    MeshLocalVertexIndex = milkShapeTempVertices.Count,
                    SMVertex             = GetSMVertexFromTriangle(milkShape, milkShapeBoneMap, t, triangleVertex)
                };

                milkShapeTempVertices.Add(resultVertex);
            }

            return((ushort)resultVertex.MeshLocalVertexIndex);
        }
        // NOTE: SimplifiedModel (based on the origin BCRES) is passed in because we can't
        // guarantee that the model edited file has ALL of the data it originally contained,
        // just the same as the SimplifiedModel isn't enough by itself and still needs the
        // origin BCRES to generate all other data!
        public static void FromMilkShape(SimplifiedModel simplifiedModel, MilkShape milkShape)
        {
            // Take the MilkShape stored bones and remap them to the CGFX bones.
            var milkShapeBoneMap = simplifiedModel.Bones
                                   .Select(b => new
            {
                b.Name,
                MilkShapeJoint = milkShape.Joints.Where(j => j.Name == b.Name),
                SMBone         = b
            })
                                   .Select(b => new MilkShapeBoneMap
            {
                Name = b.Name,
                MilkShapeJointIndex = (b.MilkShapeJoint.Count() == 1) ? milkShape.Joints.IndexOf(b.MilkShapeJoint.Single()) : -1,
                SMBoneIndex         = Array.IndexOf(simplifiedModel.Bones, b.SMBone)
            })
                                   .ToList();

            var missingBones = milkShapeBoneMap.Where(msbm => msbm.SMBoneIndex == -1);

            if (missingBones.Any())
            {
                throw new KeyNotFoundException($"The following required bones are missing from or ambiguous in the MilkShape file: {string.Join(", ", missingBones.Select(b => b.Name))}");
            }

            // We need to figure out the matching MilkShape groups to the original meshes.
            // There must be a group for each mesh, identified by the given name of "meshN"
            for (var m = 0; m < simplifiedModel.Meshes.Length; m++)
            {
                var mesh = simplifiedModel.Meshes[m];

                // Since it's still useful to HAVE groups (for showing/hiding mainly), I'm allowing
                // MilkShape groups to be named in ways that will group them together as such:
                //  mesh0       -- Basic name of mesh index 0
                //  mesh0-top   -- Also should be part of mesh index 0, but is the "top" geometry of something etc.
                //
                // ... so in this example, both "mesh0" and "mesh0-top" will be merged together into
                // Mesh Index 0, so you can still use groups in a useful way and reference back the
                // eventual CGFX mesh it's actually supposed to be a part of...
                var meshName = $"mesh{m}";
                var milkShapeGroupMatches = milkShape.Groups.Where(g => g.Name.StartsWith(meshName));
                if (!milkShapeGroupMatches.Any())
                {
                    throw new KeyNotFoundException($"Required MilkShape group {meshName} not found");
                }

                // CGFX stores vertices per mesh, but the vertices were all lumped together for MilkShape.
                // To reverse the process, we need to find out what vertices were used by all triangles in
                // this milkShapeGroup and that becomes our list of vertices for this mesh. Of course, we
                // still need to have a MilkShape vertex -> CGFX mesh vertex map to translate the triangles.

                // TODO -- Groups in MilkShape define a MaterialIndex, which isn't currently used, but if
                // it were, having different materials across the merged groups wouldn't work. This will
                // call the user out if they do that and eventually someday it might really matter.
                if (milkShapeGroupMatches.Select(g => g.MaterialIndex).Distinct().Count() > 1)
                {
                    throw new InvalidOperationException($"Groups {string.Join(", ", milkShapeGroupMatches.Select(g => g.Name))} are to be merged into {meshName} but they have different materials assigned to them! This is not supported.");
                }

                var milkShapeGroupsTriangleIndices = milkShapeGroupMatches.SelectMany(g => g.TriangleIndices);

                // Triangles in this group
                var triangles = milkShapeGroupsTriangleIndices
                                .Select(ti => milkShape.Triangles[ti])
                                .ToList();

                // The "easy" concept is that we just take the triangles and selected distinct vertexes
                // out of them based on the MilkShape vertex index...
                // Unfortunately for us, MilkShape does texture coordinates PER TRIANGLE VERTEX,
                // not per vertex, which means to PROPERLY represent it in the mesh we need to "split"
                // vertices that would be otherwise common so they can hold the unique texture coordinates.
                var SMTriangles = new List <SMTriangle>();
                var vertices    = new List <MilkShapeTempVertex>();
                foreach (var triangle in triangles)
                {
                    // Get vertices for the triangle's three vertices
                    SMTriangles.Add(new SMTriangle
                    {
                        v1 = GetLocalVertexForMSTriangleVertex(vertices, milkShape, milkShapeBoneMap, triangle, 0),
                        v2 = GetLocalVertexForMSTriangleVertex(vertices, milkShape, milkShapeBoneMap, triangle, 1),
                        v3 = GetLocalVertexForMSTriangleVertex(vertices, milkShape, milkShapeBoneMap, triangle, 2)
                    });
                }

                // Add the generated triangles back in
                mesh.Triangles.Clear();
                mesh.Triangles.AddRange(SMTriangles);

                // As a benefit, the "vertices" collection now contains the set of vertices required to
                // reconstruct the mesh!
                var SMVertices = vertices.OrderBy(v => v.MeshLocalVertexIndex).Select(v => v.SMVertex).ToList();

                // If the model has a skeleton... then WHOA there! Before we throw them back into the
                // collection, they need to be un-transformed back to the neutral bone position!!
                var nativeMeshUseColorVerts = mesh.Vertices.Where(v => !ReferenceEquals(v.Color, null)).Any();
                foreach (var vertex in SMVertices)
                {
                    vertex.Position = TransformPositionByBone(vertex, vertex.BoneIndices, simplifiedModel.Bones, true);

                    // Also, while we're here, if the original model supported vertex Color attributes
                    // (which MilkShape does NOT), we'll just patch in an all-white color. It's not the
                    // best but I don't have a lot of option with this format...
                    if (nativeMeshUseColorVerts)
                    {
                        vertex.Color = new Vector4(1, 1, 1, 1);
                    }
                }

                mesh.Vertices.Clear();
                mesh.Vertices.AddRange(SMVertices);
            }
        }
        public static MilkShape ToMilkShape(SimplifiedModel sm)
        {
            var milkShape = new MilkShape();

            // Convert the skeleton
            var bones = sm.Bones.Select(b => new ms3d_joint_t
            {
                Name           = b.Name,
                ParentName     = b.ParentName,
                Rotation       = b.Rotation,
                Position       = b.Translation,
                KeyFramesRot   = new ms3d_keyframe_rot_t[0],
                KeyFramesTrans = new ms3d_keyframe_pos_t[0]
            });

            var allVertices  = new List <ms3d_vertex_t>();
            var allTriangles = new List <ms3d_triangle_t>();
            var allMaterials = new List <ms3d_material_t>();
            var allGroups    = new List <ms3d_group_t>();

            foreach (var mesh in sm.Meshes)
            {
                // Get current vertex offset as we're accumulating them
                var vertexMeshOffset = allVertices.Count;

                // Get current triangle offset as we're grouping them
                var triangleOffset = allTriangles.Count;

                // Vertices belonging to this mesh
                var vertices = mesh.Vertices
                               .Select(v => new ms3d_vertex_t
                {
                    Position          = TransformPositionByBone(v, v.BoneIndices, sm.Bones, false),
                    BoneIdsAndWeights = GetBoneIndiciesAndWeights(v.BoneIndices, v.Weights),
                });

                allVertices.AddRange(vertices);

                // Triangles belonging to this mesh
                var triangles = mesh.Triangles
                                .Select(t => new ms3d_triangle_t
                {
                    VertexIndices = new ushort[]
                    {
                        (ushort)(vertexMeshOffset + t.v1),
                        (ushort)(vertexMeshOffset + t.v2),
                        (ushort)(vertexMeshOffset + t.v3)
                    },

                    VertexNormals = new Vector3[]
                    {
                        mesh.Vertices[t.v1].Normal,
                        mesh.Vertices[t.v2].Normal,
                        mesh.Vertices[t.v3].Normal
                    },

                    TextureCoordinates = new Vector2[]
                    {
                        // NOTE: Textures are "upside-down", so this reverses them... make sure to undo that when saving...
                        new Vector2(mesh.Vertices[t.v1].TexCoord.X, 1.0f - mesh.Vertices[t.v1].TexCoord.Y),
                        new Vector2(mesh.Vertices[t.v2].TexCoord.X, 1.0f - mesh.Vertices[t.v2].TexCoord.Y),
                        new Vector2(mesh.Vertices[t.v3].TexCoord.X, 1.0f - mesh.Vertices[t.v3].TexCoord.Y)
                    },

                    GroupIndex = (byte)allGroups.Count
                });

                allTriangles.AddRange(triangles);

                // Generate materials from the meshes
                sbyte materialIndex = -1;
                if (mesh.Texture != null)
                {
                    materialIndex = (sbyte)allMaterials.Count;
                    allMaterials.Add(new ms3d_material_t
                    {
                        Name    = mesh.Texture.Name,
                        Texture = mesh.Texture.Name + ".png"
                    });
                }

                // We're going to make the "mesh" into a "group" in MilkShape speak
                var group = new ms3d_group_t
                {
                    Name            = $"mesh{allGroups.Count}",
                    TriangleIndices = Enumerable.Range(triangleOffset, triangles.Count()).Select(i => (ushort)i).ToArray(),
                    MaterialIndex   = materialIndex
                };

                allGroups.Add(group);
            }

            milkShape.Vertices.AddRange(allVertices);
            milkShape.Triangles.AddRange(allTriangles);
            milkShape.Groups.AddRange(allGroups);
            milkShape.Materials.AddRange(allMaterials);
            milkShape.Joints.AddRange(bones);

            return(milkShape);
        }