diff --git a/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml b/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml index 082f24bb67..d9602318a4 100644 --- a/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml +++ b/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml @@ -28,6 +28,7 @@ + diff --git a/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml.cs b/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml.cs index d690569eef..b27ded1ae4 100644 --- a/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml.cs +++ b/sources/editor/Stride.Assets.Presentation/Templates/ModelAssetTemplateWindow.xaml.cs @@ -43,6 +43,7 @@ public DummyReferenceContainer() private bool showDeduplicateMaterialsCheckBox = true; private bool showFbxDedupeNotSupportedWarning = false; private bool deduplicateMaterials = true; + private bool splitHierarchy = true; private bool importTextures = true; private bool importAnimations = true; private bool importSkeleton = true; @@ -65,6 +66,8 @@ public ImportModelFromFileViewModel(IViewModelServiceProvider serviceProvider) public bool ImportAnimations { get { return importAnimations; } set { SetValue(ref importAnimations, value); } } + public bool SplitHierarchy { get { return splitHierarchy; } set { SetValue(ref splitHierarchy, value); } } + public bool ImportSkeleton { get { return importSkeleton; } set { SetValue(ref importSkeleton, value); } } public bool DontImportSkeleton { get { return dontImportSkeleton; } set { SetValue(ref dontImportSkeleton, value); } } diff --git a/sources/editor/Stride.Assets.Presentation/Templates/ModelFromFileTemplateGenerator.cs b/sources/editor/Stride.Assets.Presentation/Templates/ModelFromFileTemplateGenerator.cs index 2a354fe8dc..f561cf37bd 100644 --- a/sources/editor/Stride.Assets.Presentation/Templates/ModelFromFileTemplateGenerator.cs +++ b/sources/editor/Stride.Assets.Presentation/Templates/ModelFromFileTemplateGenerator.cs @@ -2,21 +2,26 @@ // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; +using Stride.Assets.Entities; +using Stride.Assets.Materials; +using Stride.Assets.Models; +using Stride.Assets.Textures; +using Stride.Core; using Stride.Core.Assets; using Stride.Core.Assets.Editor.Services; using Stride.Core.Assets.Editor.ViewModel; using Stride.Core.Assets.Templates; -using Stride.Core; using Stride.Core.IO; -using Stride.Core.Serialization; -using Stride.Core.Settings; +using Stride.Core.Mathematics; using Stride.Core.Presentation.Services; using Stride.Core.Presentation.Windows; -using Stride.Assets.Materials; -using Stride.Assets.Models; -using Stride.Assets.Textures; +using Stride.Core.Serialization; +using Stride.Core.Settings; +using Stride.Engine; +using Stride.Importer.Common; using Stride.Rendering; namespace Stride.Assets.Presentation.Templates @@ -29,6 +34,7 @@ public static class ModelFromFileTemplateSettings public static SettingsKey ImportAnimations = new SettingsKey("Templates/ModelFromFile/ImportAnimations", PackageUserSettings.SettingsContainer, true); public static SettingsKey ImportSkeleton = new SettingsKey("Templates/ModelFromFile/ImportSkeleton", PackageUserSettings.SettingsContainer, true); public static SettingsKey DefaultSkeleton = new SettingsKey("Templates/ModelFromFile/DefaultSkeleton", PackageUserSettings.SettingsContainer, AssetId.Empty); + public static SettingsKey SplitHierarchy = new SettingsKey("Templates/ModelFromFile/SplitHierarchy", PackageUserSettings.SettingsContainer, false); } public class ModelFromFileTemplateGenerator : AssetFromFileTemplateGenerator @@ -43,6 +49,7 @@ public class ModelFromFileTemplateGenerator : AssetFromFileTemplateGenerator protected static readonly PropertyKey ImportAnimationsKey = new PropertyKey("ImportAnimations", typeof(ModelFromFileTemplateGenerator)); protected static readonly PropertyKey ImportSkeletonKey = new PropertyKey("ImportSkeleton", typeof(ModelFromFileTemplateGenerator)); protected static readonly PropertyKey SkeletonToUseKey = new PropertyKey("SkeletonToUse", typeof(ModelFromFileTemplateGenerator)); + protected static readonly PropertyKey SplitHierarchyKey = new PropertyKey("SplitHierarchy", typeof(ModelFromFileTemplateGenerator)); public override bool IsSupportingTemplate(TemplateDescription templateDescription) { @@ -72,7 +79,8 @@ protected override async Task PrepareAssetCreation(AssetTemplateGeneratorP DeduplicateMaterials = ModelFromFileTemplateSettings.DeduplicateMaterials.GetValue(profile, true), ImportTextures = ModelFromFileTemplateSettings.ImportTextures.GetValue(profile, true), ImportAnimations = ModelFromFileTemplateSettings.ImportAnimations.GetValue(profile, true), - ImportSkeleton = ModelFromFileTemplateSettings.ImportSkeleton.GetValue(profile, true) + ImportSkeleton = ModelFromFileTemplateSettings.ImportSkeleton.GetValue(profile, true), + SplitHierarchy = ModelFromFileTemplateSettings.SplitHierarchy.GetValue(profile, false) } }; @@ -97,6 +105,7 @@ protected override async Task PrepareAssetCreation(AssetTemplateGeneratorP parameters.Tags.Set(ImportAnimationsKey, window.Parameters.ImportAnimations); parameters.Tags.Set(ImportSkeletonKey, window.Parameters.ImportSkeleton); parameters.Tags.Set(SkeletonToUseKey, skeletonToReuse); + parameters.Tags.Set(SplitHierarchyKey, window.Parameters.SplitHierarchy); // Save settings ModelFromFileTemplateSettings.ImportMaterials.SetValue(window.Parameters.ImportMaterials, profile); @@ -104,6 +113,7 @@ protected override async Task PrepareAssetCreation(AssetTemplateGeneratorP ModelFromFileTemplateSettings.ImportTextures.SetValue(window.Parameters.ImportTextures, profile); ModelFromFileTemplateSettings.ImportAnimations.SetValue(window.Parameters.ImportAnimations, profile); ModelFromFileTemplateSettings.ImportSkeleton.SetValue(window.Parameters.ImportSkeleton, profile); + ModelFromFileTemplateSettings.SplitHierarchy.SetValue(window.Parameters.SplitHierarchy, profile); skeletonId = AttachedReferenceManager.GetAttachedReference(skeletonToReuse)?.Id ?? AssetId.Empty; ModelFromFileTemplateSettings.DefaultSkeleton.SetValue(skeletonId, profile); parameters.Package.UserSettings.Save(); @@ -123,9 +133,11 @@ protected override IEnumerable CreateAssets(AssetTemplateGeneratorPar var importAnimations = parameters.Tags.Get(ImportAnimationsKey); var importSkeleton = parameters.Tags.Get(ImportSkeletonKey); var skeletonToReuse = parameters.Tags.Get(SkeletonToUseKey); + var splitHierarchy = parameters.Tags.Get(SplitHierarchyKey); var importParameters = new AssetImporterParameters { Logger = parameters.Logger }; importParameters.InputParameters.Set(ModelAssetImporter.DeduplicateMaterialsKey, deduplicateMaterials); + importParameters.InputParameters.Set(ModelAssetImporter.SplitHierarchyKey, splitHierarchy); importParameters.SelectedOutputTypes.Add(typeof(ModelAsset), true); importParameters.SelectedOutputTypes.Add(typeof(MaterialAsset), importMaterials); importParameters.SelectedOutputTypes.Add(typeof(TextureAsset), importTextures); @@ -144,7 +156,80 @@ protected override IEnumerable CreateAssets(AssetTemplateGeneratorPar continue; } - var assets = importer.Import(file, importParameters).Select(x => new AssetItem(UPath.Combine(parameters.TargetLocation, x.Location), x.Asset)).ToList(); + var assets = importer.Import(file, importParameters) + .Select(x => new AssetItem(UPath.Combine(parameters.TargetLocation, x.Location), x.Asset)) + .ToList(); + + var baseName = file.GetFileNameWithoutExtension(); + + //Find, unique name each item before building prefab + if (splitHierarchy) + { + var entityInfo = importer.GetEntityInfo(file, parameters.Logger, importParameters); + if (entityInfo?.Models.Count > 0) + { + var firstModelItem = assets.FirstOrDefault(a => a.Asset is ModelAsset); + if (firstModelItem != null) + { + assets.RemoveAll(a => a.Asset is ModelAsset); + + var perMeshAssets = new List(); + var partCounts = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < entityInfo.Models.Count; i++) + { + var rawMeshName = entityInfo.Models[i].MeshName; + var meshPart = SanitizePart(rawMeshName) ?? $"Mesh-{i + 1}"; + + if (!partCounts.TryGetValue(meshPart, out var dupCount)) + dupCount = 0; + partCounts[meshPart] = dupCount + 1; + + var disambiguated = (dupCount == 0) ? meshPart : $"{meshPart}-{dupCount + 1}"; + var desiredNoExt = $"{baseName}-{disambiguated}"; + + var uniqueFile = MakeUniqueFileName(desiredNoExt, assets); + + AssetItem itemForThisMesh; + + if (i == 0) + { + var baseModel = (ModelAsset)firstModelItem.Asset; + baseModel.Id = AssetId.New(); + baseModel.MeshName = rawMeshName; + baseModel.KepMeshIndex = i; + itemForThisMesh = new AssetItem(UPath.Combine(parameters.TargetLocation, uniqueFile), baseModel); + } + else + { + var clonedAsset = AssetCloner.Clone(firstModelItem.Asset); + ((ModelAsset)clonedAsset).Id = AssetId.New(); + ((ModelAsset)clonedAsset).MeshName = rawMeshName; + ((ModelAsset)clonedAsset).KepMeshIndex = i; + itemForThisMesh = new AssetItem(UPath.Combine(parameters.TargetLocation, uniqueFile), clonedAsset); + } + perMeshAssets.Add(itemForThisMesh); + assets.Add(itemForThisMesh); + } + + //Assign materials + foreach (var item in perMeshAssets) + { + ResetMaterialsOnPrefabItems(item, entityInfo); + } + + var prefabAssetItem = BuildPrefabForSplitHierarchy( + baseName, + entityInfo, + perMeshAssets, + parameters.TargetLocation); + + + if (prefabAssetItem != null) + assets.Add(prefabAssetItem); + } + } + } + foreach (var model in assets.Select(x => x.Asset).OfType()) { @@ -154,11 +239,183 @@ protected override IEnumerable CreateAssets(AssetTemplateGeneratorPar } } - // Create unique names amongst the list of assets importedAssets.AddRange(MakeUniqueNames(assets)); } return importedAssets; } + + private static void ResetMaterialsOnPrefabItems(AssetItem assetItem, EntityInfo entityInfo) + { + ModelAsset asset = assetItem?.Asset as ModelAsset; + if (asset == null || asset.Materials==null) + return; + + var underlyingModel=entityInfo.Models.FirstOrDefault(c => c.MeshName == asset.MeshName); + var nodeContainingMesh=entityInfo.Nodes.FirstOrDefault(c => c.Name == underlyingModel.NodeName); + + entityInfo.NodeNameToMaterialIndices.TryGetValue(nodeContainingMesh.Name, out var materialIndices); + + if (materialIndices?.Count< 1) + return; + + for (int i = asset.Materials.Count - 1; i >= 0; i--) + { + if (!materialIndices.Contains(i)) + asset.Materials.RemoveAt(i); + } + } + + private static AssetItem? BuildPrefabForSplitHierarchy(string baseName, EntityInfo entityInfo, IList perMeshModels, UDirectory targetLocation) + { + if (entityInfo?.Nodes == null || entityInfo.Nodes.Count == 0) + return null; + + // Step 1. Set up entites transversing the tree from imported entityinfo + var entities = new List(entityInfo.Nodes.Count); + var stack = new Stack(); + Entity root = null; + + for (int i = 0; i < entityInfo.Nodes.Count; i++) + { + var node = entityInfo.Nodes[i]; + var e = new Entity(string.IsNullOrEmpty(node.Name) ? $"Node_{i}" : node.Name); + + while (stack.Count > node.Depth) + stack.Pop(); + + if (stack.Count == 0) + { + root = e; + } + else + { + stack.Peek().AddChild(e); + } + + stack.Push(e); + entities.Add(e); + } + + + // Step 2. Apply TRS on each entity to that of imported source file + for (int i = 0; i < entityInfo.Nodes.Count; i++) + { + entities[i].Transform.Position = entityInfo.Nodes[i].Position; + entities[i].Transform.Rotation = entityInfo.Nodes[i].Rotation; + entities[i].Transform.Scale = entityInfo.Nodes[i].Scale; + } + + //Step 3. Attach ModelComponent and set up hierachical order + if (entityInfo?.Models?.Count > 0) + { + var nodeNameToIndex = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < entityInfo.Nodes.Count; i++) + { + var n = entityInfo.Nodes[i].Name; + if (!string.IsNullOrEmpty(n) && !nodeNameToIndex.ContainsKey(n)) + nodeNameToIndex.Add(n, i); + } + + var extraChildCountByNode = new Dictionary(); + + for (int m = 0; m < entityInfo.Models.Count; m++) + { + + var meshInfo = entityInfo.Models[m]; + + var nodeIndex = nodeNameToIndex[meshInfo.NodeName]; + + var modelItem = perMeshModels[m]; + if (modelItem?.Asset is ModelAsset) + { + var mc = new ModelComponent + { + Model = AttachedReferenceManager.CreateProxyObject(modelItem.Id, modelItem.Location) + }; + + var host = entities[nodeIndex]; + + + if (host.Get() != null) + { + if (!extraChildCountByNode.TryGetValue(nodeIndex, out var counter)) + counter = 0; + counter++; + extraChildCountByNode[nodeIndex] = counter; + + var childName = string.IsNullOrEmpty(meshInfo.NodeName) + ? $"Mesh_{m}" + : $"{meshInfo.NodeName}_Mesh{counter}"; + + var child = new Entity(childName); + host.AddChild(child); + + entities.Add(child); + + host = child; + } + + host.Components.Add(mc); + } + } + } + + root ??= entities[0]; + + // Heuristic: collapse trivial wrapper root (same name/base or "RootNode") with a single child + var firstNode = entityInfo.Nodes[0]; + var firstName = firstNode.Name ?? string.Empty; + bool looksLikeWrapper = + firstName.Equals(baseName, StringComparison.OrdinalIgnoreCase) + || + firstName.Equals("RootNode", StringComparison.OrdinalIgnoreCase); + + + if (looksLikeWrapper && root.Transform.Children.Count == 1) + { + var onlyChild = root.Transform.Children[0].Entity; + onlyChild.Transform.Parent = null; + root = onlyChild; + } + + var prefab = new PrefabAsset(); + + foreach (var e in entities) + { + var design = new EntityDesign(e); + prefab.Hierarchy.Parts.Add(e.Id, design); + } + + prefab.Hierarchy.RootParts.Add(root); + + prefab.Id = AssetId.New(); // ensure unique Id + var prefabUrl = new UFile($"{baseName} Prefab"); + return new AssetItem(UPath.Combine(targetLocation, prefabUrl), prefab); + } + + private static string? SanitizePart(string? s) + { + if (string.IsNullOrWhiteSpace(s)) + return null; + var invalid = System.IO.Path.GetInvalidFileNameChars(); + var clean = new string(s.Where(ch => !invalid.Contains(ch)).ToArray()).Trim(); + return string.IsNullOrEmpty(clean) ? null : clean; + } + + private static UFile MakeUniqueFileName(string desiredNameNoExt, List assets) + { + var existing = new HashSet( + assets.Select(a => a.Location.GetFileNameWithoutExtension()), + StringComparer.OrdinalIgnoreCase); + + var name = desiredNameNoExt; + var i = 0; + while (existing.Contains(name)) + name = $"{desiredNameNoExt}-{++i}"; + + return new UFile(name); + } + } } diff --git a/sources/editor/Stride.Assets.Presentation/ViewModel/ModelViewModel.cs b/sources/editor/Stride.Assets.Presentation/ViewModel/ModelViewModel.cs index 7c48da0745..ad3377f047 100644 --- a/sources/editor/Stride.Assets.Presentation/ViewModel/ModelViewModel.cs +++ b/sources/editor/Stride.Assets.Presentation/ViewModel/ModelViewModel.cs @@ -53,6 +53,10 @@ protected override void PrepareImporterInputParametersForUpdateFromSource(Proper protected override void UpdateAssetFromSource(ModelAsset assetToMerge) { + //Keep the material assignment unchanged for sub mesh model asset + if (Asset.KepMeshIndex > -1) + return; + // Create a dictionary containing all new and old materials, favoring old ones to maintain existing references var dictionary = assetToMerge.Materials.ToDictionary(x => x.Name, x => x); Asset.Materials.ForEach(x => dictionary[x.Name] = x); diff --git a/sources/engine/Stride.Assets.Models/ImportModelCommand.Animation.cs b/sources/engine/Stride.Assets.Models/ImportModelCommand.Animation.cs index 299d2cf132..dcda93483f 100644 --- a/sources/engine/Stride.Assets.Models/ImportModelCommand.Animation.cs +++ b/sources/engine/Stride.Assets.Models/ImportModelCommand.Animation.cs @@ -26,7 +26,7 @@ public partial class ImportModelCommand public bool ImportCustomAttributes { get; set; } public int AnimationStack { get; set; } - + private unsafe object ExportAnimation(ICommandContext commandContext, ContentManager contentManager, bool failOnEmptyAnimation) { // Read from model file diff --git a/sources/engine/Stride.Assets.Models/ImportModelCommand.Model.cs b/sources/engine/Stride.Assets.Models/ImportModelCommand.Model.cs index c63f463e9e..9f72e64180 100644 --- a/sources/engine/Stride.Assets.Models/ImportModelCommand.Model.cs +++ b/sources/engine/Stride.Assets.Models/ImportModelCommand.Model.cs @@ -31,6 +31,8 @@ public partial class ImportModelCommand public List Materials { get; set; } public string EffectName { get; set; } + public int KeptMeshIndex { get; set; } = -1; + public List ModelModifiers { get; set; } /// diff --git a/sources/engine/Stride.Assets.Models/ImportThreeDCommand.cs b/sources/engine/Stride.Assets.Models/ImportThreeDCommand.cs index 339764ed1c..ae8aab2569 100644 --- a/sources/engine/Stride.Assets.Models/ImportThreeDCommand.cs +++ b/sources/engine/Stride.Assets.Models/ImportThreeDCommand.cs @@ -40,7 +40,8 @@ private Stride.Importer.ThreeD.MeshConverter CreateMeshConverter(ICommandContext protected override Model LoadModel(ICommandContext commandContext, ContentManager contentManager) { var converter = CreateMeshConverter(commandContext); - + converter.KeepOnlyMeshByIndex(((ImportThreeDCommand)commandContext.CurrentCommand).KeptMeshIndex); + // Note: FBX exporter uses Materials for the mapping, but Assimp already uses indices so we can reuse them // We should still unify the behavior to be more consistent at some point (i.e. if model was changed on the HDD but not in the asset). // This should probably be better done during a large-scale FBX/Assimp refactoring. diff --git a/sources/engine/Stride.Assets.Models/ModelAsset.cs b/sources/engine/Stride.Assets.Models/ModelAsset.cs index 123d70e6c9..59efc9ebdc 100644 --- a/sources/engine/Stride.Assets.Models/ModelAsset.cs +++ b/sources/engine/Stride.Assets.Models/ModelAsset.cs @@ -91,8 +91,14 @@ public sealed class ModelAsset : Asset, IAssetWithSource, IModelAsset [Category] public List Modifiers { get; } = new List(); + [DataMember(55)] + public int KepMeshIndex { get; set; } = -1; + /// [DataMemberIgnore] public override UFile MainSource => Source; + + [DataMemberIgnore] + public string MeshName { get; set; } = null; } } diff --git a/sources/engine/Stride.Assets.Models/ModelAssetCompiler.cs b/sources/engine/Stride.Assets.Models/ModelAssetCompiler.cs index c3baaaa0b9..a84ffa2bdd 100644 --- a/sources/engine/Stride.Assets.Models/ModelAssetCompiler.cs +++ b/sources/engine/Stride.Assets.Models/ModelAssetCompiler.cs @@ -67,6 +67,7 @@ protected override void Prepare(AssetCompilerContext context, AssetItem assetIte importModelCommand.MergeMeshes = asset.MergeMeshes; importModelCommand.DeduplicateMaterials = asset.DeduplicateMaterials; importModelCommand.ModelModifiers = asset.Modifiers; + importModelCommand.KeptMeshIndex = asset.KepMeshIndex; if (skeleton != null) { diff --git a/sources/engine/Stride.Assets.Models/ModelAssetImporter.cs b/sources/engine/Stride.Assets.Models/ModelAssetImporter.cs index bc6accd05b..16185bf442 100644 --- a/sources/engine/Stride.Assets.Models/ModelAssetImporter.cs +++ b/sources/engine/Stride.Assets.Models/ModelAssetImporter.cs @@ -23,6 +23,8 @@ namespace Stride.Assets.Models { public abstract class ModelAssetImporter : AssetImporterBase { + public static readonly PropertyKey SplitHierarchyKey = new PropertyKey("SplitHierarchy", typeof(ModelAssetImporter)); + public static readonly PropertyKey DeduplicateMaterialsKey = new PropertyKey("DeduplicateMaterials", typeof(ModelAssetImporter)); public override IEnumerable RootAssetTypes @@ -108,8 +110,43 @@ public override IEnumerable Import(UFile localPath, AssetImporterPara // 4. Model if (isImportingModel) { - modelAsset = ImportModel(rawAssetReferences, localPath, localPath, entityInfo, false, skeletonAsset); - } + // Read the checkbox from the import parameters (defaults to false if not provided) + bool splitHierarchy = false; + importParameters.InputParameters.TryGet(SplitHierarchyKey, out splitHierarchy); + + // Ask the converter for how many meshes exist (we made ExtractModels return ALL meshes) + var meshCount = entityInfo.Models?.Count ?? 0; + + if (splitHierarchy) + { + // Base = Mesh 1 + modelAsset = ImportModel(rawAssetReferences, localPath, localPath, entityInfo, false, skeletonAsset); + + // Mesh 2..N + for (int meshIdx = 1; meshIdx < meshCount; meshIdx++) + { + var perMeshCopy = AssetCloner.Clone(modelAsset); + perMeshCopy.Id = AssetId.New(); + var perMeshUrl = new UFile($"{localPath.GetFileNameWithoutExtension()} (Mesh {meshIdx + 1})"); + rawAssetReferences.Add(new AssetItem(perMeshUrl, perMeshCopy)); + } + + // All meshes (only useful if more than one child mesh) + if (meshCount > 1) + { + var allCopy = AssetCloner.Clone(modelAsset); + allCopy.Id = AssetId.New(); + var allUrl = new UFile(localPath.GetFileNameWithoutExtension() + " (All)"); + rawAssetReferences.Add(new AssetItem(allUrl, allCopy)); + } + } + else + { + // Only the combined "All" model (no per-mesh assets) + var allUrl = new UFile(localPath.GetFileNameWithoutExtension() + " (All)"); + modelAsset = ImportModel(rawAssetReferences, localPath, allUrl, entityInfo, false, skeletonAsset); + } + } // 5. Animation if (importParameters.IsTypeSelectedForOutput()) @@ -126,6 +163,10 @@ public override IEnumerable Import(UFile localPath, AssetImporterPara return rawAssetReferences; } + + + + private static AssetItem ImportSkeleton(List assetReferences, UFile assetSource, UFile localPath, EntityInfo entityInfo) { var asset = new SkeletonAsset { Source = assetSource }; @@ -147,7 +188,6 @@ private static AssetItem ImportSkeleton(List assetReferences, UFile a assetReferences.Add(assetItem); return assetItem; } - private static void ImportAnimation(List assetReferences, UFile localPath, string animationNodeName, int animationNodeIndex, [MaybeNull]AssetItem skeletonAsset, [MaybeNull]ModelAsset modelAsset, TimeSpan animationStartTime, TimeSpan animationEndTime) { var assetSource = localPath; @@ -175,7 +215,6 @@ private static void ImportAnimation(List assetReferences, UFile local assetReferences.Add(new AssetItem(animUrl, asset)); } - private static ModelAsset ImportModel(List assetReferences, UFile assetSource, UFile localPath, EntityInfo entityInfo, bool shouldPostFixName, AssetItem skeletonAsset) { var asset = new ModelAsset { Source = assetSource }; @@ -183,23 +222,59 @@ private static ModelAsset ImportModel(List assetReferences, UFile ass if (entityInfo.Models != null) { var loadedMaterials = assetReferences.Where(x => x.Asset is MaterialAsset).ToList(); - foreach (var material in entityInfo.Materials) + asset.Materials.Clear(); + + // Prefer the exact Assimp material order if present + if (entityInfo.MaterialOrder != null && entityInfo.MaterialOrder.Count > 0) { - var modelMaterial = new ModelMaterial + foreach (var matName in entityInfo.MaterialOrder) { - Name = material.Key, - MaterialInstance = new MaterialInstance() - }; - var foundMaterial = loadedMaterials.FirstOrDefault(x => x.Location == new UFile(material.Key)); - if (foundMaterial != null) + if (!entityInfo.Materials.TryGetValue(matName, out var _)) + continue; + + var modelMaterial = new ModelMaterial + { + Name = matName, + MaterialInstance = new MaterialInstance() + }; + + // Find the imported material asset by its location (same name convention) + var foundMaterial = loadedMaterials.FirstOrDefault(x => x.Location == new UFile(matName)); + if (foundMaterial != null) + { + var reference = AttachedReferenceManager.CreateProxyObject(foundMaterial.Id, foundMaterial.Location); + modelMaterial.MaterialInstance.Material = reference; + } + + asset.Materials.Add(modelMaterial); + } + } + else + { + // Fallback: deterministic by-name order if MaterialOrder wasn’t supplied + foreach (var kv in entityInfo.Materials.OrderBy(kv => kv.Key, StringComparer.Ordinal)) { - var reference = AttachedReferenceManager.CreateProxyObject(foundMaterial.Id, foundMaterial.Location); - modelMaterial.MaterialInstance.Material = reference; + var matName = kv.Key; + + var modelMaterial = new ModelMaterial + { + Name = matName, + MaterialInstance = new MaterialInstance() + }; + + var foundMaterial = loadedMaterials.FirstOrDefault(x => x.Location == new UFile(matName)); + if (foundMaterial != null) + { + var reference = AttachedReferenceManager.CreateProxyObject(foundMaterial.Id, foundMaterial.Location); + modelMaterial.MaterialInstance.Material = reference; + } + + asset.Materials.Add(modelMaterial); } - asset.Materials.Add(modelMaterial); } - //handle the case where during import we imported no materials at all - if (entityInfo.Materials.Count == 0) + + // If still none, keep a default slot + if (asset.Materials.Count == 0) { var modelMaterial = new ModelMaterial { Name = "Material", MaterialInstance = new MaterialInstance() }; asset.Materials.Add(modelMaterial); @@ -214,7 +289,6 @@ private static ModelAsset ImportModel(List assetReferences, UFile ass assetReferences.Add(assetItem); return asset; } - private static void ImportMaterials(List assetReferences, Dictionary materials) { if (materials != null) @@ -233,7 +307,6 @@ private static void ImportMaterials(List assetReferences, Dictionary< var materialAssetReference = materialAssetReferenceLink.Reference as IReference; if (materialAssetReference == null) continue; - // texture location is #nameOfTheModel_#nameOfTheTexture at this point in the material var foundTexture = loadedTextures.FirstOrDefault(x => x.Location == materialAssetReference.Location); if (foundTexture != null) @@ -326,5 +399,12 @@ private static void ImportTextures(IEnumerable textureDependencies, List assetReferences.Add(new AssetItem(texturePath.GetFileNameWithoutExtension(), texture)); } } + + private static ModelAsset CloneModelAsset(ModelAsset original) + { + var clone = AssetCloner.Clone(original); + clone.Id = AssetId.New(); // correct factory + return clone; + } } } diff --git a/sources/tools/Stride.Importer.3D/MeshConverter.cs b/sources/tools/Stride.Importer.3D/MeshConverter.cs index e7e64621ff..d8ea7628ce 100644 --- a/sources/tools/Stride.Importer.3D/MeshConverter.cs +++ b/sources/tools/Stride.Importer.3D/MeshConverter.cs @@ -10,6 +10,7 @@ using Stride.Animations; using Stride.Assets.Materials; using Stride.Core; +using Stride.Core.Assets; using Stride.Core.Diagnostics; using Stride.Core.Extensions; using Stride.Core.IO; @@ -30,6 +31,11 @@ namespace Stride.Importer.ThreeD { public class MeshConverter { + // keptMeshIndex < 0 => keep ALL meshes + // keptMeshIndex >= 0 => keep only that mesh index + private int keptMeshIndex = -1; // default to ALL unless name says otherwise + private string keptMeshNameHint = null; + static MeshConverter() { if (Platform.Type == PlatformType.Windows) @@ -67,6 +73,18 @@ private void ResetConversionData() textureNameCount.Clear(); } + public void KeepOnlyMeshByName(string name) + { + keptMeshIndex = -1; + keptMeshNameHint = string.IsNullOrWhiteSpace(name) ? null : name; + } + + public void KeepOnlyMeshByIndex(int index) + { + keptMeshNameHint = null; + keptMeshIndex = index >= 0 ? index : -1; + } + public unsafe EntityInfo ExtractEntity(string inputFilename, string outputFilename, bool extractTextureDependencies, bool deduplicateMaterials) { try @@ -101,17 +119,47 @@ public unsafe EntityInfo ExtractEntity(string inputFilename, string outputFilena GenerateNodeNames(scene, nodeNames); + var materials = ExtractMaterials(scene, materialNames, out var materialOrder); + var entityInfo = new EntityInfo { - Materials = ExtractMaterials(scene, materialNames), + Materials = materials, + MaterialOrder = materialOrder, // NEW: preserve Assimp array order Models = ExtractModels(scene, meshNames, materialNames, nodeNames), Nodes = ExtractNodeHierarchy(scene, nodeNames), AnimationNodes = ExtractAnimations(scene, animationNames) }; + if (extractTextureDependencies) entityInfo.TextureDependencies = ExtractTextureDependencies(scene); + // Aggregate materials per NODE not per mesh to account for multiple material limitation on assimp + if (entityInfo.Models != null && entityInfo.Models.Count > 0) + { + foreach (var m in entityInfo.Models) + { + var key = string.IsNullOrEmpty(m.NodeName) ? m.MeshName : m.NodeName; + if (string.IsNullOrEmpty(key)) + continue; + + if (!entityInfo.NodeNameToMaterialIndices.TryGetValue(key, out var list)) + { + list = new List(); + entityInfo.NodeNameToMaterialIndices[key] = list; + } + + if (m.MaterialIndices != null) + { + foreach (var idx in m.MaterialIndices) + { + if (idx >= 0 && !list.Contains(idx)) + list.Add(idx); + } + } + } + } + return entityInfo; } catch (Exception ex) @@ -179,6 +227,27 @@ public unsafe Rendering.Skeleton ConvertSkeleton(string inputFilename, string ou return scene; } + private unsafe bool FindOwningNodeAndCollectSiblingMeshes(Node* node, uint targetMesh, HashSet outSet) + { + for (uint m = 0; m < node->MNumMeshes; ++m) + { + if (node->MMeshes[m] == targetMesh) + { + // Collect ALL meshes attached to this node + for (uint k = 0; k < node->MNumMeshes; ++k) + outSet.Add((int)node->MMeshes[k]); + return true; + } + } + + // Recurse + for (uint c = 0; c < node->MNumChildren; ++c) + if (FindOwningNodeAndCollectSiblingMeshes(node->MChildren[c], targetMesh, outSet)) + return true; + + return false; + } + private unsafe Model ConvertAssimpScene(Scene* scene) { modelData = new Model(); @@ -186,6 +255,57 @@ private unsafe Model ConvertAssimpScene(Scene* scene) var meshNames = new Dictionary(); GenerateMeshNames(scene, meshNames); + HashSet keptSet = null; + if (keptMeshIndex >= 0) + { + keptSet = new HashSet(); + FindOwningNodeAndCollectSiblingMeshes(scene->MRootNode, (uint)keptMeshIndex, keptSet); + } + + bool IsKept(int meshIndex, out int index) + { + index = -1; + + if (keptSet == null) + { + index = 0; + return true; + } + + int i = 0; + foreach (var item in keptSet) + { + if (item == meshIndex) + { + index = i; + return true; + } + i++; + } + + return false; + } + + bool MeshFilter(int meshIndex) => IsKept(meshIndex, out _); + + // If output asset name equals a mesh name, or ends with "-", select that mesh. + if (!string.IsNullOrEmpty(keptMeshNameHint)) + { + for (uint i = 0; i < scene->MNumMeshes; ++i) + { + var lMesh = scene->MMeshes[i]; + var meshName = meshNames[(IntPtr)lMesh]; + + if (string.Equals(keptMeshNameHint, meshName, StringComparison.OrdinalIgnoreCase) || + keptMeshNameHint.EndsWith("-" + meshName, StringComparison.OrdinalIgnoreCase)) + { + keptMeshIndex = (int)i; + break; + } + } + // If no match found, keptMeshIndex stays -1 → keep ALL meshes (Split OFF case) + } + var nodeNames = new Dictionary(); var duplicateNodeNameToNodePointers = new Dictionary>(); GenerateNodeNames(scene, nodeNames, duplicateNodeNameToNodePointers); @@ -193,14 +313,17 @@ private unsafe Model ConvertAssimpScene(Scene* scene) // register the nodes and fill hierarchy var meshIndexToNodeIndex = new Dictionary>(); var nodePointerToNodeIndex = new Dictionary(); - RegisterNodes(scene->MRootNode, -1, nodeNames, meshIndexToNodeIndex, nodePointerToNodeIndex); + RegisterNodes(scene->MRootNode, -1, nodeNames, meshIndexToNodeIndex, nodePointerToNodeIndex,null, MeshFilter); // Map the Bone pointers to their corresponding Node pointer var bonePointerToNodePointerMap = GenerateBoneToNodeMap(scene, nodePointerToNodeIndex, duplicateNodeNameToNodePointers); // meshes for (var i = 0; i < scene->MNumMeshes; ++i) - { + { + if (!IsKept(i, out int matIndex)) + continue; + if (!meshIndexToNodeIndex.TryGetValue(i, out var value)) { continue; @@ -219,8 +342,8 @@ private unsafe Model ConvertAssimpScene(Scene* scene) { Draw = meshInfo.Draw, Name = meshInfo.Name, - MaterialIndex = meshInfo.MaterialIndex, NodeIndex = nodeIndex, + MaterialIndex = keptMeshIndex > 0 ? matIndex : meshInfo.MaterialIndex }; if (meshInfo.Bones != null) @@ -241,8 +364,28 @@ private unsafe Model ConvertAssimpScene(Scene* scene) modelData.Meshes.Add(nodeMeshData); } } - - + // Incase split heirarchy, each mesh is positioned and rotated at entity level, scaled to account for assimp + // stride conversion factor of 0.01f + if (keptMeshIndex >= 0) + { + // Replace node table with a single identity root + nodes.Clear(); + nodes.Add(new ModelNodeDefinition + { + ParentIndex = -1, + Name = "Root", + Flags = ModelNodeFlags.Default, + Transform = + { + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + Scale = new Vector3(100,100,100) + } + }); + + for (int mi = 0; mi < modelData.Meshes.Count; mi++) + modelData.Meshes[mi].NodeIndex = 0; + } return modelData; } @@ -251,8 +394,10 @@ private unsafe Dictionary GenerateBoneToNodeMap(Scene* scene, Di // Get the all bones in the scene var allBones = new List<(IntPtr NodePointer, string BoneName)>(); var uniqueBoneNames = new HashSet(); - for (int meshIdx = 0; meshIdx < scene->MNumMeshes; meshIdx++) + for (int meshIdx = 0; meshIdx < (int)scene->MNumMeshes; meshIdx++) { + if (keptMeshIndex >= 0 && meshIdx != keptMeshIndex) + continue; var mesh = scene->MMeshes[meshIdx]; if (mesh->MNumBones == 0) { @@ -700,12 +845,15 @@ private unsafe void GenerateUniqueNames(Dictionary finalNames, L private unsafe void GenerateMeshNames(Scene* scene, Dictionary meshNames) { var baseNames = new List(); - for (uint i = 0; i < scene->MNumMeshes; i++) + var orderedMeshPtrs = new List(); + for (uint i = 0; i < scene->MNumMeshes; ++i) { var lMesh = scene->MMeshes[i]; baseNames.Add(lMesh->MName.AsString.CleanNodeName()); + orderedMeshPtrs.Add((IntPtr)lMesh); } - GenerateUniqueNames(meshNames, baseNames, i => (IntPtr)scene->MMeshes[i]); + + GenerateUniqueNames(meshNames, baseNames, idx => orderedMeshPtrs[idx]); } private unsafe void GenerateAnimationNames(Scene* scene, Dictionary animationNames) @@ -744,22 +892,24 @@ private unsafe void GetNodeNames(Node* node, List nodeNames, List nodeNames, Dictionary> meshIndexToNodeIndex, - Dictionary nodePointerToNodeIndex = null, Dictionary> duplicateNodeNameToNodeIndices = null) + Dictionary nodePointerToNodeIndex = null, Dictionary> duplicateNodeNameToNodeIndices = null, Func isKept = null) { var nodeIndex = nodes.Count; - // assign the index of the node to the index of the mesh + // assign the index of the node to the index of the mesh (keep only mesh 0) for (uint m = 0; m < fromNode->MNumMeshes; ++m) { - var meshIndex = fromNode->MMeshes[m]; + var meshIndex = (int)fromNode->MMeshes[m]; + if (isKept!=null && !isKept(meshIndex)) + continue; - if (!meshIndexToNodeIndex.TryGetValue((int)meshIndex, out var nodeIndices)) + if (!meshIndexToNodeIndex.TryGetValue(meshIndex, out var nodeIndices)) { nodeIndices = new List(); - meshIndexToNodeIndex.Add((int)meshIndex, nodeIndices); + meshIndexToNodeIndex.Add(meshIndex, nodeIndices); } - nodeIndices.Add(nodeIndex); + nodeIndices.Add(nodeIndex); // or nodeIndex, depending on your existing code } // Create node @@ -772,7 +922,6 @@ private unsafe void RegisterNodes( // Extract scene scaling and rotation from the root node. // Bake scaling into all node's positions and rotation into the 1st-level nodes. - if (parentIndex == -1) { rootTransform = fromNode->MTransformation.ToStrideMatrix(); @@ -793,10 +942,12 @@ private unsafe void RegisterNodes( nodes.Add(modelNodeDefinition); + // Map this node pointer to its index exactly once, after we've added it if (nodePointerToNodeIndex is not null) { nodePointerToNodeIndex.Add((IntPtr)fromNode, nodeIndex); } + if (duplicateNodeNameToNodeIndices is not null) { string originalNodeName = fromNode->MName.AsString.CleanNodeName(); @@ -815,7 +966,7 @@ private unsafe void RegisterNodes( // register the children for (uint child = 0; child < fromNode->MNumChildren; ++child) { - RegisterNodes(fromNode->MChildren[child], nodeIndex, nodeNames, meshIndexToNodeIndex, nodePointerToNodeIndex, duplicateNodeNameToNodeIndices); + RegisterNodes(fromNode->MChildren[child], nodeIndex, nodeNames, meshIndexToNodeIndex, nodePointerToNodeIndex, duplicateNodeNameToNodeIndices, isKept); } } @@ -1162,20 +1313,38 @@ private unsafe void CreateTextureFile(Silk.NET.Assimp.Texture* texture, string p System.IO.File.WriteAllBytes(path, buffer); } - private unsafe Dictionary ExtractMaterials(Scene* scene, Dictionary materialNames) + private unsafe Dictionary ExtractMaterials( + Scene* scene, + Dictionary materialNames, + out List materialOrderOut) { + // Build the unique names first (already in Assimp array order) GenerateMaterialNames(scene, materialNames); var materials = new Dictionary(); + var materialOrder = new List(capacity: (int)scene->MNumMaterials); + for (uint i = 0; i < scene->MNumMaterials; i++) { var lMaterial = scene->MMaterials[i]; + + // Name in the same position as the Assimp index var materialName = materialNames[(IntPtr)lMaterial]; - materials.Add(materialName, ProcessMeshMaterial(scene, lMaterial)); + + // Build the MaterialAsset with your existing routine + var matAsset = ProcessMeshMaterial(scene, lMaterial); + + materials[materialName] = matAsset; // lookup by name + materialOrder.Add(materialName); // preserve exact Assimp order } + + materialOrderOut = materialOrder; return materials; } + + + private unsafe void GenerateMaterialNames(Scene* scene, Dictionary materialNames) { var baseNames = new List(); @@ -1500,25 +1669,41 @@ private ComputeTextureColor GetTextureReferenceNode(string vfsOutputPath, string private unsafe List ExtractModels(Scene* scene, Dictionary meshNames, Dictionary materialNames, Dictionary nodeNames) { GenerateMeshNames(scene, meshNames); - var meshList = new List(); + + var aggregated = new Dictionary(StringComparer.Ordinal); + for (uint i = 0; i < scene->MNumMeshes; ++i) { var mesh = scene->MMeshes[i]; var lMaterial = scene->MMaterials[mesh->MMaterialIndex]; - var meshParams = new MeshParameters + var meshName = meshNames[(IntPtr)mesh]; + var nodeName = SearchMeshNode(scene->MRootNode, i, nodeNames); + var matName = materialNames[(IntPtr)lMaterial]; + var matIndex = (int)mesh->MMaterialIndex; + + var groupKey = string.IsNullOrEmpty(nodeName) ? meshName : nodeName; + + if (!aggregated.TryGetValue(groupKey, out var mp)) { - MeshName = meshNames[(IntPtr)mesh], - MaterialName = materialNames[(IntPtr)lMaterial], - NodeName = SearchMeshNode(scene->MRootNode, i, nodeNames) - }; + mp = new MeshParameters + { + MeshName = meshName, + NodeName = nodeName, + }; + aggregated[groupKey] = mp; + } - meshList.Add(meshParams); + if (!mp.MaterialNames.Contains(matName)) + mp.MaterialNames.Add(matName); + if (!mp.MaterialIndices.Contains(matIndex)) + mp.MaterialIndices.Add(matIndex); } - return meshList; + return aggregated.Values.ToList(); } + private unsafe string SearchMeshNode(Node* node, uint meshIndex, Dictionary nodeNames) { for (uint i = 0; i < node->MNumMeshes; ++i) @@ -1553,7 +1738,12 @@ private unsafe void GetNodes(Node* node, int depth, Dictionary n Preserve = true }; + // read FBX node matrix and decompose + var m = node->MTransformation.ToStrideMatrix(); + m.Decompose(out newNodeInfo.Scale, out newNodeInfo.Rotation, out newNodeInfo.Position); + allNodes.Add(newNodeInfo); + for (uint i = 0; i < node->MNumChildren; ++i) GetNodes(node->MChildren[i], depth + 1, nodeNames, allNodes); } diff --git a/sources/tools/Stride.Importer.Common/EntityInfo.cs b/sources/tools/Stride.Importer.Common/EntityInfo.cs index 10e7e7e5f3..ad5db2ba82 100644 --- a/sources/tools/Stride.Importer.Common/EntityInfo.cs +++ b/sources/tools/Stride.Importer.Common/EntityInfo.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System; using System.Collections.Generic; using Stride.Assets.Materials; @@ -13,5 +14,7 @@ public class EntityInfo public List AnimationNodes; public List Models; public List Nodes; + public List MaterialOrder { get; set; } + public Dictionary> NodeNameToMaterialIndices { get; set; } = new Dictionary>(StringComparer.Ordinal); } } diff --git a/sources/tools/Stride.Importer.Common/MeshParameters.cs b/sources/tools/Stride.Importer.Common/MeshParameters.cs index 0cf3763e11..493345d307 100644 --- a/sources/tools/Stride.Importer.Common/MeshParameters.cs +++ b/sources/tools/Stride.Importer.Common/MeshParameters.cs @@ -7,7 +7,9 @@ namespace Stride.Importer.Common { public class MeshParameters { - public string MaterialName; + public List MaterialNames { get; set; } = new(); + public List MaterialIndices { get; set; } = new(); + public string MeshName; public string NodeName; public HashSet BoneNodes; diff --git a/sources/tools/Stride.Importer.Common/NodeInfo.cs b/sources/tools/Stride.Importer.Common/NodeInfo.cs index 0bfb15b421..5e2c1ec4bd 100644 --- a/sources/tools/Stride.Importer.Common/NodeInfo.cs +++ b/sources/tools/Stride.Importer.Common/NodeInfo.cs @@ -1,6 +1,8 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using Stride.Core.Mathematics; + namespace Stride.Importer.Common { public class NodeInfo @@ -8,5 +10,8 @@ public class NodeInfo public string Name; public int Depth; public bool Preserve; + public Vector3 Position; + public Quaternion Rotation; + public Vector3 Scale; } }