diff --git a/AiDocumentation/gltf-glb-export.md b/AiDocumentation/gltf-glb-export.md new file mode 100644 index 000000000..74b66344e --- /dev/null +++ b/AiDocumentation/gltf-glb-export.md @@ -0,0 +1,268 @@ +# glTF/GLB Model Export + +## Status + +AssetEditor can export the following pack-file model sources through the existing +`Rmv_to_Gltf` exporter: + +- `.rigid_model_v2` +- `.wsmodel` +- `.variantmeshdefinition` + +Both `.gltf` and `.glb` outputs are supported. The implementation was audited against the +feature scope in September 2026. No release-blocking defect was found in the implemented +paths, and the full Windows solution suite, real-asset integration tests, and manual exports +pass. The branch is suitable for upstream PR preparation. + +## User workflow + +1. Load the pack files that contain the model and its referenced skeleton, animations, + materials, and textures. +2. In the PackFile explorer, right-click a `.rigid_model_v2`, `.wsmodel`, or + `.variantmeshdefinition` file. +3. Select **Export > Advanced Export**. +4. Select `Rmv_to_Gltf`. +5. Choose whether to export textures and animations. If a skeleton is available, select + the animation clips to include. A `stand_idle` clip is selected by default when one is + found; AssetEditor does not select the entire catalog automatically. +6. Browse for an output filename and choose either **glTF (`.gltf`)** or **Binary glTF + (`.glb`)** in the save dialog. +7. Select **Export**. + +The animation list is derived from the source model's resolved skeleton. A VMD uses the +same component traversal and shared-skeleton selection as the exporter, so the UI cannot +silently offer clips for a different component skeleton. + +## Output behavior + +| Source or option | Behavior | +| --- | --- | +| Plain RMV2 | Exports LOD0 geometry and the RMV2 material. If a valid sibling WSModel exists, its effective material is used. | +| WSModel | Resolves its referenced RMV2 geometry and merges its material overrides before export. | +| VMD | Exports one scene containing the first renderable/default candidate from each selected slot, including nested VMD and WSModel-backed parts. | +| `.gltf` | Writes JSON glTF and the image/buffer sidecars needed by the file. | +| `.glb` | Embeds standard glTF resources. Temporary PNGs embedded in the GLB are removed only after a successful save. | +| Warhammer mask texture | Converted to an auxiliary `*_mask.png`; it is not connected to base color because glTF has no equivalent Warhammer faction/mask channel. | +| 3D-printing outputs | Alpha-mask, raw/offset normal, and displacement images remain auxiliary sidecars. | + +## Architecture + +```text +RMV2 or WSModel + -> IModelAssetResolver + -> ResolvedModelAsset + ResolvedModelMaterial + \ +VMD -> RmvToGltfExporter + -> IVariantMeshCompositionResolver -> mesh/material/texture builders + -> default renderable tree -> shared skeleton + selected animations + -> GltfSceneSaver + -> .gltf or .glb +``` + +The resolution layer is in `GameWorld.Core` and does not construct MonoGame scene nodes or +WPF controls. This lets the viewport and exporter consume the same interpretation while +keeping future non-UI consumers possible. + +### Resolution layer + +| File/class | Responsibility | +| --- | --- | +| `GameWorld.Core/Services/ModelAssetResolver.cs` | Resolves RMV2/WSModel geometry, sibling WSModels, complete material mappings, effective textures, alpha state, and diagnostics. | +| `GameWorld.Core/Services/VariantMeshCompositionResolver.cs` | Parses VMDs, chooses the first renderable candidate in viewport order, recursively resolves nested definitions, preserves attachment names, and detects cycles. | +| `GameWorld.Core/Services/WsModelMaterialProvider.cs` | Compatibility adapter for older callers. New code should use `IModelAssetResolver`; the type remains present to avoid breaking consumers. | +| `Shared/GameFiles/Vmd/VariantMeshDefinition.cs` | Makes child collections safe when optional XML elements are absent. | + +The renderer path (`Rmv2ModelNodeLoader`, `ComplexMeshLoader`, `KitbashSceneCreator`, and +`BmdSceneCreator`) now consumes resolved assets. This is a deliberate part of the feature: +it prevents export and viewport material rules from drifting apart. + +### Export layer + +| File/class | Responsibility | +| --- | --- | +| `RmvToGltfExporter.cs` | Routes direct and composed exports, flattens VMD components, creates one shared skeleton, applies attachment parenting, and emits animations once. | +| `RmvToGltfAnimationSourceResolver.cs` | Builds the UI animation catalog using the exporter's component order and skeleton-selection rule. | +| `GltfMeshBuilder.cs` / `GltfStaticMeshBuilder.cs` | Builds LOD0 primitives and standard glTF materials from effective material data. | +| `GltfTextureHandler.cs` | Converts effective texture paths, caches conversions per export, avoids basename collisions in composed scenes, and keeps Warhammer masks auxiliary. | +| `GltfSceneSaver.cs` | Lets SharpGLTF choose `.gltf` or `.glb` by extension and safely cleans embedded-GLB texture intermediates after success. | +| `RmvToGltfExporterViewModel.cs` | Initializes against the selected source, exposes animation search/selection, and passes only selected, resolvable clips to the exporter. | +| `ExporterCoreViewModel.cs` / `IExporterViewModel.cs` | Supplies the source before displaying exporter-specific options and exposes a two-format save filter. | + +### Material rules + +`ResolvedModelMaterial` begins with the RMV2 texture table and overlays every WSModel +texture slot, including explicit empty slots. An empty override therefore disables the +RMV2 texture instead of accidentally falling back to it. WSModel mappings must cover every +RMV2 LOD/part exactly once; otherwise resolution uses RMV2 materials and records a +diagnostic, matching the previous viewport all-or-nothing validation. + +The exporter maps representable inputs to standard glTF channels: base color/diffuse, +normal, metallic-roughness/material map, specular, gloss, occlusion, and emissive. Explicit +WSModel/RMV2 alpha state takes precedence over older filename/material heuristics. + +### VMD composition and attachments + +For each VMD slot, inline child meshes are visited before child references, matching the +existing viewport loader. The first candidate that resolves to renderable content is used. +References are pack-root-relative, and recursive VMD cycles produce diagnostics rather than +unbounded recursion. + +The first non-empty component skeleton becomes the shared glTF skeleton. Components naming +that skeleton may be skinned against it. Components with another named skeleton are +exported unskinned and produce a warning, because their joint indices cannot safely address +the shared skin. Named VMD attachment points remain eligible for bone parenting. If no +named attachment is specified, a compatible RMV matrix index may be used. A named but +unknown attachment is authoritative and does not silently fall back to a matrix index. + +## Validation + +### Automated coverage + +Focused tests cover: + +- RMV2 and explicit/sibling WSModel resolution, complete mappings, overrides, and fallback + diagnostics; +- VMD candidate order, root-relative lookup, nested definitions, cycle detection, and a + tracked VMD asset; +- reloadable composed GLB output; +- attachment-by-name, matrix-index attachment, invalid attachment behavior, pivot + translation, and rigid-versus-skinned precedence; +- one shared VMD skeleton, incompatible component skeletons, and single animation emission; +- animation catalog resolution, UI initialization, default idle selection, and passing only + selected clips; +- texture conversion reuse, basename collision avoidance, mask/base-color separation, + DDS red/blue channel order, and successful/failed GLB cleanup; +- a full-stack Karl Franz WSModel export using the production DDS converters, including exact + decoded RGBA preservation, mask separation, GLB reloadability, and sidecar cleanup; +- a full-stack animated Throt VMD export, including component coverage, shared joint bindings, + animation channels, and declared attachment-bone parenting after GLB reload. + +Run the full suite on Windows from the repository root: + +```powershell +dotnet test .\AssetEditor.sln --configuration Release --no-restore --verbosity normal +``` + +Focused exporter tests: + +```powershell +dotnet test .\Editors\ImportExportEditor\Test.ImportExport\Test.ImportExport.csproj --configuration Release --no-restore --filter "FullyQualifiedName~RmvToGltfExporterTests|FullyQualifiedName~GltfSceneSaverTests|FullyQualifiedName~GltfTextureExportSessionTests|FullyQualifiedName~GltfSceneAttachmentTests|FullyQualifiedName~VmdSkeletonExportTests|FullyQualifiedName~GltfAnimationCatalogTests|FullyQualifiedName~RealAssetGltfExportTests|FullyQualifiedName~TextureHelperTests" +``` + +Focused resolver tests: + +```powershell +dotnet test .\GameWorld\GameWorldCore\GameWorld.CoreTest\GameWorld.CoreTest.csproj --configuration Release --no-restore --filter "FullyQualifiedName~ModelAssetResolverTests|FullyQualifiedName~VariantMeshCompositionResolverTests|FullyQualifiedName~SkeletonAnimationLookUpHelperTests|FullyQualifiedName~SkeletonAnimationLookUpHelperKarlPackTests" +``` + +The focused Windows reruns recorded during implementation passed (GLB cleanup 5/5, resolver +11/11, the six initially failing path-dependent VMD tests 6/6, and the two real-asset export +tests 2/2). The latest full Windows solution suite also passed. Manual VMD export with several +selected animations passed. WSL is not a valid substitute for this checkout's existing +Windows restore artifacts: their NuGet assets contain the Windows-only fallback folder +`D:\Microsoft Visual Studio\Shared\NuGetPackages`. + +### Tracked sample assets + +- WSModel/RMV2: + `Data/Karl_and_celestialgeneral_Pack/variantmeshes/wh_variantmodels/hu1/emp/emp_karl_franz/emp_karl_franz.wsmodel` + and its sibling `emp_karl_franz.rigid_model_v2`. +- VMD: + `Data/Rome_Man_And_Shield_Pack/variantmeshes/_variantmodels/man/shield/celtic_oval_patterns.variantmeshdefinition`. +- Animation lookup fixture: `Data/Karl_and_celestialgeneral.pack`. +- Animated VMD integration fixture: `Data/Throt.pack`. + +### Manual release matrix + +For a final release candidate, open each exported file in an independent viewer such as the +Khronos glTF Sample Viewer or Blender and record the exact pack path used: + +- plain RMV2 without a sibling override; +- WSModel with an obvious base-color override and multiple material differences; +- simple VMD; +- VMD containing a WSModel-backed component; +- nested VMD; +- skinned character with several selected animations; +- weapon or shield attached to an animated bone; +- both `.gltf` and `.glb` for representative textured assets. + +Confirm component count, texture identity and RGB order, compatible skin bindings sharing one +ordered joint hierarchy, clip names, attachment motion, output reloadability, and expected +sidecar cleanup. + +## Known limitations + +- Only LOD0 is exported. +- VMD export represents one default/renderable composition, not every possible variant + combination and not campaign/database-driven variant selection. +- Standard glTF cannot reproduce Total War shader logic such as faction tinting or all mask + semantics. The Warhammer mask is an unbound auxiliary PNG, not a base-color or alpha map. +- Components with a skeleton name different from the chosen shared skeleton are not skinned. +- Skeletons, animations, referenced models, materials, and textures must be present in the + currently loaded pack containers. +- The animation list is resolved synchronously when the export window opens. The lookup is + indexed, but a very large first-time catalog may briefly delay the UI. +- Export errors still use the existing WPF `IStandardDialogs` path in `GltfSceneSaver`; the + resolution objects are renderer/UI-independent, but the current save service is not a + headless API. +- Animation/status diagnostics in the exporter panel are currently English strings; action + labels are localized. + +## Upstream-readiness audit + +### Scope and compatibility + +- The changes are confined to general AssetEditor model resolution, rendering consistency, + import/export UI, and tests. They add no Mod Manager, Electron, Three.js, or IPC concepts. +- Existing RMV2 overloads and constructor call sites remain available. New optional/default + interface members preserve source compatibility for existing exporter view models and test + doubles. +- `WsModelMaterialProvider` is deprecated but retained. Its `[Obsolete]` marker is an + intentional migration signal, not a claim that WSModel support itself is legacy. +- Nullability annotations added around optional scene parents and VMD XML collections describe + states already accepted by the loaders; they do not change runtime signatures. +- `git diff --check` passes for the feature range, and all three edited localization JSON files + parse successfully. + +### Review risks + +1. The feature range is large (47 files, roughly 4,600 additions) because renderer/exporter + parity and VMD tests cross project boundaries. Submit it as a small stacked series rather + than one unstructured PR. +2. The global export window is taller to accommodate animation selection. This affects every + exporter, though minimum dimensions remain conservative. A future UI polish could make the + content scroll or size itself per exporter. +3. `TextureHelper` corrects channel order for every DDS-to-PNG consumer, not only glTF. It has + a focused DXT1 regression test and should be reviewed as an explicit prerequisite/fix. +4. The old private nested `GltfTextureHandler.IDDsToPngExporter` that throws + `NotImplementedException` predates this work, is unused, and should not be mixed into these + PRs merely as opportunistic cleanup. + +### Recommended PR split + +Use stacked PRs so each diff tells one story: + +1. **DDS channel-order prerequisite** — the `TextureHelper` fix and regression test. This can + also be submitted independently. +2. **WSModel-aware glTF/GLB export** — `IModelAssetResolver`, renderer parity, effective + materials, `.glb` save selection/cleanup, and WSModel tests. +3. **Composed VMD export** — `IVariantMeshCompositionResolver`, VMD flattening, collision-safe + texture sessions, attachments/shared skin, parser null safety, and VMD tests. Depends on 2. +4. **Animation selection UI for model exports** — shared catalog resolution, source-aware + exporter initialization, UI controls/localization, and catalog/view-model tests. Depends on + 3 for VMD animation selection. + +Before publishing, use interactive rebase to fold the path/assertion fixups into the commits +that introduced their tests and fold mask/GLB cleanup fixes into the appropriate export PR. +Do not rewrite the working branch until the PR boundaries are agreed and a backup branch or +tag exists. + +## Recommended follow-ups + +These are hardening items, not evidence that the validated feature is broken: + +1. Run upstream CI again after history is split/rebased. +2. Translate the remaining dynamic status strings if maintainers require full localization. +3. If a headless consumer is built later, separate save-error reporting from WPF dialogs and + expose a result/exception-based service boundary. The current resolvers can already be + reused without MonoGame. diff --git a/AssetEditor/Language_Cn.json b/AssetEditor/Language_Cn.json index 3a0f7a163..83e0ad945 100644 --- a/AssetEditor/Language_Cn.json +++ b/AssetEditor/Language_Cn.json @@ -449,6 +449,10 @@ "RmvToGltfExporter.ConvertMaterial": "转换材质", "RmvToGltfExporter.ConvertNormal": "转换法线", "RmvToGltfExporter.ExportAnimations": "导出动画", + "RmvToGltfExporter.Animations": "动画:", + "RmvToGltfExporter.SelectAll": "全选", + "RmvToGltfExporter.SelectNone": "全部取消", + "RmvToGltfExporter.FilterAnimations": "筛选动画", "RmvToGltfExporter.Tooltips.ConvertMaterial": "将材质纹理转换为 Blender 喜欢的版本", "RmvToGltfExporter.Tooltips.ConvertNormal": "将法线贴图转换为蓝色版本 - 许多软件使用,包括 Blender", @@ -916,4 +920,4 @@ "PackFileCache.BuildingCache": "正在为 {0} 构建 Pack 文件缓存。这可能需要一些时间,请稍候...", "PackFileCache.BuildingCache.Title": "正在构建缓存", "PackFileCache.Description": "缓存通过将预处理的游戏文件数据存储在磁盘上来加快启动速度并减少内存使用。" -} \ No newline at end of file +} diff --git a/AssetEditor/Language_En.json b/AssetEditor/Language_En.json index b73133b44..ae3102d99 100644 --- a/AssetEditor/Language_En.json +++ b/AssetEditor/Language_En.json @@ -449,6 +449,10 @@ "RmvToGltfExporter.ConvertMaterial": "Convert Material", "RmvToGltfExporter.ConvertNormal": "Convert Normal", "RmvToGltfExporter.ExportAnimations": "Export Animations", + "RmvToGltfExporter.Animations": "Animations: ", + "RmvToGltfExporter.SelectAll": "Select all", + "RmvToGltfExporter.SelectNone": "Select none", + "RmvToGltfExporter.FilterAnimations": "Filter animations", "RmvToGltfExporter.Tooltips.ConvertMaterial": "Convert the Material texture into the version Blender prefers", "RmvToGltfExporter.Tooltips.ConvertNormal": "Convert the normal map texture into blue version - used by a lot of software, including blender", @@ -917,4 +921,3 @@ "PackFileCache.BuildingCache.Title": "Building Cache", "PackFileCache.Description": "The cache allows faster startup times and reduces memory usage by storing pre-processed game file data on disk." } - diff --git a/AssetEditor/Language_Fr.json b/AssetEditor/Language_Fr.json index 4d6e8e09a..52f53428e 100644 --- a/AssetEditor/Language_Fr.json +++ b/AssetEditor/Language_Fr.json @@ -421,6 +421,10 @@ "RmvToGltfExporter.ConvertMaterial": "Convert Material", "RmvToGltfExporter.ConvertNormal": "Convert Normal", "RmvToGltfExporter.ExportAnimations": "Export Animations", + "RmvToGltfExporter.Animations": "Animations: ", + "RmvToGltfExporter.SelectAll": "Select all", + "RmvToGltfExporter.SelectNone": "Select none", + "RmvToGltfExporter.FilterAnimations": "Filter animations", "RmvToGltfExporter.Tooltips.ConvertMaterial": "Convert the Material texture into the version Blender prefers", "RmvToGltfExporter.Tooltips.ConvertNormal": "Convert the normal map texture into blue version - used by a lot of software, including blender", "AudioEditor.Menu.File": "File", diff --git a/Editors/BmdEditor/Services/BmdSceneCreator.cs b/Editors/BmdEditor/Services/BmdSceneCreator.cs index 6f9a2f829..b02f68e79 100644 --- a/Editors/BmdEditor/Services/BmdSceneCreator.cs +++ b/Editors/BmdEditor/Services/BmdSceneCreator.cs @@ -26,6 +26,7 @@ public class BmdSceneCreator private readonly IPackFileService _packFileService; private readonly GameWorld.Core.Components.SceneManager _sceneManager; private readonly GameWorld.Core.SceneNodes.Rmv2ModelNodeLoader _rmv2ModelNodeLoader; + private readonly IModelAssetResolver _modelAssetResolver; private readonly ResourceLibrary _resourceLibrary; private readonly GameWorld.Core.Services.MeshBuilderService _meshBuilderService; @@ -38,11 +39,13 @@ public BmdSceneCreator( GameWorld.Core.Components.SceneManager sceneManager, GameWorld.Core.SceneNodes.Rmv2ModelNodeLoader rmv2ModelNodeLoader, ResourceLibrary resourceLibrary, - GameWorld.Core.Services.MeshBuilderService meshBuilderService) + GameWorld.Core.Services.MeshBuilderService meshBuilderService, + IModelAssetResolver? modelAssetResolver = null) { _packFileService = packFileService; _sceneManager = sceneManager; _rmv2ModelNodeLoader = rmv2ModelNodeLoader; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(packFileService); _resourceLibrary = resourceLibrary; _meshBuilderService = meshBuilderService; } @@ -148,33 +151,12 @@ private SceneNode LoadSingleProp(string propPath, PropInfo propInfo, GroupNode p try { - // Handle wsmodel files by getting the actual rigid_model_v2 path - var actualModelFile = modelFile; - var actualModelPath = propPath; - - if (Path.GetExtension(propPath).ToLower() == ".wsmodel") - { - var wsModel = new WsModelFile(modelFile); - if (string.IsNullOrEmpty(wsModel.GeometryPath)) - { - return CreatePlaceholderProp(propPath, propInfo, propsGroup, instanceIndex, "WsModel has no geometry path"); - } - - actualModelPath = wsModel.GeometryPath; - actualModelFile = _packFileService.FindFile(actualModelPath); - if (actualModelFile == null) - { - return CreatePlaceholderProp(propPath, propInfo, propsGroup, instanceIndex, $"Referenced file not found: {actualModelPath}"); - } - } - - // Load the RMV2 model - var modelData = actualModelFile.DataSource.ReadData(); - var rmv = ModelFactory.Create().Load(modelData); + var resolvedAsset = _modelAssetResolver.Resolve(modelFile); + var rmv = resolvedAsset.Model; // Create RMV2 model nodes - var modelFullPath = _packFileService.GetFullPath(actualModelFile); - var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromFile(rmv, modelFullPath, false, null); + var modelFullPath = _packFileService.GetFullPath(resolvedAsset.GeometryFile); + var lodNodes = _rmv2ModelNodeLoader.CreateModelNodesFromAsset(resolvedAsset, modelFullPath, false); var lodNode = lodNodes.FirstOrDefault(); if (lodNode == null) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Assembly.cs b/Editors/ImportExportEditor/Editors.ImportExport/Assembly.cs new file mode 100644 index 000000000..d865b9854 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Test.ImportExport")] diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index 5e768febc..bfda50063 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -41,6 +41,7 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(x => x.GetRequiredService()); services.AddTransient(); // Importer ViewModels @@ -71,6 +72,7 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSingleton(); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToMaterialPng/DdsToMaterialPngExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToMaterialPng/DdsToMaterialPngExporter.cs index aada44e0c..fd052f2f7 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToMaterialPng/DdsToMaterialPngExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToMaterialPng/DdsToMaterialPngExporter.cs @@ -30,9 +30,10 @@ public string Export(string filePath, string outputPath, bool convertToBlenderFo if (packFile == null) return ""; - var fileName = Path.GetFileNameWithoutExtension(filePath); - var outDirectory = Path.GetDirectoryName(outputPath); - var outFilePath = outDirectory + "/" + fileName + ".png"; + var fileName = Path.GetFileNameWithoutExtension( + filePath.Replace('\\', Path.DirectorySeparatorChar)); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; + var outFilePath = Path.Combine(outDirectory, fileName + ".png"); var bytes = packFile.DataSource.ReadData(); if (bytes == null || !bytes.Any()) @@ -44,7 +45,7 @@ public string Export(string filePath, string outputPath, bool convertToBlenderFo if (convertToBlenderFormat) { - imgBytes = ConvertToBlenderFormat(imgBytes, outputPath, outFilePath); + imgBytes = ConvertToBlenderFormat(imgBytes); } _imageSaveHandler.Save(imgBytes, outFilePath); @@ -61,37 +62,23 @@ public ExportSupportEnum CanExportFile(PackFile file) return ExportSupportEnum.NotSupported; } - byte[] ConvertToBlenderFormat(byte[] imgBytes, string outputPath, string fileDirectory) + private static byte[] ConvertToBlenderFormat(byte[] imgBytes) { - var ms = new MemoryStream(imgBytes); + using var ms = new MemoryStream(imgBytes); using var image = Image.FromStream(ms); using var bitmap = new Bitmap(image); + var pixels = BitmapPixelBuffer.ReadBgra(bitmap); + for (var index = 0; index < pixels.Length; index += 4) { - for (int x = 0; x < bitmap.Width; x++) - { - for (int y = 0; y < bitmap.Height; y++) - { - var pixel = bitmap.GetPixel(x, y); - var R = pixel.R; - var G = pixel.G; - var B = pixel.B; - var newColor = Color.FromArgb(255, B, G, R); - bitmap.SetPixel(x, y, newColor); - } - } - - // get raw PNG bytes - using var b = new MemoryStream(); - bitmap.Save(b, System.Drawing.Imaging.ImageFormat.Png); - - return b.ToArray(); + (pixels[index], pixels[index + 2]) = (pixels[index + 2], pixels[index]); + pixels[index + 3] = 255; } - } - void DoNotConvertExport(byte[] imgBytes, string outputPath, string fileDirectory) - { - _imageSaveHandler.Save(imgBytes, fileDirectory); + using var converted = BitmapPixelBuffer.CreateBitmap(bitmap.Width, bitmap.Height, pixels); + using var output = new MemoryStream(); + converted.Save(output, System.Drawing.Imaging.ImageFormat.Png); + return output.ToArray(); } } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs index 3ac22fe67..b0f29abf9 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs @@ -1,6 +1,5 @@ using System.Drawing; using System.IO; -using System.Numerics; using Editors.ImportExport.Misc; using MeshImportExport; using Shared.Core.PackFiles; @@ -41,9 +40,12 @@ public string Export(string filePath, string outputPath, bool convertToBlueNorma if (packFile == null) return ""; - var fileName = Path.GetFileNameWithoutExtension(filePath); - var outDirectory = Path.GetDirectoryName(outputPath); - var rawFilePath = outDirectory + "/" + fileName + "_raw.png"; + var fileName = Path.GetFileNameWithoutExtension( + filePath.Replace('\\', Path.DirectorySeparatorChar)); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; + var outputFilePath = Path.Combine( + outDirectory, + convertToBlueNormalMap ? fileName + ".png" : fileName + "_raw.png"); var bytes = packFile.DataSource.ReadData(); if (bytes == null || !bytes.Any()) @@ -53,9 +55,56 @@ public string Export(string filePath, string outputPath, bool convertToBlueNorma if (imgBytes == null || !imgBytes.Any()) throw new Exception($"image data invalid/empty. imgBytes.Count = {imgBytes?.Length}"); - _imageSaveHandler.Save(imgBytes, rawFilePath); + if (convertToBlueNormalMap) + imgBytes = ConvertPackedNormalToStandard(imgBytes); - return rawFilePath; + _imageSaveHandler.Save(imgBytes, outputFilePath); + + return outputFilePath; + } + + private static byte[] ConvertPackedNormalToStandard(byte[] pngBytes) + { + using var inputStream = new MemoryStream(pngBytes); + using var image = Image.FromStream(inputStream); + using var source = new Bitmap(image); + var sourcePixels = BitmapPixelBuffer.ReadBgra(source); + var outputPixels = new byte[sourcePixels.Length]; + + for (var index = 0; index < sourcePixels.Length; index += 4) + { + var packedR = sourcePixels[index + 2]; + var packedG = sourcePixels[index + 1]; + var packedA = sourcePixels[index + 3]; + + // WH3 stores tangent-space X as R*A and Y as G. Keep the + // shader's Y orientation; only reconstruct Z and emit a + // conventional opaque RGB normal texture. + var x01 = (packedR / 255d) * (packedA / 255d); + var y01 = packedG / 255d; + var normalX = Math.Clamp(2d * x01 - 1d, -1d, 1d); + var normalY = Math.Clamp(2d * y01 - 1d, -1d, 1d); + var normalZ = Math.Sqrt(Math.Max(0d, 1d - normalX * normalX - normalY * normalY)); + + outputPixels[index] = EncodeNormalComponent(normalZ); + outputPixels[index + 1] = EncodeNormalComponent(normalY); + outputPixels[index + 2] = EncodeNormalComponent(normalX); + outputPixels[index + 3] = 255; + } + + using var output = BitmapPixelBuffer.CreateBitmap(source.Width, source.Height, outputPixels); + using var outputStream = new MemoryStream(); + output.Save(outputStream, System.Drawing.Imaging.ImageFormat.Png); + return outputStream.ToArray(); + } + + private static byte EncodeNormalComponent(double component) + { + if (!double.IsFinite(component)) + return 0; + + var encoded = ((Math.Clamp(component, -1d, 1d) + 1d) * 0.5d) * 255d; + return (byte)Math.Clamp((int)Math.Round(encoded, MidpointRounding.AwayFromZero), 0, 255); } } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs index 069b97058..cd3a545fc 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfAnimationBuilder.cs @@ -26,7 +26,7 @@ public GltfAnimationBuilder(IPackFileService packFileServoce) _packFileService = packFileServoce; } - public void Build(AnimationFile animSkeleton, RmvToGltfExporterSettings settings, ProcessedGltfSkeleton gltfSkeleton, ModelRoot outputScene) + public virtual void Build(AnimationFile animSkeleton, RmvToGltfExporterSettings settings, ProcessedGltfSkeleton gltfSkeleton, ModelRoot outputScene) { foreach (var animationPackFile in settings.InputAnimationFiles) { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs index 7eb485d4b..bfd4757a2 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs @@ -1,6 +1,7 @@ using System.IO; using System.Numerics; using Editors.ImportExport.Common; +using GameWorld.Core.Services; using Shared.GameFormats.RigidModel; using Shared.GameFormats.RigidModel.Vertex; using SharpGLTF.Geometry; @@ -13,6 +14,33 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers public class GltfMeshBuilder { public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings, bool willHaveSkeleton = true) + => Build(rmv2, textures, settings, willHaveSkeleton, null); + + // Keep the original public signature for existing callers and binary + // consumers. Composed VMD exports use the overload below to provide a + // stable per-part name prefix. + public List> Build( + ResolvedModelAsset asset, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton = true) + => Build(asset, textures, settings, willHaveSkeleton, null); + + public List> Build( + ResolvedModelAsset asset, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton, + string? namePrefix) + => Build(asset.Model, textures, settings, willHaveSkeleton, asset.FirstLod.Select(x => x.Material).ToArray(), namePrefix); + + private List> Build( + RmvFile rmv2, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton, + IReadOnlyList? effectiveMaterials, + string? namePrefix = null) { var lodLevel = rmv2.ModelList.First(); var hasSkeleton = willHaveSkeleton && string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; @@ -22,8 +50,15 @@ public List> Build(RmvFile rmv2, Listx.MeshIndex == i).ToList(); - var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures); - var gltfMesh = GenerateMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, hasSkeleton, settings.MirrorMesh); + var effectiveMaterial = effectiveMaterials != null && i < effectiveMaterials.Count ? effectiveMaterials[i] : null; + var baseName = string.IsNullOrWhiteSpace(rmvMesh.Material.ModelName) + ? $"Part_{i}" + : rmvMesh.Material.ModelName; + var modelName = string.IsNullOrWhiteSpace(namePrefix) + ? baseName + : $"{namePrefix}_{i:D3}_{baseName}"; + var gltfMaterial = Create(settings, modelName + "_Material", meshTextures, effectiveMaterial); + var gltfMesh = GenerateMesh(rmvMesh.Mesh, modelName, gltfMaterial, hasSkeleton, settings.MirrorMesh); meshes.Add(gltfMesh); } return meshes; @@ -59,17 +94,22 @@ MeshBuilder Generate { glTfvertex = SetVertexInfluences(vertex, glTfvertex); } - else if (hasAnyWeights) + else { - // If some vertices have weights in this mesh we enabled validation. - // Ensure vertices without weights get a default binding so validation passes. + // VertexJoints4 still carries a JOINTS/WEIGHTS attribute + // even for an otherwise rigid mesh. Give those vertices + // a valid neutral binding whenever a skeleton is in the + // scene, including the all-unweighted case, so the + // SharpGLTF validator does not reject zero-sum weights. glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); } } - else if (hasAnyWeights) + else { - // Model has weight data but no skeleton is available. - // Set default binding to prevent validation errors. + // The exporter uses VertexJoints4 for every dynamic mesh, + // even when no skeleton is available. Keep its optional + // weight attribute valid for both weighted and completely + // rigid meshes so SharpGLTF can validate the primitive. glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); } @@ -163,12 +203,23 @@ VertexBuilder SetVer return glTfvertex; } - MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, List texturesForModel) + MaterialBuilder Create( + RmvToGltfExporterSettings settings, + string materialName, + List texturesForModel, + ResolvedModelMaterial? effectiveMaterial = null) { var material = new MaterialBuilder(materialName) .WithDoubleSide(true) - .WithMetallicRoughness() - .WithAlpha(AlphaMode.MASK); + .WithMetallicRoughness(); + + // Keep the existing RMV2 export behavior (masked material) when + // the source material has no usable alpha flag. Weighted RMV2 and + // WSModel materials both expose an explicit effective value. + var alphaMode = effectiveMaterial?.HasExplicitAlpha == true + ? (effectiveMaterial.Alpha ? AlphaMode.MASK : AlphaMode.OPAQUE) + : AlphaMode.MASK; + material.WithAlpha(alphaMode); foreach (var texture in texturesForModel) { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs index 3f119558f..f5ff07736 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfSceneSaver.cs @@ -1,4 +1,6 @@ -using System.Windows; +using System.IO; +using System.Linq; +using System.Windows; using Shared.Core.ErrorHandling.Exceptions; using Shared.Core.Services; using SharpGLTF.Schema2; @@ -9,6 +11,18 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers public interface IGltfSceneSaver { public void Save(ModelRoot modelRoot, string fullSystemPath); + + /// + /// Saves a scene and, for binary glTF output, removes the exact + /// generated texture intermediates supplied by the exporter after the + /// save succeeds. The default implementation preserves compatibility + /// with existing saver implementations. + /// + public void Save( + ModelRoot modelRoot, + string fullSystemPath, + IReadOnlyCollection generatedTexturePaths) + => Save(modelRoot, fullSystemPath); } public class GltfSceneSaver : IGltfSceneSaver @@ -21,10 +35,31 @@ public GltfSceneSaver(IStandardDialogs exceptionService) } public void Save(ModelRoot modelRoot, string fullSystemPath) - { + => Save(modelRoot, fullSystemPath, Array.Empty()); + + public void Save( + ModelRoot modelRoot, + string fullSystemPath, + IReadOnlyCollection generatedTexturePaths) + { try { - modelRoot.SaveGLTF(fullSystemPath); + // SharpGLTF selects the container from the requested extension: + // .gltf produces the JSON/sidecar form and .glb is embedded. + modelRoot.Save(fullSystemPath); + + if (string.Equals(Path.GetExtension(fullSystemPath), ".glb", StringComparison.OrdinalIgnoreCase)) + { + foreach (var texturePath in generatedTexturePaths + .Where(x => string.Equals(Path.GetExtension(x), ".png", StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + // These are exact paths returned by the texture + // handler. Never infer or wildcard-delete filenames. + if (File.Exists(texturePath)) + File.Delete(texturePath); + } + } } catch (Exception ex) { diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs index 8be7407b8..a5bc3d99c 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs @@ -1,7 +1,9 @@ using System.IO; using System.Numerics; using Editors.ImportExport.Common; +using GameWorld.Core.Services; using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.Types; using SharpGLTF.Geometry; using SharpGLTF.Geometry.VertexTypes; using SharpGLTF.Materials; @@ -12,6 +14,16 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers public class GltfStaticMeshBuilder { public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings) + => Build(rmv2, textures, settings, null); + + public List> Build(ResolvedModelAsset asset, List textures, RmvToGltfExporterSettings settings) + => Build(asset.Model, textures, settings, asset.FirstLod.Select(x => x.Material).ToArray()); + + private List> Build( + RmvFile rmv2, + List textures, + RmvToGltfExporterSettings settings, + IReadOnlyList? effectiveMaterials) { var lodLevel = rmv2.ModelList.First(); @@ -20,7 +32,16 @@ public List> Build(RmvFile rmv2, List x.MeshIndex == i).ToList(); - var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures); + var effectiveMaterial = effectiveMaterials != null && i < effectiveMaterials.Count ? effectiveMaterials[i] : null; + // Masks are exported as auxiliary files, not glTF channels, but + // still participate in the existing masked-material heuristic. + // Prefer the effective material so an explicit empty WSModel + // mask override is not replaced by the RMV fallback. + var maskTexturePath = effectiveMaterial != null + ? effectiveMaterial.GetTexture(TextureType.Mask) + : rmvMesh.Material.GetTexture(TextureType.Mask)?.Path; + var hasMaskTexture = !string.IsNullOrWhiteSpace(maskTexturePath); + var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures, effectiveMaterial, hasMaskTexture); var gltfMesh = GenerateStaticMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, settings.MirrorMesh); meshes.Add(gltfMesh); } @@ -113,7 +134,7 @@ Vector4 ValidateAndFixTangent(Vector4 tangent, Vector3 normal) tangentXYZ = Vector3.Normalize(tangentXYZ); } - // Ensure tangent handedness is valid (W should be 1, typically 1 for right-handed) + // Ensure tangent handedness is valid (W should be -1, typically 1 for right-handed) float handedness = tangent.W; if (Math.Abs(handedness) < 0.5f) { @@ -144,7 +165,12 @@ Vector3 GeneratePerpendicularVector(Vector3 normal) return Vector3.Normalize(tangent); } - MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, List texturesForModel) + MaterialBuilder Create( + RmvToGltfExporterSettings settings, + string materialName, + List texturesForModel, + ResolvedModelMaterial? effectiveMaterial = null, + bool hasMaskTexture = false) { // Option 4: Material Enhancement with proper PBR setup var material = new MaterialBuilder(materialName) @@ -155,7 +181,8 @@ MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, bool hasAlphaMaskedTexture = texturesForModel.Any(t => t.HasAlphaChannel); bool hasMaskInName = texturesForModel.Any(t => t.SystemFilePath.Contains("mask", StringComparison.OrdinalIgnoreCase) || - t.SystemFilePath.Contains("_m.", StringComparison.OrdinalIgnoreCase)); + t.SystemFilePath.Contains("_m.", StringComparison.OrdinalIgnoreCase)) + || hasMaskTexture; bool hasTransparency = texturesForModel.Any(t => t.SystemFilePath.Contains("alpha", StringComparison.OrdinalIgnoreCase) || t.SystemFilePath.Contains("transparent", StringComparison.OrdinalIgnoreCase)); @@ -171,7 +198,11 @@ MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, materialName.Contains("chain", StringComparison.OrdinalIgnoreCase); // Set appropriate alpha mode - if (hasTransparency) + if (effectiveMaterial?.HasExplicitAlpha == true) + { + material.WithAlpha(effectiveMaterial.Alpha ? AlphaMode.MASK : AlphaMode.OPAQUE); + } + else if (hasTransparency) { material.WithAlpha(AlphaMode.BLEND); } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 564f4cddc..442939dc6 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -1,28 +1,57 @@ using System.IO; +using System.Security.Cryptography; +using System.Text; using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; +using GameWorld.Core.Services; using Shared.GameFormats.RigidModel; using Shared.GameFormats.RigidModel.Types; using Shared.Core.PackFiles; +using Editors.ImportExport.Misc; using SharpGLTF.Materials; namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers { - public record TextureResult(int MeshIndex, string SystemFilePath, KnownChannel GlftTexureType, bool HasAlphaChannel = false); + public record TextureResult( + int MeshIndex, + string SystemFilePath, + KnownChannel GlftTexureType, + bool HasAlphaChannel = false); public record MaskTextureResult(int MeshIndex, string SystemFilePath); + /// + /// Conversion cache owned by one export operation. Composed VMD exports + /// share this instance across all component models so the same source is + /// converted once and different source paths with the same basename get + /// distinct output files. + /// + public sealed class GltfTextureExportSession + { + public GltfTextureExportSession(bool collisionSafe = true) + { + CollisionSafe = collisionSafe; + } + + internal Dictionary ExportedTextures { get; } = new(StringComparer.OrdinalIgnoreCase); + internal bool CollisionSafe { get; } + } + public interface IGltfTextureHandler { public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSettings settings); + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings) + => HandleTextures(asset.Model, settings); + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings, GltfTextureExportSession session) + => HandleTextures(asset, settings); } public class GltfTextureHandler : IGltfTextureHandler { private readonly IDdsToNormalPngExporter _ddsToNormalPngExporter; private readonly IDdsToMaterialPngExporter _ddsToMaterialPngExporter; - private readonly IPackFileService _packFileService; + private readonly IPackFileService? _packFileService; - public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IPackFileService packFileService = null) + public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IPackFileService? packFileService = null) { _ddsToNormalPngExporter = ddsToNormalPngExporter; _ddsToMaterialPngExporter = ddsToMaterialPngExporter; @@ -37,7 +66,7 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett if (!settings.ExportMaterials) return output; - var exportedTextures = new Dictionary(); // To avoid exporting same texture multiple times + var session = new GltfTextureExportSession(collisionSafe: false); int lodICounnt = 1; for (var lodIndex = 0; lodIndex < lodICounnt; lodIndex++) @@ -49,21 +78,7 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett foreach (var tex in textures) { - switch (tex.Type) - { - case TextureType.Normal: DoTextureConversionNormalMap(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.MaterialMap: DoTextureConversionMaterialMap(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.BaseColour: - case TextureType.Diffuse: - DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); - break; - case TextureType.Mask: DoTextureMask(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Specular: DoTextureDefault(KnownChannel.SpecularColor, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Gloss: DoTextureDefault(KnownChannel.MetallicRoughness, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Ambient_occlusion: DoTextureDefault(KnownChannel.Occlusion, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Emissive: DoTextureDefault(KnownChannel.Emissive, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.EmissiveDistortion: DoTextureDefault(KnownChannel.Emissive, settings, output, exportedTextures, meshIndex, tex); break; - } + HandleTexture(settings, output, session, meshIndex, tex); } } @@ -72,6 +87,37 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett return output; } + + /// + /// Exports textures from the effective material for each LOD0 part. A + /// resolved WSModel material contains only its overrides; the resolver + /// has already merged those values with the RMV2 material, matching the + /// viewport's fallback behavior. + /// + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings) + => HandleTextures(asset, settings, new GltfTextureExportSession(collisionSafe: false)); + + public List HandleTextures(ResolvedModelAsset asset, RmvToGltfExporterSettings settings, GltfTextureExportSession session) + { + var output = new List(); + + if (!settings.ExportMaterials) + return output; + + foreach (var part in asset.FirstLod) + { + foreach (var texture in part.Material.Textures) + { + if (string.IsNullOrWhiteSpace(texture.Value)) + continue; + + var input = new MaterialBuilderTextureInput(texture.Value, texture.Key); + HandleTexture(settings, output, session, part.PartIndex, input); + } + } + + return output; + } interface IDDsToPngExporter { public string Export(string path, string outputPath, bool convertToBlender) @@ -90,37 +136,139 @@ List ExtractTextures(RmvModel model) record MaterialBuilderTextureInput(string Path, TextureType Type); - private void DoTextureConversionMaterialMap(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private static string CacheKey(MaterialBuilderTextureInput texture, string conversion, bool option = false) + => $"{NormalizeTexturePath(texture.Path)}|{conversion}|{option}"; + + private static string NormalizeTexturePath(string path) + => path.Replace('\\', '/').Trim().ToLowerInvariant(); + + private static string FinalizeTexturePath( + GltfTextureExportSession session, + string cacheKey, + string sourcePath, + string? exportedPath) + { + if (string.IsNullOrWhiteSpace(exportedPath) || !session.CollisionSafe) + return exportedPath ?? string.Empty; + + var directory = Path.GetDirectoryName(exportedPath) ?? string.Empty; + var extension = Path.GetExtension(exportedPath); + var stem = Path.GetFileNameWithoutExtension(exportedPath); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + $"{NormalizeTexturePath(sourcePath)}|{cacheKey}"))).ToLowerInvariant()[..10]; + var targetPath = Path.Combine(directory, $"{stem}_{hash}{extension}"); + + // The DDS exporters choose their own basename. Move the completed + // file immediately so a later component with the same basename + // cannot overwrite it. Mocks may return a path without a file; + // returning the deterministic target still keeps glTF references + // distinct in those cases. + if (!string.Equals(exportedPath, targetPath, StringComparison.OrdinalIgnoreCase) + && File.Exists(exportedPath)) + { + if (File.Exists(targetPath)) + File.Delete(targetPath); + File.Move(exportedPath, targetPath); + } + + return targetPath; + } + + private static string GetCollisionSafeStem( + GltfTextureExportSession session, + string cacheKey, + string sourcePath, + string stem) + { + if (!session.CollisionSafe) + return stem; + + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + $"{NormalizeTexturePath(sourcePath)}|{cacheKey}"))).ToLowerInvariant()[..10]; + return $"{stem}_{hash}"; + } + + private void HandleTexture( + RmvToGltfExporterSettings settings, + List output, + GltfTextureExportSession session, + int meshIndex, + MaterialBuilderTextureInput texture) + { + switch (texture.Type) + { + case TextureType.Normal: + DoTextureConversionNormalMap(settings, output, session, meshIndex, texture); + break; + case TextureType.MaterialMap: + DoTextureConversionMaterialMap(settings, output, session, meshIndex, texture); + break; + case TextureType.BaseColour: + case TextureType.Diffuse: + DoTextureDefault(KnownChannel.BaseColor, settings, output, session, meshIndex, texture); + break; + case TextureType.Mask: + DoTextureMask(settings, session, texture); + break; + case TextureType.Specular: + DoTextureDefault(KnownChannel.SpecularColor, settings, output, session, meshIndex, texture); + break; + case TextureType.Gloss: + DoTextureDefault(KnownChannel.MetallicRoughness, settings, output, session, meshIndex, texture); + break; + case TextureType.Ambient_occlusion: + DoTextureDefault(KnownChannel.Occlusion, settings, output, session, meshIndex, texture); + break; + case TextureType.Emissive: + case TextureType.EmissiveDistortion: + DoTextureDefault(KnownChannel.Emissive, settings, output, session, meshIndex, texture); + break; + } + } + + private void DoTextureConversionMaterialMap(RmvToGltfExporterSettings settings, List output, GltfTextureExportSession session, int meshIndex, MaterialBuilderTextureInput text) { - if (exportedTextures.ContainsKey(text.Path) == false) - exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertMaterialTextureToBlender); + var cacheKey = CacheKey(text, "material", settings.ConvertMaterialTextureToBlender); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) + { + var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertMaterialTextureToBlender); + session.ExportedTextures[cacheKey] = FinalizeTexturePath(session, cacheKey, text.Path, exportedPath); + } - var systemPath = exportedTextures[text.Path]; - if (systemPath != null) + var systemPath = session.ExportedTextures[cacheKey]; + if (string.IsNullOrWhiteSpace(systemPath) == false) output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.MetallicRoughness)); } - private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSettings settings, List output, GltfTextureExportSession session, int meshIndex, MaterialBuilderTextureInput text) { - if (exportedTextures.ContainsKey(text.Path) == false) + var cacheKey = CacheKey(text, "default"); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) { - exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); + var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); + session.ExportedTextures[cacheKey] = FinalizeTexturePath(session, cacheKey, text.Path, exportedPath); // For 3D printing: Export alpha channel as a separate mask for base color/diffuse if (settings.ExportDisplacementMaps && textureType == KnownChannel.BaseColor) { - ExportAlphaMask(text.Path, settings.OutputPath); + ExportAlphaMask( + text.Path, + settings.OutputPath, + session.CollisionSafe + ? GetCollisionSafeStem(session, cacheKey, text.Path, Path.GetFileNameWithoutExtension(text.Path)) + : null); } } - var systemPath = exportedTextures[text.Path]; - if (systemPath != null) - output.Add(new TextureResult(meshIndex, systemPath, textureType, false)); + var systemPath = session.ExportedTextures[cacheKey]; + if (string.IsNullOrWhiteSpace(systemPath) == false) + output.Add(new TextureResult(meshIndex, systemPath, textureType)); } - private void DoTextureMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private void DoTextureMask(RmvToGltfExporterSettings settings, GltfTextureExportSession session, MaterialBuilderTextureInput text) { - if (exportedTextures.ContainsKey(text.Path) == false) + var cacheKey = CacheKey(text, "mask"); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) { // Export mask as separate PNG - name it with _mask suffix for clarity var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); @@ -130,10 +278,11 @@ private void DoTextureMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) + private void DoTextureConversionNormalMap(RmvToGltfExporterSettings settings, List output, GltfTextureExportSession session, int meshIndex, MaterialBuilderTextureInput text) { - if (exportedTextures.ContainsKey(text.Path) == false) + var cacheKey = CacheKey(text, "normal", settings.ConvertNormalTextureToBlue || settings.ExportDisplacementMaps); + if (session.ExportedTextures.ContainsKey(cacheKey) == false) { // Only export displacement maps for 3D printing workflow if (settings.ExportDisplacementMaps) { // Export normal map variants with proper YCoCg decoding - ExportNormalMapVariants(text.Path, settings.OutputPath); - ExportDisplacementFromNormalMap(text.Path, settings.OutputPath, settings); + var outputStem = session.CollisionSafe + ? GetCollisionSafeStem(session, cacheKey, text.Path, Path.GetFileNameWithoutExtension(text.Path)) + : Path.GetFileNameWithoutExtension(text.Path); + ExportNormalMapVariants(text.Path, settings.OutputPath, outputStem); + ExportDisplacementFromNormalMap(text.Path, settings.OutputPath, settings, outputStem); // Set the path to the raw normal map - var fileName = Path.GetFileNameWithoutExtension(text.Path); - var outDirectory = Path.GetDirectoryName(settings.OutputPath); - exportedTextures[text.Path] = Path.Combine(outDirectory, fileName + "_raw.png"); + var outDirectory = Path.GetDirectoryName(settings.OutputPath) ?? string.Empty; + var rawNormalPath = Path.Combine(outDirectory, outputStem + "_raw.png"); + session.ExportedTextures[cacheKey] = session.CollisionSafe + ? rawNormalPath + : FinalizeTexturePath(session, cacheKey, text.Path, rawNormalPath); } else { // Regular export: use the standard DDS to PNG exporter - exportedTextures[text.Path] = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + var exportedPath = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + session.ExportedTextures[cacheKey] = FinalizeTexturePath(session, cacheKey, text.Path, exportedPath); } } - var systemPath = exportedTextures[text.Path]; - if (systemPath != null) + var systemPath = session.ExportedTextures[cacheKey]; + if (string.IsNullOrWhiteSpace(systemPath) == false) output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.Normal)); } - private void ExportNormalMapVariants(string packFilePath, string outputPath) + private void ExportNormalMapVariants(string packFilePath, string outputPath, string? outputStem = null) { if (_packFileService == null) return; @@ -231,8 +381,8 @@ private void ExportNormalMapVariants(string packFilePath, string outputPath) if (packFile == null) return; - var fileName = Path.GetFileNameWithoutExtension(packFilePath); - var outDirectory = Path.GetDirectoryName(outputPath); + var fileName = outputStem ?? Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; var bytes = packFile.DataSource.ReadData(); if (bytes != null && bytes.Any()) @@ -242,7 +392,7 @@ private void ExportNormalMapVariants(string packFilePath, string outputPath) } } - private void ExportAlphaMask(string packFilePath, string outputPath) + private void ExportAlphaMask(string packFilePath, string outputPath, string? outputStem = null) { if (_packFileService == null) return; @@ -251,8 +401,8 @@ private void ExportAlphaMask(string packFilePath, string outputPath) if (packFile == null) return; - var fileName = Path.GetFileNameWithoutExtension(packFilePath); - var outDirectory = Path.GetDirectoryName(outputPath); + var fileName = outputStem ?? Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; var bytes = packFile.DataSource.ReadData(); if (bytes == null || !bytes.Any()) @@ -299,10 +449,21 @@ private void ExportAlphaMask(string packFilePath, string outputPath) maskBitmap.Save(maskPath, System.Drawing.Imaging.ImageFormat.Png); } - public void ExportDisplacementFromNormalMap(string normalMapPath, string outputPath, RmvToGltfExporterSettings settings) + // Preserve the original public signature for existing callers. + public void ExportDisplacementFromNormalMap( + string normalMapPath, + string outputPath, + RmvToGltfExporterSettings settings) + => ExportDisplacementFromNormalMap(normalMapPath, outputPath, settings, null); + + public void ExportDisplacementFromNormalMap( + string normalMapPath, + string outputPath, + RmvToGltfExporterSettings settings, + string? outputStem) { - var fileName = Path.GetFileNameWithoutExtension(normalMapPath); - var outDirectory = Path.GetDirectoryName(outputPath); + var fileName = outputStem ?? Path.GetFileNameWithoutExtension(normalMapPath); + var outDirectory = Path.GetDirectoryName(outputPath) ?? string.Empty; if (_packFileService == null) return; diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfAnimationSourceResolver.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfAnimationSourceResolver.cs new file mode 100644 index 000000000..bb32585ad --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfAnimationSourceResolver.cs @@ -0,0 +1,235 @@ +using System.Collections.ObjectModel; +using GameWorld.Core.Services; +using Shared.Core.PackFiles.Models; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf; + +/// +/// The animation catalog available for an RMV2, WSModel, or composed VMD +/// export. It retains the original animation references and their pack-file +/// containers so callers can resolve the exact selected file later. +/// +public sealed class GltfAnimationCatalog +{ + public GltfAnimationCatalog( + string? skeletonName, + bool hasSkeletonFile, + IReadOnlyList animations, + IReadOnlyList diagnostics) + { + SkeletonName = skeletonName; + HasSkeletonFile = hasSkeletonFile; + Animations = new ReadOnlyCollection(animations.ToList()); + Diagnostics = new ReadOnlyCollection(diagnostics.ToList()); + } + + public string? SkeletonName { get; } + public bool HasSkeletonFile { get; } + public IReadOnlyList Animations { get; } + public IReadOnlyList Diagnostics { get; } +} + +public interface IGltfAnimationCatalogResolver +{ + GltfAnimationCatalog Resolve(PackFile inputFile); +} + +/// +/// Resolves the animation catalog using the same model, VMD composition, and +/// skeleton lookup services as the exporter. VMD components are visited in the +/// same order as exporter flattening: the node's model, its model reference, +/// then selected slot children. +/// +public sealed class GltfAnimationCatalogResolver : IGltfAnimationCatalogResolver +{ + private readonly IModelAssetResolver _modelAssetResolver; + private readonly IVariantMeshCompositionResolver _variantMeshResolver; + private readonly ISkeletonAnimationLookUpHelper _skeletonAnimationLookUpHelper; + + public GltfAnimationCatalogResolver( + IModelAssetResolver modelAssetResolver, + IVariantMeshCompositionResolver variantMeshResolver, + ISkeletonAnimationLookUpHelper skeletonAnimationLookUpHelper) + { + _modelAssetResolver = modelAssetResolver; + _variantMeshResolver = variantMeshResolver; + _skeletonAnimationLookUpHelper = skeletonAnimationLookUpHelper; + } + + public GltfAnimationCatalog Resolve(PackFile inputFile) + { + ArgumentNullException.ThrowIfNull(inputFile); + + var source = IsVariantMeshDefinition(inputFile) + ? ResolveVariantMeshSource(inputFile) + : ResolveModelSource(inputFile); + + var diagnostics = new List(source.Diagnostics); + var skeletonName = source.SkeletonName; + if (string.IsNullOrWhiteSpace(skeletonName)) + return new GltfAnimationCatalog(null, false, [], diagnostics); + + try + { + var skeletonFile = _skeletonAnimationLookUpHelper.GetSkeletonFileFromName(skeletonName); + if (skeletonFile == null) + { + diagnostics.Add($"Skeleton '{skeletonName}' was not found in the loaded packs."); + return new GltfAnimationCatalog(skeletonName, false, [], diagnostics); + } + + var animations = _skeletonAnimationLookUpHelper.GetAnimationsForSkeleton(skeletonName); + return new GltfAnimationCatalog(skeletonName, true, animations, diagnostics); + } + catch (Exception exception) + { + diagnostics.Add($"Unable to load animation catalog for skeleton '{skeletonName}': {exception.Message}"); + return new GltfAnimationCatalog(skeletonName, false, [], diagnostics); + } + } + + private RmvToGltfAnimationSource ResolveModelSource(PackFile inputFile) + { + try + { + var asset = _modelAssetResolver.Resolve(inputFile); + var diagnostics = new List(asset.Diagnostics); + var skeletonName = SelectSharedSkeletonName([asset], diagnostics); + return new RmvToGltfAnimationSource(skeletonName, diagnostics); + } + catch (Exception exception) + { + return new RmvToGltfAnimationSource( + null, + [$"Unable to resolve model '{inputFile.Name}' for animation export: {exception.Message}"]); + } + } + + private RmvToGltfAnimationSource ResolveVariantMeshSource(PackFile inputFile) + { + ResolvedVariantMeshComposition composition; + try + { + composition = _variantMeshResolver.Resolve(inputFile); + } + catch (Exception exception) + { + return new RmvToGltfAnimationSource( + null, + [$"Unable to resolve VariantMeshDefinition '{inputFile.Name}' for animation export: {exception.Message}"]); + } + + var diagnostics = new List(composition.Diagnostics); + + if (composition.Root == null || composition.HasRenderableContent == false) + { + diagnostics.Add($"VariantMeshDefinition '{inputFile.Name}' has no renderable model components."); + return new RmvToGltfAnimationSource(null, diagnostics); + } + + var assets = EnumerateComponents(composition.Root) + .Select(x => x.Asset) + .ToList(); + var skeletonName = SelectSharedSkeletonName(assets, diagnostics); + return new RmvToGltfAnimationSource(skeletonName, diagnostics); + } + + /// + /// Returns composed model components in the export order. Keeping this + /// traversal shared avoids the UI selecting animations for a different + /// VMD root skeleton than the exporter uses. + /// + internal static IEnumerable EnumerateComponents(ResolvedVariantMeshNode node) + { + if (node.ModelAsset != null) + yield return new RmvToGltfResolvedComponent(node.ModelAsset, string.Empty); + + if (node.ResolvedModelReference != null) + { + foreach (var component in EnumerateComponents(node.ResolvedModelReference)) + yield return component; + } + + foreach (var slot in node.Slots) + { + if (slot.SelectedChild == null) + continue; + + foreach (var component in EnumerateComponents(slot.SelectedChild, slot.AttachmentPoint)) + yield return component; + } + } + + private static IEnumerable EnumerateComponents( + ResolvedVariantMeshNode node, + string attachmentPoint) + { + if (node.ModelAsset != null) + yield return new RmvToGltfResolvedComponent(node.ModelAsset, attachmentPoint); + + if (node.ResolvedModelReference != null) + { + foreach (var component in EnumerateComponents(node.ResolvedModelReference, attachmentPoint)) + yield return component; + } + + foreach (var slot in node.Slots) + { + if (slot.SelectedChild == null) + continue; + + foreach (var component in EnumerateComponents(slot.SelectedChild, slot.AttachmentPoint)) + yield return component; + } + } + + /// + /// Selects the first non-empty component skeleton, matching the composed + /// exporter. Different component skeletons are reported to the caller; + /// they must not silently change the animation source. + /// + internal static string? SelectSharedSkeletonName( + IEnumerable assets, + ICollection? diagnostics = null) + { + var names = assets + .Select(x => x.Model.Header.SkeletonName) + .Where(x => string.IsNullOrWhiteSpace(x) == false) + .ToList(); + var selected = names.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(selected)) + return null; + + foreach (var other in names.Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (string.Equals(other, selected, StringComparison.OrdinalIgnoreCase)) + continue; + + diagnostics?.Add( + $"Composed models use different skeletons ('{selected}' and '{other}'); " + + $"using '{selected}' for the shared glTF skeleton."); + break; + } + + return selected; + } + + private static bool IsVariantMeshDefinition(PackFile file) + => file.Name.EndsWith(".variantmeshdefinition", StringComparison.OrdinalIgnoreCase); + + private sealed class RmvToGltfAnimationSource + { + public RmvToGltfAnimationSource(string? skeletonName, IReadOnlyList diagnostics) + { + SkeletonName = skeletonName; + Diagnostics = diagnostics; + } + + public string? SkeletonName { get; } + public IReadOnlyList Diagnostics { get; } + } +} + +internal sealed record RmvToGltfResolvedComponent( + ResolvedModelAsset Asset, + string AttachmentPoint); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs index aa7949fd6..173466b6c 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs @@ -1,16 +1,23 @@ using System.Windows; +using Editors.ImportExport.Common; using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; using Editors.ImportExport.Misc; using GameWorld.Core.Services; using Shared.Core.PackFiles.Models; -using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.MaterialHeaders; using SharpGLTF.Geometry; using SharpGLTF.Materials; using SharpGLTF.Schema2; namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf { - public class RmvToGltfExporter + internal interface IRmvToGltfExporter + { + ExportSupportEnum CanExportFile(PackFile file); + void Export(RmvToGltfExporterSettings settings); + } + + public class RmvToGltfExporter : IRmvToGltfExporter { private readonly ILogger _logger = Logging.Create(); private readonly IGltfSceneSaver _gltfSaver; @@ -19,8 +26,40 @@ public class RmvToGltfExporter private readonly GltfSkeletonBuilder _gltfSkeletonBuilder; private readonly GltfAnimationBuilder _gltfAnimationBuilder; private readonly ISkeletonAnimationLookUpHelper _skeletonLookUpHelper; + private readonly IModelAssetResolver _modelAssetResolver; + private readonly IVariantMeshCompositionResolver? _variantMeshResolver; + + // Keep the pre-composition constructor signature intact. The + // composition resolver is an additive dependency for VMD exports. + public RmvToGltfExporter( + IGltfSceneSaver gltfSaver, + GltfMeshBuilder gltfMeshBuilder, + IGltfTextureHandler gltfTextureHandler, + GltfSkeletonBuilder gltfSkeletonsBuilder, + GltfAnimationBuilder gltfAnimationCreator, + ISkeletonAnimationLookUpHelper skeletonLookUpHelper, + IModelAssetResolver? modelAssetResolver = null) + : this( + gltfSaver, + gltfMeshBuilder, + gltfTextureHandler, + gltfSkeletonsBuilder, + gltfAnimationCreator, + skeletonLookUpHelper, + modelAssetResolver, + null) + { + } - public RmvToGltfExporter(IGltfSceneSaver gltfSaver, GltfMeshBuilder gltfMeshBuilder, IGltfTextureHandler gltfTextureHandler, GltfSkeletonBuilder gltfSkeletonsBuilder, GltfAnimationBuilder gltfAnimationCreator, ISkeletonAnimationLookUpHelper skeletonLookUpHelper) + public RmvToGltfExporter( + IGltfSceneSaver gltfSaver, + GltfMeshBuilder gltfMeshBuilder, + IGltfTextureHandler gltfTextureHandler, + GltfSkeletonBuilder gltfSkeletonsBuilder, + GltfAnimationBuilder gltfAnimationCreator, + ISkeletonAnimationLookUpHelper skeletonLookUpHelper, + IModelAssetResolver? modelAssetResolver, + IVariantMeshCompositionResolver? variantMeshResolver) { _gltfSaver = gltfSaver; _gltfMeshBuilder = gltfMeshBuilder; @@ -28,71 +67,330 @@ public RmvToGltfExporter(IGltfSceneSaver gltfSaver, GltfMeshBuilder gltfMeshBuil _gltfSkeletonBuilder = gltfSkeletonsBuilder; _gltfAnimationBuilder = gltfAnimationCreator; _skeletonLookUpHelper = skeletonLookUpHelper; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(); + _variantMeshResolver = variantMeshResolver; } internal ExportSupportEnum CanExportFile(PackFile file) { - if (FileExtensionHelper.IsRmvFile(file.Name)) + if (FileExtensionHelper.IsRmvFile(file.Name) + || FileExtensionHelper.IsWsModelFile(file.Name) + || IsVariantMeshDefinition(file)) return ExportSupportEnum.HighPriority; - if (FileExtensionHelper.IsWsModelFile(file.Name)) - return ExportSupportEnum.NotSupported; // This should be supported in the future return ExportSupportEnum.NotSupported; } + ExportSupportEnum IRmvToGltfExporter.CanExportFile(PackFile file) => CanExportFile(file); + + void IRmvToGltfExporter.Export(RmvToGltfExporterSettings settings) => Export(settings); + public void Export(RmvToGltfExporterSettings settings) { LogSettings(settings); - var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + if (IsVariantMeshDefinition(settings.InputModelFile)) + { + ExportVariantMesh(settings); + return; + } + + var resolvedAsset = _modelAssetResolver.Resolve(settings.InputModelFile); + foreach (var diagnostic in resolvedAsset.Diagnostics) + _logger.Here().Warning(diagnostic); + + var outputScene = ModelRoot.CreateModel(); + var modelPart = new ExportModelPart(resolvedAsset, string.Empty, "model", true, true); + // Keep the established direct RMV/WS behavior: those exports only + // request a skeleton when animation export is enabled. VMD + // composition intentionally decouples these choices below so slot + // attachments can still use a shared skeleton without clips. + ProcessedGltfSkeleton? skeleton = null; + global::Shared.GameFormats.Animation.AnimationFile? skeletonFile = null; + if (settings.ExportAnimations) + { + skeleton = CreateSharedSkeleton( + [modelPart], + settings, + outputScene, + warnWhenMissing: true, + out skeletonFile, + out var exportCancelled); + // Preserve the direct exporter behavior: choosing No in the + // missing-skeleton warning aborts without saving an output. + if (exportCancelled) + return; + } + if (skeleton != null && skeletonFile != null && settings.ExportAnimations) + _gltfAnimationBuilder.Build(skeletonFile, settings, skeleton, outputScene); + var textureSession = new GltfTextureExportSession(collisionSafe: false); + var textures = _gltfTextureHandler.HandleTextures(resolvedAsset, settings, textureSession); + var meshes = BuildMeshes(modelPart, textures, settings, skeleton != null); + + BuildGltfScene( + meshes, + skeleton, + settings, + outputScene, + textures.Select(x => x.SystemFilePath).ToArray()); + } + + private void ExportVariantMesh(RmvToGltfExporterSettings settings) + { + if (_variantMeshResolver == null) + throw new InvalidOperationException("VariantMeshDefinition export requires the variant mesh composition resolver."); + + var composition = _variantMeshResolver.Resolve(settings.InputModelFile); + foreach (var diagnostic in composition.Diagnostics) + _logger.Here().Warning(diagnostic); + + if (!composition.HasRenderableContent || composition.Root == null) + { + var details = composition.Diagnostics.Count == 0 + ? "No renderable model candidates were found." + : string.Join(Environment.NewLine, composition.Diagnostics); + throw new InvalidOperationException($"Unable to resolve VariantMeshDefinition '{settings.InputModelFile.Name}'. {details}"); + } + + var modelParts = FlattenModelParts(composition.Root); + if (modelParts.Count == 0) + throw new InvalidOperationException($"VariantMeshDefinition '{settings.InputModelFile.Name}' contains no renderable models."); + var outputScene = ModelRoot.CreateModel(); + var skeleton = CreateSharedSkeleton( + modelParts, + settings, + outputScene, + warnWhenMissing: false, + out var skeletonFile, + out _); + modelParts = ApplySharedSkeletonCompatibility(modelParts, skeleton); + var textureSession = new GltfTextureExportSession(collisionSafe: true); + var meshes = new List(); + var generatedTexturePaths = new HashSet(StringComparer.OrdinalIgnoreCase); - // Determine skeleton availability before building meshes to avoid weight validation issues - bool willHaveSkeleton = false; - ProcessedGltfSkeleton? gltfSkeleton = null; - if (settings.ExportAnimations && !string.IsNullOrEmpty(rmv2.Header.SkeletonName)) + foreach (var modelPart in modelParts) { - var skeletonAnimFile = _skeletonLookUpHelper.GetSkeletonFileFromName(rmv2.Header.SkeletonName); - if (skeletonAnimFile == null) - { - if (MessageBox.Show( + var textures = _gltfTextureHandler.HandleTextures(modelPart.Asset, settings, textureSession); + generatedTexturePaths.UnionWith(textures.Select(x => x.SystemFilePath)); + meshes.AddRange(BuildMeshes(modelPart, textures, settings, skeleton != null && modelPart.UseSharedSkeleton)); + } + + // Skeleton creation and animation export are intentionally separate: + // attachments and skinning remain useful when animation export is + // disabled, while selected animations are still emitted exactly once. + if (skeleton != null && skeletonFile != null && settings.ExportAnimations) + _gltfAnimationBuilder.Build(skeletonFile, settings, skeleton, outputScene); + + _logger.Here().Information($"VMD Export - Parts={modelParts.Count} MeshCount={meshes.Count} Skeleton={skeleton?.Data.Count}"); + BuildGltfScene(meshes, skeleton, settings, outputScene, generatedTexturePaths); + } + + private ProcessedGltfSkeleton? CreateSharedSkeleton( + IReadOnlyList modelParts, + RmvToGltfExporterSettings settings, + ModelRoot outputScene, + bool warnWhenMissing, + out global::Shared.GameFormats.Animation.AnimationFile? skeletonFile, + out bool exportCancelled) + { + skeletonFile = null; + exportCancelled = false; + var skeletonDiagnostics = new List(); + var skeletonName = GltfAnimationCatalogResolver.SelectSharedSkeletonName( + modelParts.Select(x => x.Asset), + skeletonDiagnostics); + if (string.IsNullOrWhiteSpace(skeletonName)) + return null; + + foreach (var diagnostic in skeletonDiagnostics) + _logger.Here().Warning(diagnostic); + + skeletonFile = _skeletonLookUpHelper.GetSkeletonFileFromName(skeletonName); + if (skeletonFile == null) + { + var message = $"Skeleton '{skeletonName}' was not found; exporting without a glTF skeleton."; + _logger.Here().Warning(message); + if (warnWhenMissing && settings.ExportAnimations + && MessageBox.Show( "Skeleton file not found, \n(Have you loaded all CA pakcs for the right game?)\n Do you want to continue exporting without skeleton/animations?", - "Warning!", - MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.No) - return; - } - else + "Warning!", MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.No) { - willHaveSkeleton = true; - gltfSkeleton = _gltfSkeletonBuilder.CreateSkeleton(skeletonAnimFile, outputScene, settings); - _gltfAnimationBuilder.Build(skeletonAnimFile, settings, gltfSkeleton, outputScene); + exportCancelled = true; + return null; } - } - - var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); - - var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings, willHaveSkeleton); - - _logger.Here().Information($"MeshCount={meshes.Count()} TextureCount={textures.Count()} Skeleton={gltfSkeleton?.Data.Count}"); - BuildGltfScene(meshes, gltfSkeleton, settings, outputScene); + return null; + } + + var gltfSkeleton = _gltfSkeletonBuilder.CreateSkeleton(skeletonFile, outputScene, settings); + return gltfSkeleton; } - void BuildGltfScene(List> meshBuilders, ProcessedGltfSkeleton? gltfSkeleton, RmvToGltfExporterSettings settings, ModelRoot outputScene) + private List FlattenModelParts(ResolvedVariantMeshNode node) + { + var output = new List(); + var nextIndex = 0; + foreach (var component in GltfAnimationCatalogResolver.EnumerateComponents(node)) + { + output.Add(new ExportModelPart( + component.Asset, + component.AttachmentPoint, + $"vmd_part_{nextIndex++:D3}", + false, + true)); + } + return output; + } + + private List BuildMeshes( + ExportModelPart modelPart, + List textures, + RmvToGltfExporterSettings settings, + bool willHaveSkeleton) + { + var output = new List(); + var meshBuilders = _gltfMeshBuilder.Build( + modelPart.Asset, + textures, + settings, + willHaveSkeleton, + modelPart.NamePrefix == "model" ? null : modelPart.NamePrefix); + + for (var i = 0; i < meshBuilders.Count; i++) + { + var matrixIndex = -1; + if (i < modelPart.Asset.FirstLod.Count + && modelPart.Asset.FirstLod[i].Material.SourceMaterial is WeightedMaterial weightedMaterial) + matrixIndex = weightedMaterial.MatrixIndex; + + var hasWeights = i < modelPart.Asset.FirstLod.Count + && modelPart.Asset.FirstLod[i].Model.Mesh.VertexList.Any(x => x.WeightCount > 0); + var pivotPoint = i < modelPart.Asset.FirstLod.Count + ? VecConv.GetSys(GlobalSceneTransforms.FlipVector( + modelPart.Asset.FirstLod[i].Material.SourceMaterial.PivotPoint, + settings.MirrorMesh)) + : System.Numerics.Vector3.Zero; + output.Add(new ExportedMesh( + meshBuilders[i], + modelPart.AttachmentPoint, + matrixIndex, + hasWeights, + willHaveSkeleton, + modelPart.AllowMatrixAttachment, + pivotPoint)); + } + + return output; + } + + internal void BuildGltfScene( + List meshes, + ProcessedGltfSkeleton? gltfSkeleton, + RmvToGltfExporterSettings settings, + ModelRoot outputScene, + IReadOnlyCollection? generatedTexturePaths = null) { var scene = outputScene.UseScene("default"); - foreach (var meshBuilder in meshBuilders) + foreach (var exportedMesh in meshes) { - var mesh = outputScene.CreateMesh(meshBuilder); + var mesh = outputScene.CreateMesh(exportedMesh.MeshBuilder); + Node? parent = null; if (gltfSkeleton != null) - scene.CreateNode(mesh.Name).WithSkinnedMesh(mesh, gltfSkeleton.Data.ToArray()); + { + var attachmentBone = FindAttachmentBone( + gltfSkeleton, + exportedMesh.AttachmentPoint, + exportedMesh.MatrixIndex, + exportedMesh.AllowMatrixAttachment); + if (attachmentBone != null) + parent = attachmentBone; + } + + var node = parent?.CreateNode(mesh.Name) ?? scene.CreateNode(mesh.Name); + // Rigid attachment is the renderer's precedence rule: an + // attachment resolver plus a valid RMV matrix override means + // the mesh follows that bone as a rigid object, so its vertex + // weights must not apply the same skeleton a second time. + var followsBoneRigidly = parent != null && exportedMesh.MatrixIndex >= 0; + node.WithLocalTranslation(exportedMesh.PivotPoint); + if (gltfSkeleton != null + && exportedMesh.CanUseSkeleton + && exportedMesh.HasWeights + && !followsBoneRigidly) + node.WithSkinnedMesh(mesh, gltfSkeleton.Data.ToArray()); else - scene.CreateNode(mesh.Name).WithMesh(mesh); + node.WithMesh(mesh); + } + + _gltfSaver.Save(outputScene, settings.OutputPath, generatedTexturePaths ?? Array.Empty()); + } + + internal static Node? FindAttachmentBone( + ProcessedGltfSkeleton skeleton, + string attachmentPoint, + int matrixIndex, + bool allowMatrixIndex = true) + { + if (!string.IsNullOrWhiteSpace(attachmentPoint)) + { + // Match SceneObjectEditor.WireAttachmentResolvers: a named + // attachment is authoritative. An unknown name does not + // fall back to the RMV matrix index. + return skeleton.Data + .Select(x => x.Item1) + .FirstOrDefault(x => string.Equals(x.Name, attachmentPoint, StringComparison.OrdinalIgnoreCase)); } - _gltfSaver.Save(outputScene, settings.OutputPath); + if (allowMatrixIndex && matrixIndex >= 0 && matrixIndex < skeleton.Data.Count) + return skeleton.Data[matrixIndex].Item1; + + return null; } - void LogSettings(RmvToGltfExporterSettings settings) + private List ApplySharedSkeletonCompatibility( + List modelParts, + ProcessedGltfSkeleton? skeleton) + { + if (skeleton == null) + return modelParts; + + var sharedSkeletonName = GltfAnimationCatalogResolver.SelectSharedSkeletonName( + modelParts.Select(x => x.Asset)); + if (string.IsNullOrWhiteSpace(sharedSkeletonName)) + return modelParts; + + return modelParts.Select(modelPart => + { + var componentSkeletonName = modelPart.Asset.Model.Header.SkeletonName; + var canUseSharedSkeleton = string.Equals( + componentSkeletonName, + sharedSkeletonName, + StringComparison.OrdinalIgnoreCase); + + if (!canUseSharedSkeleton && string.IsNullOrWhiteSpace(componentSkeletonName) == false) + { + _logger.Here().Warning( + $"VMD component '{modelPart.Asset.InputFile.Name}' uses skeleton '{componentSkeletonName}', " + + $"which differs from shared skeleton '{sharedSkeletonName}'; exporting it without skinning."); + } + + return modelPart with + { + UseSharedSkeleton = canUseSharedSkeleton, + // A component with a different named skeleton cannot + // safely interpret its own MatrixIndex in the shared + // skeleton. Named VMD attachments remain resolvable by + // name; pure MatrixIndex attachments stay unparented. + AllowMatrixAttachment = canUseSharedSkeleton + || string.IsNullOrWhiteSpace(modelPart.AttachmentPoint) == false + }; + }).ToList(); + } + + private static bool IsVariantMeshDefinition(PackFile file) + => file.Name.EndsWith(".variantmeshdefinition", StringComparison.OrdinalIgnoreCase); + + private void LogSettings(RmvToGltfExporterSettings settings) { var str = $"Exporting using {nameof(RmvToGltfExporter)}\n"; str += $"\tInputModelFile:{settings.InputModelFile?.Name}\n"; @@ -102,8 +400,24 @@ void LogSettings(RmvToGltfExporterSettings settings) str += $"\tConvertNormalTextureToBlue:{settings.ConvertNormalTextureToBlue}\n"; str += $"\tExportAnimations:{settings.ExportAnimations}\n"; str += $"\tMirrorMesh:{settings.MirrorMesh}\n"; - + _logger.Here().Information(str); } + + private sealed record ExportModelPart( + ResolvedModelAsset Asset, + string AttachmentPoint, + string NamePrefix, + bool UseSharedSkeleton, + bool AllowMatrixAttachment); + + internal sealed record ExportedMesh( + IMeshBuilder MeshBuilder, + string AttachmentPoint, + int MatrixIndex, + bool HasWeights, + bool CanUseSkeleton, + bool AllowMatrixAttachment, + System.Numerics.Vector3 PivotPoint); } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs index d3455e3fa..8c01c5037 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs @@ -1,5 +1,6 @@ using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; using Editors.ImportExport.Misc; +using GameWorld.Core.Services; using Shared.Core.PackFiles.Models; using Shared.GameFormats.RigidModel; using SharpGLTF.Geometry; @@ -14,12 +15,18 @@ public class RmvToGltfStaticExporter private readonly IGltfSceneSaver _gltfSaver; private readonly GltfStaticMeshBuilder _gltfMeshBuilder; private readonly IGltfTextureHandler _gltfTextureHandler; + private readonly IModelAssetResolver _modelAssetResolver; - public RmvToGltfStaticExporter(IGltfSceneSaver gltfSaver, GltfStaticMeshBuilder gltfMeshBuilder, IGltfTextureHandler gltfTextureHandler) + public RmvToGltfStaticExporter( + IGltfSceneSaver gltfSaver, + GltfStaticMeshBuilder gltfMeshBuilder, + IGltfTextureHandler gltfTextureHandler, + IModelAssetResolver? modelAssetResolver = null) { _gltfSaver = gltfSaver; _gltfMeshBuilder = gltfMeshBuilder; _gltfTextureHandler = gltfTextureHandler; + _modelAssetResolver = modelAssetResolver ?? new ModelAssetResolver(); } internal ExportSupportEnum CanExportFile(PackFile file) @@ -27,7 +34,7 @@ internal ExportSupportEnum CanExportFile(PackFile file) if (FileExtensionHelper.IsRmvFile(file.Name)) return ExportSupportEnum.Supported; if (FileExtensionHelper.IsWsModelFile(file.Name)) - return ExportSupportEnum.NotSupported; + return ExportSupportEnum.Supported; return ExportSupportEnum.NotSupported; } @@ -35,17 +42,29 @@ public void Export(RmvToGltfExporterSettings settings) { LogSettings(settings); - var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + var resolvedAsset = _modelAssetResolver.Resolve(settings.InputModelFile); + foreach (var diagnostic in resolvedAsset.Diagnostics) + _logger.Here().Warning(diagnostic); + var outputScene = ModelRoot.CreateModel(); - var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); - var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings); + var textureSession = new GltfTextureExportSession(collisionSafe: false); + var textures = _gltfTextureHandler.HandleTextures(resolvedAsset, settings, textureSession); + var meshes = _gltfMeshBuilder.Build(resolvedAsset, textures, settings); _logger.Here().Information($"Static Export - MeshCount={meshes.Count()} TextureCount={textures.Count()}"); - BuildGltfScene(meshes, settings, outputScene); + BuildGltfScene( + meshes, + settings, + outputScene, + textures.Select(x => x.SystemFilePath).ToArray()); } - void BuildGltfScene(List> meshBuilders, RmvToGltfExporterSettings settings, ModelRoot outputScene) + void BuildGltfScene( + List> meshBuilders, + RmvToGltfExporterSettings settings, + ModelRoot outputScene, + IReadOnlyCollection? generatedTexturePaths = null) { var scene = outputScene.UseScene("default"); foreach (var meshBuilder in meshBuilders) @@ -54,7 +73,7 @@ void BuildGltfScene(List> meshBuilders, RmvToGltfE scene.CreateNode(mesh.Name).WithMesh(mesh); } - _gltfSaver.Save(outputScene, settings.OutputPath); + _gltfSaver.Save(outputScene, settings.OutputPath, generatedTexturePaths ?? Array.Empty()); } void LogSettings(RmvToGltfExporterSettings settings) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml index 54821b2a2..9ccbe96d4 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExportWindow.xaml @@ -7,7 +7,7 @@ xmlns:loc="clr-namespace:Shared.Ui.Common;assembly=Shared.Ui" mc:Ignorable="d" Style="{StaticResource CustomWindowStyle}" - Title="{loc:Loc ExportWindow.Title}" Height="350" Width="600"> + Title="{loc:Loc ExportWindow.Title}" Height="550" MinHeight="450" Width="700" MinWidth="600"> diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs index 70b2c9ed1..60269cc83 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/ExporterCoreViewModel.cs @@ -37,6 +37,9 @@ public ExporterCoreViewModel(IEnumerable exporterViewModels) public void Initialize(PackFile packFile) { _inputFile = packFile; + PossibleExporters.Clear(); + SelectedExporter = null; + foreach (var viewModel in _exporterViewModels) { var supported = viewModel.CanExportFile(packFile); @@ -44,6 +47,7 @@ public void Initialize(PackFile packFile) continue; PossibleExporters.Add(viewModel); + viewModel.Initialize(packFile); if(supported == ExportSupportEnum.HighPriority) SelectedExporter = viewModel; } @@ -61,7 +65,7 @@ public void BrowsePathCommand() { FileName = Path.GetFileNameWithoutExtension(_inputFile!.Name), DefaultExt = SelectedExporter!.OutputExtension, - Filter = $"File ({SelectedExporter!.OutputExtension})|*{SelectedExporter!.OutputExtension}" + Filter = SelectedExporter!.OutputFilter }; if (dlg.ShowDialog() == true) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs index 0c8968866..c77b3eeb9 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/IExporterViewModel.cs @@ -8,6 +8,14 @@ public interface IExporterViewModel { public string DisplayName { get; } string OutputExtension { get; } + string OutputFilter => $"File ({OutputExtension})|*{OutputExtension}"; + + /// + /// Gives an exporter view model the source file before its view is + /// displayed or Execute is called. Existing exporters do not need + /// source-specific setup, so the default implementation is a no-op. + /// + void Initialize(PackFile exportSource) { } public void Execute(PackFile exportSource, string outputPath, bool generateImporter); public ExportSupportEnum CanExportFile(PackFile file); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml index cf87fbe52..9e5e97127 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml @@ -20,6 +20,10 @@ + + + + @@ -43,6 +47,31 @@ + + + + + + + + +