From 42c66e2f8835a9538b03003ead8fb67084bea9c4 Mon Sep 17 00:00:00 2001 From: robert-d-schultz Date: Mon, 3 Aug 2026 19:00:01 -0400 Subject: [PATCH] BMD editor: editing, saving, and a real details UI The BMD editor was read-only: it parsed a .bmd and rendered a text dump of every field. There was no writer at all, so nothing could be saved. Format layer: - Add BmdWriter, mirroring BmdParser section-for-section over the whole file (not just the editable categories), with a self-check that re-parses its own output before returning. Legacy/undocumented branches that the parser itself discards throw NotSupportedException rather than silently corrupting data. - Capture data the parser previously read purely to advance the stream and then dropped, which a writer cannot reproduce otherwise: BmdFile.SectionVersions, PropInfo.PropIndex (prop string tables contain duplicate paths, so re-deriving an index can silently repoint a prop), and CultureMask.RawBytes (several bit positions map to no named field and would be zeroed on reconstruction). - Fix BmdParser never populating BmdFile.Props (the shared prop string table was read and thrown away). - Fix unreachable spotlight branch: `if (Version > 3) ... else if (Version > 4)` meant v>4 spotlights read a UInt32 PdlcMask instead of a UInt64, misaligning the stream by 4 bytes. Editor: - Implement ISaveableEditor with a Save button and dirty tracking. - Per-category transform editing, exposing only the degrees of freedom the format actually stores: full TRS for props/decals/VFX/composite scenes and polymesh v>3; position+rotation for spot lights; position only for point lights, light probes, terrain hole vertices, sounds, and polymesh v<=3 (bulk vertex offset). Transforms are edited as position / euler degrees / scale rather than raw matrices. - Add BmdGizmoComponent (ported from CscGizmoComponent) driving the same properties, with rotate/scale gated to categories that support them. - Replace the text-dump details panel with per-category templates of real controls, and make paths editable (prop model, VFX, sound event, polymesh material, composite scene file). - Group the component tree into collapsible per-category sections and split decals out from props. - Add an "Add" menu covering all ten categories, matching the CSC editor. - Add Terry project export (.terry/.layer) via pack file context menu. - Remove the unused BmdSceneView/Bmd3DSceneViewer/BmdSceneViewModel trio and the unreferenced BmdBmdReferenceKey class. Verified: all 44 local .bmd files (36 campaign prefabs, 8 terrain tile_maps) round-trip byte-for-byte identical; edits through the view models change only the intended bytes; the ten Add-menu defaults write and re-parse correctly. Co-Authored-By: Claude Opus 5 --- .../ExportBmdAsTerryProjectCommand.cs | 59 + .../BmdEditor/DependencyInjectionContainer.cs | 22 +- Editors/BmdEditor/Editors.BmdEditor.csproj | 1 + .../Exporting/BmdReferenceResolver.cs | 18 + .../Exporting/BmdTerryProjectWriter.cs | 472 ++++++++ .../BmdEditor/Exporting/TerryCultureMask.cs | 110 ++ Editors/BmdEditor/Exporting/TerryId.cs | 21 + Editors/BmdEditor/Exporting/TerryTransform.cs | 153 +++ .../BmdEditor/Services/BmdElementFactory.cs | 137 +++ .../BmdEditor/Services/BmdGizmoComponent.cs | 176 +++ Editors/BmdEditor/Services/BmdSceneCreator.cs | 101 +- .../ViewModels/BmdEditorViewModel.cs | 833 +++++++++++--- .../BmdEditor/ViewModels/BmdSceneViewModel.cs | 150 --- Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml | 24 - .../BmdEditor/Views/Bmd3DSceneViewer.xaml.cs | 15 - Editors/BmdEditor/Views/BmdEditorView.xaml | 432 ++++++- Editors/BmdEditor/Views/BmdEditorView.xaml.cs | 7 + Editors/BmdEditor/Views/BmdSceneView.xaml | 47 - Editors/BmdEditor/Views/BmdSceneView.xaml.cs | 15 - Shared/GameFiles/Bmd/BmdFile.cs | 31 + Shared/GameFiles/Bmd/BmdParser.cs | 107 +- Shared/GameFiles/Bmd/BmdWriter.cs | 1015 +++++++++++++++++ 22 files changed, 3465 insertions(+), 481 deletions(-) create mode 100644 Editors/BmdEditor/ContextMenu/ExportBmdAsTerryProjectCommand.cs create mode 100644 Editors/BmdEditor/Exporting/BmdReferenceResolver.cs create mode 100644 Editors/BmdEditor/Exporting/BmdTerryProjectWriter.cs create mode 100644 Editors/BmdEditor/Exporting/TerryCultureMask.cs create mode 100644 Editors/BmdEditor/Exporting/TerryId.cs create mode 100644 Editors/BmdEditor/Exporting/TerryTransform.cs create mode 100644 Editors/BmdEditor/Services/BmdElementFactory.cs create mode 100644 Editors/BmdEditor/Services/BmdGizmoComponent.cs delete mode 100644 Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs delete mode 100644 Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml delete mode 100644 Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml.cs delete mode 100644 Editors/BmdEditor/Views/BmdSceneView.xaml delete mode 100644 Editors/BmdEditor/Views/BmdSceneView.xaml.cs create mode 100644 Shared/GameFiles/Bmd/BmdWriter.cs diff --git a/Editors/BmdEditor/ContextMenu/ExportBmdAsTerryProjectCommand.cs b/Editors/BmdEditor/ContextMenu/ExportBmdAsTerryProjectCommand.cs new file mode 100644 index 000000000..5bad50c5a --- /dev/null +++ b/Editors/BmdEditor/ContextMenu/ExportBmdAsTerryProjectCommand.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Text; +using Editors.BmdEditor.Exporting; +using Shared.Core.Misc; +using Shared.Core.PackFiles; +using Shared.Core.Services; +using Shared.GameFormats.Bmd; +using Shared.Ui.BaseDialogs.PackFileTree; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.Commands; +using Shared.Ui.BaseDialogs.PackFileTree.Utility; + +namespace Editors.BmdEditor.ContextMenu +{ + public class ExportBmdAsTerryProjectCommand(IPackFileService packFileService, IStandardDialogs standardDialogs, IFileSystemAccess fileSystemAccess) : IContextMenuCommand + { + private readonly IPackFileService _packFileService = packFileService; + private readonly IStandardDialogs _standardDialogs = standardDialogs; + private readonly IFileSystemAccess _fileSystemAccess = fileSystemAccess; + + public string GetDisplayName(TreeNode node) => "Export as Terry project"; + public bool ShouldAdd(TreeNode node) => node.NodeType == NodeType.File && TreeNodeHelper.GetPackFile(node) != null; + public bool IsEnabled(TreeNode node) + { + var packFile = TreeNodeHelper.GetPackFile(node); + return packFile != null && packFile.Name.EndsWith(".bmd", StringComparison.OrdinalIgnoreCase); + } + + private TreeNode _node = null!; + + public void Configure(TreeNode node) + { + _node = node; + } + + public void Execute() + { + var packFile = TreeNodeHelper.GetPackFile(_node); + if (packFile == null) + return; + + var dialogResult = _standardDialogs.ShowSystemFolderBrowserDialog(); + if (!dialogResult.Result || string.IsNullOrWhiteSpace(dialogResult.FolderPath)) + return; + + DirectoryHelper.EnsureCreated(dialogResult.FolderPath); + + var bmdFile = BmdParser.Parse(packFile.DataSource.ReadData()); + var project = BmdTerryProjectWriter.Build(bmdFile, BmdReferenceResolver.Create(_packFileService)); + + var baseName = Path.GetFileNameWithoutExtension(packFile.Name); + var terryPath = Path.Combine(dialogResult.FolderPath, baseName + ".terry"); + var layerPath = Path.Combine(dialogResult.FolderPath, $"{baseName}.{project.LayerEntityId}.layer"); + + _fileSystemAccess.FileWriteAllBytes(terryPath, Encoding.UTF8.GetBytes(project.TerryXml)); + _fileSystemAccess.FileWriteAllBytes(layerPath, Encoding.UTF8.GetBytes(project.LayerXml)); + } + } +} diff --git a/Editors/BmdEditor/DependencyInjectionContainer.cs b/Editors/BmdEditor/DependencyInjectionContainer.cs index 38bc98850..e06245df1 100644 --- a/Editors/BmdEditor/DependencyInjectionContainer.cs +++ b/Editors/BmdEditor/DependencyInjectionContainer.cs @@ -1,4 +1,5 @@ -using Editors.BmdEditor.ViewModels; +using Editors.BmdEditor.ContextMenu; +using Editors.BmdEditor.ViewModels; using Editors.BmdEditor.Views; using Editors.BmdEditor.Services; using Microsoft.Extensions.DependencyInjection; @@ -14,6 +15,7 @@ using GameWorld.Core.Rendering.Geometry; using GameWorld.Core.WpfWindow; using GameWorld.Core.Components.Selection; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu; namespace Editors.BmdEditor { @@ -23,12 +25,9 @@ public override void Register(IServiceCollection serviceCollection) { // Views serviceCollection.AddTransient(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); // ViewModels serviceCollection.AddScoped(); - serviceCollection.AddScoped(); serviceCollection.AddScoped(); // Services @@ -36,6 +35,13 @@ public override void Register(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); + // Game components (picked up by IComponentInserter) + RegisterGameComponent(serviceCollection); + + // Context menu + serviceCollection.AddScoped(); + serviceCollection.AddSingleton(); + RegisterAllAsInterface(serviceCollection, ServiceLifetime.Transient); } @@ -48,4 +54,12 @@ public override void RegisterTools(IEditorDatabase editorDatabase) .Build(editorDatabase); } } + + public class BmdPackFileContextMenuRegistration : IPackFileContextMenuRegistration + { + public void Register(PackFileContextMenuRegistry registry) + { + registry.RegisterPackFileContextMenuItem(ContextMenuType.MainApplication, path: "Export", priority: 40, ContextMenuCluster.Export); + } + } } diff --git a/Editors/BmdEditor/Editors.BmdEditor.csproj b/Editors/BmdEditor/Editors.BmdEditor.csproj index b9de587db..2bce42eb3 100644 --- a/Editors/BmdEditor/Editors.BmdEditor.csproj +++ b/Editors/BmdEditor/Editors.BmdEditor.csproj @@ -10,6 +10,7 @@ + diff --git a/Editors/BmdEditor/Exporting/BmdReferenceResolver.cs b/Editors/BmdEditor/Exporting/BmdReferenceResolver.cs new file mode 100644 index 000000000..ddd707358 --- /dev/null +++ b/Editors/BmdEditor/Exporting/BmdReferenceResolver.cs @@ -0,0 +1,18 @@ +using Shared.Core.PackFiles; +using Shared.GameFormats.Bmd; + +namespace Editors.BmdEditor.Exporting +{ + /// Resolves a BmdInfo reference's path to its parsed via the pack + /// file system, for recursively flattening nested BMDs during Terry export. + public static class BmdReferenceResolver + { + public static Func Create(IPackFileService packFileService) => path => + { + var packFile = packFileService.FindFile(path); + if (packFile == null) + return null; + return BmdParser.Parse(packFile.DataSource.ReadData()); + }; + } +} diff --git a/Editors/BmdEditor/Exporting/BmdTerryProjectWriter.cs b/Editors/BmdEditor/Exporting/BmdTerryProjectWriter.cs new file mode 100644 index 000000000..6b018b4f8 --- /dev/null +++ b/Editors/BmdEditor/Exporting/BmdTerryProjectWriter.cs @@ -0,0 +1,472 @@ +using System.Globalization; +using System.IO; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Xna.Framework; +using Shared.GameFormats.Bmd; + +namespace Editors.BmdEditor.Exporting +{ + /// + /// Builds a Terry project (.terry) and its accompanying layer (.layer) from a parsed + /// , in the XML shape read by the Total War editor "Terry". BmdInfo + /// references are recursively flattened into the same layer (their own Transform is not + /// composed into descendants - only their culture mask is inherited - matching the reference + /// Python export scripts this was ported from), so the result contains everything reachable + /// from the root .bmd. + /// + public static class BmdTerryProjectWriter + { + public const string ProjectVersion = "27"; + public const string LayerVersion = "41"; + + public readonly record struct TerryProject(string TerryXml, string LayerXml, string LayerEntityId); + + // Fixed display order for the logical layers grouping the flattened entities by component + // type (matches the real editor's own "group by kind" habit, e.g. pyre_rock.*.layer, though + // that sample groups by spatial batch rather than type - this groups by type instead since + // that's what was asked for). + private static readonly (string Key, string DisplayName)[] LayerCategories = + { + ("props", "Props"), + ("decals", "Decals"), + ("vfx", "VFX"), + ("light_probes", "Light Probes"), + ("terrain_holes", "Terrain Holes"), + ("point_lights", "Point Lights"), + ("poly_meshes", "Polygon Meshes"), + ("spot_lights", "Spot Lights"), + ("sounds", "Sounds"), + ("composite_scenes", "Composite Scenes"), + }; + + public static TerryProject Build(BmdFile rootBmd, Func resolveReferencedBmd) + { + var entitiesByCategory = new Dictionary>(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectEntities(rootBmd, "", entitiesByCategory, resolveReferencedBmd, visited); + + var entitiesRoot = new XElement("entities"); + var logical = new XElement("Logical"); + foreach (var (key, displayName) in LayerCategories) + { + if (!entitiesByCategory.TryGetValue(key, out var members) || members.Count == 0) + continue; + + var layerId = TerryId.NewId(); + entitiesRoot.Add(NewLayerEntity(layerId, displayName)); + entitiesRoot.Add(members); + + var from = new XElement("from", new XAttribute("id", layerId)); + foreach (var member in members) + from.Add(new XElement("to", new XAttribute("id", (string)member.Attribute("id")!))); + logical.Add(from); + } + + var layerEntityId = TerryId.NewId(); + var projectId = TerryId.NewId(); + + var layerDoc = new XDocument( + new XElement("layer", + new XAttribute("version", LayerVersion), + entitiesRoot, + new XElement("associations", + logical, + new XElement("Transform")))); + + var terryDoc = new XDocument( + new XElement("project", + new XAttribute("version", ProjectVersion), + new XAttribute("id", projectId), + new XElement("pc", + new XAttribute("type", "QTU::ProjectPrefab"), + new XElement("data", new XAttribute("database", "campaign"), new XAttribute("is_skybox", "0"))), + new XElement("pc", + new XAttribute("type", "QTU::Scene"), + new XElement("data", + new XAttribute("version", LayerVersion), + new XElement("entity", + new XAttribute("id", layerEntityId), + new XAttribute("name", "Default"), + new XElement("ECFileLayer", new XAttribute("export", "true"), new XAttribute("bmd_export_type", ""))))), + new XElement("pc", new XAttribute("type", "QTU::Terrain")))); + + return new TerryProject(ToXmlString(terryDoc), ToXmlString(layerDoc), layerEntityId); + } + + private static void CollectEntities(BmdFile bmd, string inheritedCultureMask, Dictionary> entitiesByCategory, Func resolveReferencedBmd, HashSet visited) + { + foreach (var prop in bmd.PropInfos) + Add(entitiesByCategory, prop.IsDecal ? "decals" : "props", BuildPropEntity(prop, inheritedCultureMask)); + + foreach (var vfx in bmd.VfxInfos) + Add(entitiesByCategory, "vfx", BuildVfxEntity(vfx, inheritedCultureMask)); + + foreach (var probe in bmd.LightProbes) + Add(entitiesByCategory, "light_probes", BuildLightProbeEntity(probe)); + + foreach (var hole in bmd.TerrainHoles) + Add(entitiesByCategory, "terrain_holes", BuildTerrainHoleEntity(hole, inheritedCultureMask)); + + foreach (var light in bmd.PointLights) + Add(entitiesByCategory, "point_lights", BuildPointLightEntity(light, inheritedCultureMask)); + + foreach (var mesh in bmd.PolyMeshes) + Add(entitiesByCategory, "poly_meshes", BuildPolyMeshEntity(mesh, inheritedCultureMask)); + + foreach (var light in bmd.SpotLights) + Add(entitiesByCategory, "spot_lights", BuildSpotLightEntity(light, inheritedCultureMask)); + + foreach (var sound in bmd.Sounds) + Add(entitiesByCategory, "sounds", BuildSoundEntity(sound)); + + foreach (var csc in bmd.CscInfos) + Add(entitiesByCategory, "composite_scenes", BuildCscEntity(csc, inheritedCultureMask)); + + foreach (var childRef in bmd.BmdInfos) + { + if (string.IsNullOrEmpty(childRef.BmdString) || !visited.Add(childRef.BmdString)) + continue; + + var childBmd = resolveReferencedBmd(childRef.BmdString); + if (childBmd == null) + continue; + + CollectEntities(childBmd, TerryCultureMask.Format(childRef.CultureMask), entitiesByCategory, resolveReferencedBmd, visited); + } + } + + private static void Add(Dictionary> entitiesByCategory, string category, XElement entity) + { + if (!entitiesByCategory.TryGetValue(category, out var list)) + { + list = new List(); + entitiesByCategory[category] = list; + } + list.Add(entity); + } + + private static XElement NewLayerEntity(string id, string name) => + new XElement("entity", + new XAttribute("id", id), + new XAttribute("name", name), + new XElement("ECLayer", new XAttribute("export", "true"), new XAttribute("bmd_export_type", "")), + new XElement("ECToggleableBuildingsSlot", + new XAttribute("enabled", "false"), + new XAttribute("type", "TBST_TOWERS"), + new XAttribute("capture_location", ""), + new XAttribute("script_id", ""), + new XAttribute("map_barrier_record_key", ""), + new XAttribute("ground_melee_attack_allowed", "true"))); + + private static XElement BuildPropEntity(PropInfo prop, string inheritedCultureMask) + { + var t = TerryTransform.Decompose(prop.Transform); + var cultureMask = TerryCultureMask.PreferOwn(TerryCultureMask.Format(prop.CultureMask), inheritedCultureMask); + // Version >19 only: the stored bit is "visible without shroud", so shroud-only visibility is its inverse. + var visibleInShroudOnly = prop.PropInfoVersion > 19 && !prop.VisibleWithoutShroud; + + var entity = NewEntity(); + if (prop.IsDecal) + { + entity.Add(new XElement("ECDecal", + new XAttribute("model_path", prop.Rmv2Path), + new XAttribute("parallax_scale", "0"), + new XAttribute("tiling", "0"), + new XAttribute("normal_mode", "DNM_BLEND"), + new XAttribute("apply_to_terrain", Bool(prop.ApplyToTerrain)), + new XAttribute("apply_to_objects", Bool(prop.ApplyToPropsOrReceiveDecal)), + new XAttribute("render_above_snow", Bool(prop.RenderAboveSnow)))); + } + else + { + entity.Add(new XElement("ECPropMesh")); + entity.Add(new XElement("ECMesh", new XAttribute("model_path", prop.Rmv2Path), new XAttribute("opacity", "1"))); + entity.Add(new XElement("ECMeshRenderSettings", new XAttribute("receive_decals", Bool(prop.ApplyToPropsOrReceiveDecal)))); + } + + entity.Add(new XElement("ECVisibilitySettingsCampaign", + new XAttribute("visible_in_tactical_view", Bool(prop.Flags.VisibleInTactical)), + new XAttribute("visible_in_tactical_view_only", Bool(prop.Flags.OnlyVisibleInTactical)))); + entity.Add(new XElement("ECPropHeightPatch", + new XAttribute("apply_height_patch", Bool(prop.ApplyHeightPatch)), + new XAttribute("for_camera_height_map_only", "false"))); + entity.Add(new XElement("ECCampaignProperties", + new XAttribute("visible_inside_snow_region", Bool(prop.VisibleInsideSnowRegion)), + new XAttribute("visible_outside_snow_region", Bool(prop.VisibleOutsideSnowRegion)), + new XAttribute("visible_inside_destruction_region", Bool(prop.VisibleInsideDestructionRegion)), + new XAttribute("visible_outside_destruction_region", Bool(prop.VisibleOutsideDestructionRegion)), + new XAttribute("visible_in_shroud", Bool(prop.VisibleInShroud)), + new XAttribute("visible_in_shroud_only", Bool(visibleInShroudOnly)), + new XAttribute("no_culling", Bool(prop.NoCulling)), + new XAttribute("culture_mask", cultureMask))); + entity.Add(BuildTransformElement(t)); + return entity; + } + + private static XElement BuildVfxEntity(VfxInfo vfx, string inheritedCultureMask) + { + var t = TerryTransform.Decompose(vfx.Transform); + var cultureMask = TerryCultureMask.PreferInherited(TerryCultureMask.Format(vfx.CultureMask), inheritedCultureMask); + + return NewEntity( + new XElement("ECVisibilitySettingsCampaign", + new XAttribute("visible_in_tactical_view", Bool(vfx.Flags.VisibleInTactical)), + new XAttribute("visible_in_tactical_view_only", Bool(vfx.Flags.OnlyVisibleInTactical))), + // autoplay/scale are hardcoded here to match the reference export scripts, not vfx.Autoplay/the transform scale. + new XElement("ECVFX", + new XAttribute("vfx", vfx.VfxString), + new XAttribute("autoplay", "true"), + new XAttribute("scale", "1"), + new XAttribute("instance_name", vfx.InstanceName)), + new XElement("ECCampaignProperties", + new XAttribute("visible_in_shroud", Bool(vfx.VisibleInShroud)), + new XAttribute("visible_in_shroud_only", Bool(vfx.VisibleInShroudOnly)), + new XAttribute("culture_mask", cultureMask)), + BuildTransformElement(t)); + } + + private static XElement BuildLightProbeEntity(LightProbeInfo probe) + { + var innerRadius = probe.Version > 2 ? probe.InnerRadius : probe.OuterRadius; + + return NewEntity( + new XElement("ECLightProbe", new XAttribute("primary", Bool(probe.Primary))), + new XElement("ECTransform", + new XAttribute("position", FormatVec(probe.Position.ToVector3())), + new XAttribute("rotation", "0 0 0"), + new XAttribute("scale", "1 1 1"), + new XAttribute("pivot", "0 0 0")), + new XElement("ECDoubleSphere", + new XAttribute("inner_radius", Fmt(innerRadius)), + new XAttribute("outer_radius", Fmt(probe.OuterRadius)))); + } + + private static XElement BuildTerrainHoleEntity(TerrainHoleTriangleInfo hole, string inheritedCultureMask) + { + var (position, eulerDegrees, localV2, localV3) = TerryTransform.FlattenTriangle(hole.FirstVert.ToVector3(), hole.SecondVert.ToVector3(), hole.ThirdVert.ToVector3()); + + return NewEntity( + new XElement("ECTerrainHole"), + new XElement("ECTransform", + new XAttribute("position", FormatVec(position)), + new XAttribute("rotation", FormatVec(eulerDegrees)), + new XAttribute("scale", "1 1 1"), + new XAttribute("pivot", "0 0 0")), + new XElement("ECVisibilitySettingsCampaign", + new XAttribute("visible_in_tactical_view", "false"), + new XAttribute("visible_in_tactical_view_only", "false")), + new XElement("ECCampaignProperties", + new XAttribute("visible_in_shroud", "true"), + new XAttribute("no_culling", "true"), + new XAttribute("culture_mask", inheritedCultureMask)), + new XElement("ECPolyline", + new XElement("polyline", new XAttribute("closed", "true"), + new XElement("point", new XAttribute("x", Fmt(0)), new XAttribute("y", Fmt(0))), + new XElement("point", new XAttribute("x", Fmt(localV2.X)), new XAttribute("y", Fmt(localV2.Z))), + new XElement("point", new XAttribute("x", Fmt(localV3.X)), new XAttribute("y", Fmt(localV3.Z)))))); + } + + private static XElement BuildPointLightEntity(PointLightInfo light, string inheritedCultureMask) + { + var animType = light.AnimationTypeEnum switch + { + 1 => "LAT_RADIUS_SIN", + 2 => "LAT_RADIUS_SIN_SIN", + _ => "LAT_NONE", + }; + + return NewEntity( + new XElement("ECPointLight", + new XAttribute("colour", $"{(int)(light.Red * 255)} {(int)(light.Green * 255)} {(int)(light.Blue * 255)} 255"), + new XAttribute("colour_scale", Fmt(light.ColorScale)), + new XAttribute("radius", Fmt(light.Radius)), + new XAttribute("animation_type", animType), + new XAttribute("animation_speed_scale", $"{Fmt(light.AnimationSpeedScale1)} {Fmt(light.AnimationSpeedScale2)}"), + new XAttribute("colour_min", Fmt(light.ColorMin)), + new XAttribute("random_offset", Fmt(light.RandomOffset)), + new XAttribute("falloff_type", light.FalloffType), + new XAttribute("for_light_probes_only", Bool(light.LightProbeOnly))), + new XElement("ECVisibilitySettingsCampaign", + new XAttribute("visible_in_tactical_view", "false"), + new XAttribute("visible_in_tactical_view_only", "false")), + new XElement("ECCampaignProperties", new XAttribute("culture_mask", inheritedCultureMask)), + new XElement("ECTransform", + new XAttribute("position", FormatVec(light.Position.ToVector3())), + new XAttribute("rotation", "0 0 0"), + new XAttribute("scale", "1 1 1"), + new XAttribute("pivot", "0 0 0"))); + } + + private static XElement BuildPolyMeshEntity(PolyMeshInfo mesh, string inheritedCultureMask) + { + TerryTransform.Decomposed t; + bool visibleInShroud; + if (mesh.PolyMeshVersion > 3) + { + t = TerryTransform.Decompose(mesh.Transform); + visibleInShroud = mesh.VisibleInShroud; + } + else + { + var y = mesh.VertexList.Length > 0 ? mesh.VertexList[0].Y : 0f; + t = new TerryTransform.Decomposed(new Vector3(0, y, 0), Vector3.Zero, Vector3.One); + visibleInShroud = false; + } + + var polyline = new XElement("polyline", new XAttribute("closed", "true")); + foreach (var v in mesh.VertexList) + polyline.Add(new XElement("point", new XAttribute("x", Fmt(v.X)), new XAttribute("y", Fmt(v.Z)))); + + return NewEntity( + new XElement("ECPolygonMesh", + new XAttribute("material", mesh.MaterialString), + new XAttribute("affects_mesh_optimization", "false")), + new XElement("ECVisibilitySettingsCampaign", + new XAttribute("visible_in_tactical_view", Bool(mesh.Flags.VisibleInTactical)), + new XAttribute("visible_in_tactical_view_only", Bool(mesh.Flags.OnlyVisibleInTactical))), + new XElement("ECCampaignProperties", + new XAttribute("visible_in_shroud", Bool(visibleInShroud)), + new XAttribute("no_culling", "true"), + new XAttribute("culture_mask", inheritedCultureMask)), + BuildTransformElement(t), + new XElement("ECPolyline", polyline)); + } + + private static XElement BuildSpotLightEntity(SpotLightInfo light, string inheritedCultureMask) + { + var eulerDegrees = TerryTransform.QuaternionToEulerDegrees(light.QuartX, light.QuartY, light.QuartZ, light.QuartW); + + var maxIntensity = MathF.Max(light.IntensityRed, MathF.Max(light.IntensityGreen, light.IntensityBlue)); + int r = 0, g = 0, b = 0; + if (maxIntensity > 0f) + { + r = (int)(light.IntensityRed / maxIntensity * 255); + g = (int)(light.IntensityGreen / maxIntensity * 255); + b = (int)(light.IntensityBlue / maxIntensity * 255); + } + + return NewEntity( + new XElement("ECSpotLight", + new XAttribute("colour", $"{r} {g} {b} 255"), + new XAttribute("intensity", Fmt(maxIntensity)), + new XAttribute("length", Fmt(light.Length)), + new XAttribute("inner_angle", Fmt(MathHelper.ToDegrees(light.InnerAngleRadians))), + new XAttribute("outer_angle", Fmt(MathHelper.ToDegrees(light.OuterAngleRadians))), + new XAttribute("falloff", Fmt(light.Falloff)), + new XAttribute("volumetric", Bool(light.Volumetric)), + new XAttribute("gobo", light.Gobo)), + new XElement("ECVisibilitySettingsCampaign", + new XAttribute("visible_in_tactical_view", "false"), + new XAttribute("visible_in_tactical_view_only", "false")), + new XElement("ECCampaignProperties", new XAttribute("culture_mask", inheritedCultureMask)), + new XElement("ECTransform", + new XAttribute("position", FormatVec(light.Position.ToVector3())), + new XAttribute("rotation", FormatVec(eulerDegrees)), + new XAttribute("scale", "1 1 1"), + new XAttribute("pivot", "0 0 0"))); + } + + private static XElement BuildSoundEntity(SoundInfo sound) + { + var origin = sound.CoordList.Length > 0 ? sound.CoordList[0].ToVector3() : Vector3.Zero; + var cultureMask = TerryCultureMask.Format(sound.CultureMask); + + var entity = NewEntity( + new XElement("ECSoundMarker", new XAttribute("key", sound.SoundString)), + new XElement("ECTransform", + new XAttribute("position", FormatVec(origin)), + new XAttribute("rotation", "0 0 0"), + new XAttribute("scale", "1 1 1"), + new XAttribute("pivot", "0 0 0")), + new XElement("ECCampaignProperties", new XAttribute("culture_mask", cultureMask))); + + switch (sound.TypeString) + { + case "SST_LINE_LIST": + { + var polyline3d = new XElement("polyline3d", new XAttribute("closed", "false")); + foreach (var c in sound.CoordList) + { + var v = c.ToVector3() - origin; + polyline3d.Add(new XElement("point", new XAttribute("x", Fmt(v.X)), new XAttribute("y", Fmt(v.Y)), new XAttribute("z", Fmt(v.Z)))); + } + entity.Add(new XElement("ECPolyline3D", polyline3d)); + break; + } + case "SST_MULTI_POINT": + { + var pointCloud = new XElement("point_cloud"); + foreach (var c in sound.CoordList) + { + var v = c.ToVector3() - origin; + pointCloud.Add(new XElement("point", new XAttribute("x", Fmt(v.X)), new XAttribute("y", Fmt(v.Y)), new XAttribute("z", Fmt(v.Z)))); + } + entity.Add(new XElement("ECPointCloud", pointCloud)); + break; + } + case "SST_SPHERE": + entity.Add(new XElement("ECSphere", new XAttribute("radius", Fmt(sound.OuterRadius)))); + break; + } + + return entity; + } + + private static XElement BuildCscEntity(CscInfo csc, string inheritedCultureMask) + { + var t = TerryTransform.Decompose(csc.Transform); + // Version >9 only: same "visible without shroud" bit-inversion as PropInfo above. + var visibleInShroudOnly = csc.Version > 9 && !csc.VisibleWithoutShroud; + + return NewEntity( + new XElement("ECCompositeScene", + new XAttribute("path", csc.SceneFile), + new XAttribute("script_id", ""), + new XAttribute("autoplay", "true")), + new XElement("ECVisibilitySettingsCampaign", + new XAttribute("visible_in_tactical_view", "false"), + new XAttribute("visible_in_tactical_view_only", "false")), + BuildTransformElement(t), + new XElement("ECCampaignProperties", + new XAttribute("visible_in_shroud", Bool(csc.VisibleInShroud)), + new XAttribute("visible_in_shroud_only", Bool(visibleInShroudOnly)), + new XAttribute("no_culling", Bool(csc.NoCulling)), + new XAttribute("culture_mask", inheritedCultureMask))); + } + + private static XElement NewEntity(params object[] components) => + new XElement("entity", new XAttribute("id", TerryId.NewId()), components); + + private static XElement BuildTransformElement(TerryTransform.Decomposed t) => + new XElement("ECTransform", + new XAttribute("position", FormatVec(t.Position)), + new XAttribute("rotation", FormatVec(t.EulerDegrees)), + new XAttribute("scale", FormatVec(t.Scale)), + new XAttribute("pivot", "0 0 0")); + + private static string Bool(bool b) => b ? "true" : "false"; + private static string Fmt(float f) => f.ToString(CultureInfo.InvariantCulture); + private static string FormatVec(Vector3 v) => $"{Fmt(v.X)} {Fmt(v.Y)} {Fmt(v.Z)}"; + + private static string ToXmlString(XDocument doc) + { + var settings = new XmlWriterSettings + { + Indent = true, + IndentChars = " ", + Encoding = new UTF8Encoding(false), + }; + using var stringWriter = new Utf8StringWriter(); + using (var writer = XmlWriter.Create(stringWriter, settings)) + doc.Save(writer); + return stringWriter.ToString(); + } + + private sealed class Utf8StringWriter : StringWriter + { + public override Encoding Encoding => Encoding.UTF8; + } + } +} diff --git a/Editors/BmdEditor/Exporting/TerryCultureMask.cs b/Editors/BmdEditor/Exporting/TerryCultureMask.cs new file mode 100644 index 000000000..274d76244 --- /dev/null +++ b/Editors/BmdEditor/Exporting/TerryCultureMask.cs @@ -0,0 +1,110 @@ +using Shared.GameFormats.Bmd; + +namespace Editors.BmdEditor.Exporting +{ + /// + /// Formats BMD culture masks into Terry's comma-separated culture_mask attribute text + /// (e.g. "wh3_main_cth_cathay,wh3_main_ksl_kislev"), and resolves how an entity's own mask + /// combines with the mask inherited from an enclosing BmdInfo reference. The per-type + /// combination rules (prefer own vs. prefer inherited vs. own-only) mirror what the reference + /// Terry-export scripts do for each BMD component type. + /// + public static class TerryCultureMask + { + // Ideally these bit->culture names would be looked up from the game DB (e.g. a + // "prefab_types_tables"-style table) instead of hardcoded here. AssetEditor has no live + // DB-table-reading path to do that with, though: Shared/GameFiles/DB (SchemaManager, + // SimpleSchema) is schema-driven table-decoding scaffolding, but every class in it is + // commented out/dead, so there's nothing to hook into short of building that + // infrastructure from scratch. Hardcoded here instead, matching the reference scripts. + private static readonly Dictionary BitNames = new() + { + [6] = "wh_dlc03_bst_beastmen", + [7] = "wh_main_brt_bretonnia", + [8] = "wh_main_chs_chaos", + [9] = "wh_main_dwf_dwarfs", + [10] = "wh_main_emp_empire", + [11] = "wh_main_grn_greenskins", + [12] = "wh_main_vmp_vampire_counts", + [13] = "wh_dlc05_wef_wood_elves", + [17] = "wh2_main_def_dark_elves", + [18] = "wh2_main_hef_high_elves", + [19] = "wh2_main_lzd_lizardmen", + [20] = "wh2_main_skv_skaven", + [21] = "wh2_dlc09_tmb_tomb_kings", + [22] = "wh2_main_rogue", + [23] = "wh3_main_ksl_kislev", + [24] = "wh3_main_ogr_ogre_kingdoms", + [25] = "wh2_dlc11_cst_vampire_coast", + [27] = "wh3_main_kho_khorne", + [28] = "wh3_main_tze_tzeentch", + [29] = "wh3_main_nur_nurgle", + [30] = "wh3_main_sla_slaanesh", + [31] = "wh3_main_dae_daemons", + [32] = "wh3_main_cth_cathay", + [33] = "wh_dlc08_nor_norsca", + [34] = "wh3_dlc23_chd_chaos_dwarfs", + [63] = "*", + }; + + public static string Format(ulong bits) + { + if (bits == 0) + return ""; + + var names = new List(); + for (var i = 0; i < 64; i++) + { + if ((bits & (1UL << i)) != 0 && BitNames.TryGetValue(i, out var name)) + names.Add(name); + } + return string.Join(",", names); + } + + public static string Format(byte[]? raw8) + { + if (raw8 == null || raw8.Length < 8) + return ""; + return Format(BitConverter.ToUInt64(raw8, 0)); + } + + public static string Format(CultureMask mask) + { + var bits = 0UL; + if (mask.CultMaskBst) bits |= 1UL << 6; + if (mask.CultMaskBrt) bits |= 1UL << 7; + if (mask.CultMaskChs) bits |= 1UL << 8; + if (mask.CultMaskDwf) bits |= 1UL << 9; + if (mask.CultMaskEmp) bits |= 1UL << 10; + if (mask.CultMaskGrn) bits |= 1UL << 11; + if (mask.CultMaskVmp) bits |= 1UL << 12; + if (mask.CultMaskWef) bits |= 1UL << 13; + if (mask.CultMaskDef) bits |= 1UL << 17; + if (mask.CultMaskHef) bits |= 1UL << 18; + if (mask.CultMaskLzd) bits |= 1UL << 19; + if (mask.CultMaskSkv) bits |= 1UL << 20; + if (mask.CultMaskTmb) bits |= 1UL << 21; + if (mask.CultMaskRogue) bits |= 1UL << 22; + if (mask.CultMaskKsl) bits |= 1UL << 23; + if (mask.CultMaskOgr) bits |= 1UL << 24; + if (mask.CultMaskCst) bits |= 1UL << 25; + if (mask.CultMaskKho) bits |= 1UL << 27; + if (mask.CultMaskTze) bits |= 1UL << 28; + if (mask.CultMaskNur) bits |= 1UL << 29; + if (mask.CultMaskSla) bits |= 1UL << 30; + if (mask.CultMaskDae) bits |= 1UL << 31; + if (mask.CultMaskCth) bits |= 1UL << 32; + if (mask.CultMaskNor) bits |= 1UL << 33; + if (mask.CultMaskChd) bits |= 1UL << 34; + return Format(bits); + } + + /// Props: keep the prop's own mask; only fall back to the inherited (BmdInfo) + /// mask when the prop itself carries no restriction. + public static string PreferOwn(string own, string inherited) => own.Length > 0 ? own : inherited; + + /// VFX: the inherited (BmdInfo) mask wins whenever it's non-empty, regardless of + /// the VFX's own mask. + public static string PreferInherited(string own, string inherited) => inherited.Length > 0 ? inherited : own; + } +} diff --git a/Editors/BmdEditor/Exporting/TerryId.cs b/Editors/BmdEditor/Exporting/TerryId.cs new file mode 100644 index 000000000..8c0aa0f04 --- /dev/null +++ b/Editors/BmdEditor/Exporting/TerryId.cs @@ -0,0 +1,21 @@ +namespace Editors.BmdEditor.Exporting +{ + /// + /// Generates Terry-style entity/project ids: lowercase hex, no leading zeros (real ids read like + /// a plain hex integer - e.g. timestamp/counter derived - never zero-padded), matching the shape + /// seen in real .terry/.layer files. Real Terry ids aren't random - they look derived from a + /// timestamp/counter (ids created in the same editing session share a long common prefix) - + /// but the exact proprietary scheme isn't known here, so this uses .NET's own canonical unique + /// id primitive (a GUID) rather than a hand-rolled random loop. Terry only requires ids to be + /// unique within a project, so this is sufficient even though it won't reproduce that shared-prefix + /// shape. + /// + public static class TerryId + { + public static string NewId() + { + var id = Guid.NewGuid().ToString("N")[..15].TrimStart('0'); + return id.Length > 0 ? id : "0"; + } + } +} diff --git a/Editors/BmdEditor/Exporting/TerryTransform.cs b/Editors/BmdEditor/Exporting/TerryTransform.cs new file mode 100644 index 000000000..5a4275ee9 --- /dev/null +++ b/Editors/BmdEditor/Exporting/TerryTransform.cs @@ -0,0 +1,153 @@ +using Microsoft.Xna.Framework; + +namespace Editors.BmdEditor.Exporting +{ + /// + /// Converts BMD's row-vector transforms (translation in the matrix's fourth row, same + /// convention as ) into the position/XYZ-euler-degrees/scale + /// triples Terry's ECTransform expects. + /// + public static class TerryTransform + { + public readonly record struct Decomposed(Vector3 Position, Vector3 EulerDegrees, Vector3 Scale); + + public static Decomposed Decompose(Matrix m) + { + var position = new Vector3(m.M41, m.M42, m.M43); + + var row1 = new Vector3(m.M11, m.M12, m.M13); + var row2 = new Vector3(m.M21, m.M22, m.M23); + var row3 = new Vector3(m.M31, m.M32, m.M33); + + var scale = new Vector3(row1.Length(), row2.Length(), row3.Length()); + + var a = scale.X > 1e-8f ? row1 / scale.X : row1; + var d = scale.Y > 1e-8f ? row2 / scale.Y : row2; + var g = scale.Z > 1e-8f ? row3 / scale.Z : row3; + + var yRad = MathF.Asin(Math.Clamp(g.X, -1f, 1f)); + var xRad = MathF.Atan2(-g.Y, g.Z); + var zRad = MathF.Atan2(-d.X, a.X); + + var eulerDegrees = new Vector3(MathHelper.ToDegrees(xRad), MathHelper.ToDegrees(yRad), MathHelper.ToDegrees(zRad)); + return new Decomposed(position, eulerDegrees, scale); + } + + /// + /// Inverse of : builds the row-vector transform matrix Terry's + /// position/XYZ-euler-degrees/scale triple represents. The rotation part is + /// M = Rz(z) * Ry(y) * Rx(x) (row-vector composition) - verified by substituting its rows + /// back into Decompose's own asin/atan2 formulas and recovering x, y, z exactly. + /// + public static Matrix Compose(Vector3 position, Vector3 eulerDegrees, Vector3 scale) + { + var x = MathHelper.ToRadians(eulerDegrees.X); + var y = MathHelper.ToRadians(eulerDegrees.Y); + var z = MathHelper.ToRadians(eulerDegrees.Z); + + var cx = MathF.Cos(x); var sx = MathF.Sin(x); + var cy = MathF.Cos(y); var sy = MathF.Sin(y); + var cz = MathF.Cos(z); var sz = MathF.Sin(z); + + var row1 = new Vector3(cy * cz, sz * cx + cz * sy * sx, sz * sx - cz * sy * cx) * scale.X; + var row2 = new Vector3(-sz * cy, cz * cx - sz * sy * sx, cz * sx + sz * sy * cx) * scale.Y; + var row3 = new Vector3(sy, -cy * sx, cy * cx) * scale.Z; + + return new Matrix( + row1.X, row1.Y, row1.Z, 0, + row2.X, row2.Y, row2.Z, 0, + row3.X, row3.Y, row3.Z, 0, + position.X, position.Y, position.Z, 1); + } + + /// Standard quaternion-to-XYZ-euler-degrees conversion, used for SpotLightInfo + /// which stores its orientation as a quaternion rather than a matrix. + public static Vector3 QuaternionToEulerDegrees(float qx, float qy, float qz, float qw) + { + var ySquared = qy * qy; + + var t0 = 2f * (qw * qx + qy * qz); + var t1 = 1f - 2f * (qx * qx + ySquared); + var xRad = MathF.Atan2(t0, t1); + + var t2 = Math.Clamp(2f * (qw * qy - qz * qx), -1f, 1f); + var yRad = MathF.Asin(t2); + + var t3 = 2f * (qw * qz + qx * qy); + var t4 = 1f - 2f * (ySquared + qz * qz); + var zRad = MathF.Atan2(t3, t4); + + return new Vector3(MathHelper.ToDegrees(xRad), MathHelper.ToDegrees(yRad), MathHelper.ToDegrees(zRad)); + } + + /// Inverse of . + public static Quaternion EulerDegreesToQuaternion(Vector3 eulerDegrees) + { + var x = MathHelper.ToRadians(eulerDegrees.X); + var y = MathHelper.ToRadians(eulerDegrees.Y); + var z = MathHelper.ToRadians(eulerDegrees.Z); + + var qx = Quaternion.CreateFromAxisAngle(Vector3.UnitX, x); + var qy = Quaternion.CreateFromAxisAngle(Vector3.UnitY, y); + var qz = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, z); + return qz * qy * qx; + } + + /// + /// First-order-accurate XYZ delta extraction from a small (single gizmo-drag-tick) + /// rotation matrix, safe to add directly onto any Euler-degree channel regardless of its + /// composition order - for infinitesimal rotations, all conventions agree to first order. + /// + public static Vector3 ExtractSmallRotationDeltaDegrees(Matrix delta) + { + var xRad = MathF.Atan2(delta.M23, delta.M33); + var yRad = MathF.Asin(Math.Clamp(-delta.M13, -1f, 1f)); + var zRad = MathF.Atan2(delta.M12, delta.M11); + return new Vector3(MathHelper.ToDegrees(xRad), MathHelper.ToDegrees(yRad), MathHelper.ToDegrees(zRad)); + } + + /// + /// Terry can only hole-punch flat (2D) polylines, but a terrain-hole triangle can sit at + /// any orientation. This rotates the triangle so its normal aligns with +Y (making it flat + /// in the local X-Z plane) and returns the placement rotation needed to put it back. + /// + public static (Vector3 Position, Vector3 EulerDegrees, Vector3 LocalVertex2, Vector3 LocalVertex3) FlattenTriangle(Vector3 v1, Vector3 v2, Vector3 v3) + { + var rel2 = v2 - v1; + var rel3 = v3 - v1; + + var normal = Vector3.Cross(rel2, rel3); + if (normal.LengthSquared() < 1e-12f) + return (v1, Vector3.Zero, new Vector3(rel2.X, 0, rel2.Z), new Vector3(rel3.X, 0, rel3.Z)); + normal.Normalize(); + + var axis = Vector3.Cross(normal, Vector3.Up); + if (axis.LengthSquared() < 1e-12f) + return (v1, Vector3.Zero, new Vector3(rel2.X, 0, rel2.Z), new Vector3(rel3.X, 0, rel3.Z)); + axis.Normalize(); + + var angle = MathF.Acos(Math.Clamp(normal.Y, -1f, 1f)); + + // Row-vector rotation matrix (v' = v * m) for `angle` around `axis`, built directly + // via Rodrigues' formula rather than Matrix.CreateFromAxisAngle so the sign convention + // is known and matches the euler extraction below. + var cos = MathF.Cos(angle); + var sin = MathF.Sin(angle); + var m = new Matrix( + cos + axis.X * axis.X * (1 - cos), axis.X * axis.Y * (1 - cos) + axis.Z * sin, axis.X * axis.Z * (1 - cos) - axis.Y * sin, 0, + axis.Y * axis.X * (1 - cos) - axis.Z * sin, cos + axis.Y * axis.Y * (1 - cos), axis.Y * axis.Z * (1 - cos) + axis.X * sin, 0, + axis.Z * axis.X * (1 - cos) + axis.Y * sin, axis.Z * axis.Y * (1 - cos) - axis.X * sin, cos + axis.Z * axis.Z * (1 - cos), 0, + 0, 0, 0, 1); + + var flatRel2 = Vector3.Transform(rel2, m); + var flatRel3 = Vector3.Transform(rel3, m); + + var yRad = MathF.Asin(Math.Clamp(m.M13, -1f, 1f)); + var xRad = MathF.Atan2(-m.M23, m.M33); + var zRad = MathF.Atan2(-m.M12, m.M11); + var eulerDegrees = new Vector3(MathHelper.ToDegrees(xRad), MathHelper.ToDegrees(yRad), MathHelper.ToDegrees(zRad)); + + return (v1, eulerDegrees, flatRel2, flatRel3); + } + } +} diff --git a/Editors/BmdEditor/Services/BmdElementFactory.cs b/Editors/BmdEditor/Services/BmdElementFactory.cs new file mode 100644 index 000000000..42b87ba0f --- /dev/null +++ b/Editors/BmdEditor/Services/BmdElementFactory.cs @@ -0,0 +1,137 @@ +using Microsoft.Xna.Framework; +using Shared.GameFormats.Bmd; +using Shared.GameFormats.RigidModel.Transforms; + +namespace Editors.BmdEditor.Services +{ + /// + /// Builds default POCOs for the "Add" menu. Every field version is picked to be the highest + /// one understands for that type (so nothing gets silently truncated + /// on save) - each item carries its own version tag, so this doesn't need to match whatever + /// version the file's other, pre-existing entries happen to use. + /// + public static class BmdElementFactory + { + private const string DefaultHeightMode = "HM_TERRAIN"; + + // All-0xFF = "no culture restriction" - real vanilla files use this as the wildcard + // pattern (see CultureMask.RawBytes); an all-zero mask would make a new prop invisible to + // every culture, which would look like a bug to whoever placed it. + private static byte[] WildcardCultureMaskBytes() => [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + + private static BmdComponentFlags DefaultFlags() => new() { FlagVersion = 4 }; + + private static BmdComponentFlags AllSeasonsFlags() => new() + { + FlagVersion = 4, + SeasonSpring = true, + SeasonSummer = true, + SeasonAutumn = true, + SeasonWinter = true, + }; + + public static PropInfo CreateProp(string rmv2Path, bool isDecal = false) => new() + { + PropInfoVersion = 25, + Rmv2Path = rmv2Path, + Transform = Matrix.Identity, + IsDecal = isDecal, + Flags = AllSeasonsFlags(), + HeightMode = "BHM_PARENT", + CultureMask = WildcardCultureMaskBytes(), + CastsShadow = true, + }; + + public static VfxInfo CreateVfx(string vfxString) => new() + { + VfxInfoVersion = 10, + VfxString = vfxString, + Transform = Matrix.Identity, + Flags = DefaultFlags(), + HeightMode = DefaultHeightMode, + CultureMask = WildcardCultureMaskBytes(), + }; + + public static PointLightInfo CreatePointLight() => new() + { + PointLightInfoVersion = 7, + Position = new RmvVector3(0, 0, 0), + Radius = 10, + Red = 1, + Green = 1, + Blue = 1, + ColorScale = 1, + HeightMode = DefaultHeightMode, + Flags = DefaultFlags(), + }; + + public static SpotLightInfo CreateSpotLight() => new() + { + Version = 8, + Position = new RmvVector3(0, 0, 0), + QuartX = 0, + QuartY = 0, + QuartZ = 0, + QuartW = 1, + Length = 10, + InnerAngleRadians = 0.3f, + OuterAngleRadians = 0.6f, + IntensityRed = 1, + IntensityGreen = 1, + IntensityBlue = 1, + Falloff = 1, + HeightMode = DefaultHeightMode, + Flags = DefaultFlags(), + }; + + public static SoundInfo CreateSound(string soundString) => new() + { + Version = 10, + SoundString = soundString, + TypeString = "SST_POINT", + CoordList = [new RmvVector3(0, 0, 0)], + HeightMode = DefaultHeightMode, + CultureMask = new CultureMask { RawBytes = WildcardCultureMaskBytes() }, + }; + + public static PolyMeshInfo CreatePolyMesh(string materialString) => new() + { + PolyMeshVersion = 4, + VertexList = [new RmvVector3(0, 0, 0), new RmvVector3(1, 0, 0), new RmvVector3(0, 1, 0)], + TriangleList = [0, 1, 2], + MaterialString = materialString, + HeightMode = DefaultHeightMode, + Flags = DefaultFlags(), + Transform = Matrix.Identity, + Booleans = new byte[4], + MoreBooleans = new byte[1], + }; + + public static LightProbeInfo CreateLightProbe() => new() + { + Version = 3, + Position = new RmvVector3(0, 0, 0), + OuterRadius = 10, + InnerRadius = 5, + HeightMode = DefaultHeightMode, + }; + + public static TerrainHoleTriangleInfo CreateTerrainHole() => new() + { + TerrainHoleVersion = 3, + FirstVert = new RmvVector3(0, 0, 0), + SecondVert = new RmvVector3(1, 0, 0), + ThirdVert = new RmvVector3(0, 1, 0), + HeightMode = DefaultHeightMode, + Flags = DefaultFlags(), + }; + + public static CscInfo CreateCsc(string sceneFile) => new() + { + Version = 12, + SceneFile = sceneFile, + Transform = Matrix.Identity, + HeightMode = DefaultHeightMode, + }; + } +} diff --git a/Editors/BmdEditor/Services/BmdGizmoComponent.cs b/Editors/BmdEditor/Services/BmdGizmoComponent.cs new file mode 100644 index 000000000..503dabbfa --- /dev/null +++ b/Editors/BmdEditor/Services/BmdGizmoComponent.cs @@ -0,0 +1,176 @@ +using System; +using Editors.BmdEditor.Exporting; +using Editors.BmdEditor.ViewModels; +using GameWorld.Core.Components; +using GameWorld.Core.Components.Gizmo; +using GameWorld.Core.Components.Input; +using GameWorld.Core.Components.Rendering; +using GameWorld.Core.Services; +using GameWorld.Core.Utility; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace Editors.BmdEditor.Services +{ + /// + /// A BMD-specific transform gizmo, ported from CscGizmoComponent. The stock + /// bakes transforms into mesh vertices, which is wrong here - + /// moving a BMD element means editing its own position/rotation/scale fields. This component + /// reuses the low-level widget and routes its deltas into whichever + /// element is currently selected in the tree, via the gizmo-support members on + /// . Unlike Csc, BMD has no timeline and no parent/attach + /// hierarchy (every scene group node is an identity transform), so world delta == local delta + /// and there's no need to re-evaluate a world matrix every frame - the view model's own + /// position/orientation is already the ground truth. + /// + public class BmdGizmoComponent : BaseComponent, IDisposable + { + readonly ArcBallCamera _camera; + readonly IMouseComponent _mouse; + readonly IKeyboardComponent _keyboard; + readonly RenderEngineComponent _renderEngine; + readonly IDeviceResolver _deviceResolver; + readonly IGraphicsResourceCreator _graphicsResourceCreator; + + Gizmo? _gizmo; + readonly TargetAdapter _adapter = new(); + BmdElementViewModel? _element; + bool _enabled; + + public BmdGizmoComponent( + ArcBallCamera camera, + IMouseComponent mouseComponent, + IKeyboardComponent keyboardComponent, + RenderEngineComponent renderEngine, + IDeviceResolver deviceResolver, + IGraphicsResourceCreator graphicsResourceCreator) + { + _camera = camera; + _mouse = mouseComponent; + _keyboard = keyboardComponent; + _renderEngine = renderEngine; + _deviceResolver = deviceResolver; + _graphicsResourceCreator = graphicsResourceCreator; + + UpdateOrder = (int)ComponentUpdateOrderEnum.Gizmo; + DrawOrder = (int)ComponentDrawOrderEnum.Gizmo; + } + + public override void Initialize() + { + _gizmo = new Gizmo(_camera, _mouse, _deviceResolver.Device, _renderEngine, _graphicsResourceCreator); + _gizmo.ActivePivot = PivotType.ObjectCenter; + _gizmo.TranslateEvent += OnTranslate; + _gizmo.RotateEvent += OnRotate; + _gizmo.ScaleEvent += OnScale; + _gizmo.StartEvent += OnDragStart; + _gizmo.StopEvent += OnDragEnd; + _gizmo.Selection.Add(_adapter); + } + + public void SetMode(GizmoMode mode) + { + if (_gizmo == null) + return; + if (mode == GizmoMode.Rotate && _element?.SupportsRotate != true) + return; + if (mode == GizmoMode.NonUniformScale && _element?.SupportsScale != true) + return; + _gizmo.ActiveMode = mode; + _enabled = true; + } + + public void Disable() => _enabled = false; + + public void SetTarget(BmdElementViewModel? element) + { + _element = element; + _gizmo?.ResetDeltas(); + } + + bool IsActive => _enabled && _element != null && _gizmo != null; + + public override void Update(GameTime gameTime) + { + if (!IsActive) + return; + + SyncAdapter(); + var isCameraMoving = _keyboard.IsKeyDown(Keys.LeftAlt); + _gizmo!.Update(gameTime, !isCameraMoving); + } + + public override void Draw(GameTime gameTime) + { + if (IsActive) + _gizmo!.Draw(); + } + + void SyncAdapter() + { + _adapter.Position = _element!.GizmoPosition; + _adapter.Orientation = _element.GizmoOrientation; + } + + void OnDragStart() + { + _mouse.MouseOwner = this; + } + + void OnDragEnd() + { + if (_mouse.MouseOwner == this) + { + _mouse.MouseOwner = null; + _mouse.ClearStates(); + } + } + + void OnTranslate(ITransformable transformable, TransformationEventArgs e) + { + if (!IsActive) + return; + + var worldDelta = (Vector3)e.Value!; + _element!.ApplyTranslateDelta(worldDelta); + _adapter.Position += worldDelta; + } + + void OnRotate(ITransformable transformable, TransformationEventArgs e) + { + if (!IsActive || _element?.SupportsRotate != true) + return; + + var deltaMatrix = (Matrix)e.Value!; + var deltaDegrees = TerryTransform.ExtractSmallRotationDeltaDegrees(deltaMatrix); + _element.ApplyRotateDelta(deltaDegrees); + } + + void OnScale(ITransformable transformable, TransformationEventArgs e) + { + if (!IsActive || _element?.SupportsScale != true) + return; + + var value = (Vector3)e.Value!; + var component = value.X != 0 ? value.X : value.Y != 0 ? value.Y : value.Z; + var factor = 1 + component; + if (Math.Abs(factor) < 0.001f) + return; + + _element.ApplyScaleFactor(factor); + } + + public void Dispose() + { + _gizmo?.Dispose(); + } + + class TargetAdapter : ITransformable + { + public Vector3 Position { get; set; } + public Vector3 Scale { get; set; } = Vector3.One; + public Quaternion Orientation { get; set; } = Quaternion.Identity; + public Vector3 GetObjectCentre() => Position; + } + } +} diff --git a/Editors/BmdEditor/Services/BmdSceneCreator.cs b/Editors/BmdEditor/Services/BmdSceneCreator.cs index 952382716..6f9a2f829 100644 --- a/Editors/BmdEditor/Services/BmdSceneCreator.cs +++ b/Editors/BmdEditor/Services/BmdSceneCreator.cs @@ -388,6 +388,10 @@ private SceneNode CreatePolyMeshNode(PolyMeshInfo polyMesh, GroupNode polyMeshGr }); if (placeholderMesh != null) { + // Version > 3 carries a real transform matrix; version <= 3 has none (vertices are + // already baked into world space), matching BmdTerryProjectWriter's understanding. + if (polyMesh.PolyMeshVersion > 3) + placeholderMesh.ModelMatrix = polyMesh.Transform; meshNode.AddObject(placeholderMesh); } @@ -516,7 +520,81 @@ private SceneNode CreateSoundPlaceholderNode(SoundInfo soundInfo, GroupNode soun return soundNode; } - + /// + /// Pushes an edited view model's transform (from a textbox edit or a gizmo drag) into its + /// scene node so the 3D view updates immediately, without rebuilding the scene. Each + /// component's visual reads its position/orientation from a different place (some from + /// their own ModelMatrix, some from bespoke fields the node's own Render reads directly - + /// see the corresponding CreateXxxNode above), so this mirrors that per-type. Sound and + /// world-space (PolyMeshVersion <= 3) polymesh visuals need no push at all - they read + /// straight from the same array the view model already mutated in place. + /// + public void RefreshVisual(BmdElementViewModel element) + { + if (!ComponentNodes.TryGetValue(element, out var node)) + return; + + switch (element) + { + case PropInfoViewModel propVm: + SetChildModelMatrix(node, propVm.Prop.Transform); + break; + + case VfxInfoViewModel vfxVm: + SetChildModelMatrix(node, vfxVm.Vfx.Transform); + break; + + case CscInfoViewModel cscVm: + SetChildModelMatrix(node, cscVm.Csc.Transform); + break; + + case PointLightInfoViewModel lightVm: + SetChildModelMatrix(node, Matrix.CreateTranslation(lightVm.Light.Position.ToVector3())); + break; + + case LightProbeInfoViewModel probeVm: + SetChildModelMatrix(node, Matrix.CreateTranslation(probeVm.Probe.Position.ToVector3())); + break; + + case SpotLightInfoViewModel spotVm: + { + var cone = FindChild(node); + if (cone != null) + { + cone.Position = spotVm.Light.Position; + cone.Quaternion = new Quaternion(spotVm.Light.QuartX, spotVm.Light.QuartY, spotVm.Light.QuartZ, spotVm.Light.QuartW); + } + break; + } + + case TerrainHoleInfoViewModel holeVm: + { + var edges = FindChild(node); + if (edges != null) + { + edges.FirstVert = holeVm.Hole.FirstVert; + edges.SecondVert = holeVm.Hole.SecondVert; + edges.ThirdVert = holeVm.Hole.ThirdVert; + } + break; + } + + case PolyMeshInfoViewModel meshVm when meshVm.Mesh.PolyMeshVersion > 3: + SetChildModelMatrix(node, meshVm.Mesh.Transform); + break; + } + } + + private static void SetChildModelMatrix(SceneNode node, Matrix matrix) + { + var child = node.Children.FirstOrDefault(); + if (child != null) + child.ModelMatrix = matrix; + } + + private static T? FindChild(SceneNode node) where T : class, ISceneNode => + node.Children.OfType().FirstOrDefault(); + public void HighlightComponent(BmdElementViewModel component) { // Clear previous highlight @@ -812,25 +890,4 @@ private SceneNode CreateBoundaryNode(Boundary boundary, SceneNode regionNode, in } } - - // Special key class for BMD reference tracking to prevent infinite recursion - public class BmdBmdReferenceKey : BmdElementViewModel - { - public string BmdPath { get; } - - public BmdBmdReferenceKey(string bmdPath) : base("BMD_Reference", bmdPath, "BMD reference for recursion prevention") - { - BmdPath = bmdPath; - } - - public override bool Equals(object? obj) - { - return obj is BmdBmdReferenceKey other && BmdPath == other.BmdPath; - } - - public override int GetHashCode() - { - return BmdPath.GetHashCode(); - } - } } diff --git a/Editors/BmdEditor/ViewModels/BmdEditorViewModel.cs b/Editors/BmdEditor/ViewModels/BmdEditorViewModel.cs index e701745ad..2d9be20bd 100644 --- a/Editors/BmdEditor/ViewModels/BmdEditorViewModel.cs +++ b/Editors/BmdEditor/ViewModels/BmdEditorViewModel.cs @@ -1,38 +1,50 @@ -using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; +using System.Linq; +using System.Text; using System.Windows.Input; using CommunityToolkit.Mvvm.Input; +using Editors.BmdEditor.Exporting; using Editors.BmdEditor.Services; using GameWorld.Core.Components; using GameWorld.Core.Components.Selection; -using GameWorld.Core.Rendering.Materials; using GameWorld.Core.SceneNodes; -using GameWorld.Core.Services; using GameWorld.Core.WpfWindow; +using Microsoft.Xna.Framework; using Shared.Core.Commands; using Shared.Core.Misc; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Utility; using Shared.Core.Services; using Shared.Core.ToolCreation; using Shared.GameFormats.Bmd; +using Shared.GameFormats.RigidModel.Transforms; namespace Editors.BmdEditor.ViewModels { - public class BmdEditorViewModel : NotifyPropertyChangedImpl, IEditorInterface, IFileEditor, IDisposable + public class BmdEditorViewModel : NotifyPropertyChangedImpl, IEditorInterface, IFileEditor, ISaveableEditor, IDisposable { private readonly IPackFileService _packFileService; private readonly IEditorManager _editorCreator; private readonly IStandardDialogs _standardDialogs; - private readonly GameWorld.Core.Services.ResourceLibrary _resourceLibrary; + private readonly IFileSystemAccess _fileSystemAccess; + private readonly IFileSaveService _fileSaveService; private readonly Shared.Core.Events.IEventHub _eventHub; - private readonly GameWorld.Core.Services.IGraphicsResourceCreator _graphicsResourceCreator; private BmdFile? _bmdFile; public string DisplayName { get; set; } = "Not set"; public PackFile CurrentFile { get; private set; } = null!; public string StatusText { get; set; } = "Ready"; + private bool _hasUnsavedChanges; + public bool HasUnsavedChanges + { + get => _hasUnsavedChanges; + set => SetAndNotify(ref _hasUnsavedChanges, value); + } + // BMD File Properties public BmdFile? BmdFile { @@ -63,19 +75,44 @@ public BmdFile? BmdFile public ObservableCollection CscInfos { get; } = []; public ObservableCollection Deployments { get; } = []; + /// Category sections shown in the (collapsible) component tree - built from the + /// typed collections above whenever they're (re)loaded. + public ObservableCollection ComponentGroups { get; } = []; + // Commands public ICommand RefreshCommand { get; } public ICommand ExportCommand { get; } + public ICommand SaveCommand { get; } public ICommand NavigateToReferencedFileCommand { get; } + public ICommand GizmoTranslateCommand { get; } + public ICommand GizmoRotateCommand { get; } + public ICommand GizmoScaleCommand { get; } + public ICommand GizmoOffCommand { get; } + public ICommand AddPropCommand { get; } + public ICommand AddDecalCommand { get; } + public ICommand AddVfxCommand { get; } + public ICommand AddPointLightCommand { get; } + public ICommand AddSpotLightCommand { get; } + public ICommand AddSoundCommand { get; } + public ICommand AddPolyMeshCommand { get; } + public ICommand AddLightProbeCommand { get; } + public ICommand AddTerrainHoleCommand { get; } + public ICommand AddCscCommand { get; } // Selected component for details display private BmdElementViewModel? _selectedComponent; public BmdElementViewModel? SelectedComponent { get => _selectedComponent; - set => SetAndNotify(ref _selectedComponent, value); + set + { + SetAndNotify(ref _selectedComponent, value); + NotifyPropertyChanged(nameof(HasSelection)); + } } + public bool HasSelection => SelectedComponent != null; + // Component details for display private string _componentDetails = "Select a component to view details"; public string ComponentDetails @@ -85,8 +122,9 @@ public string ComponentDetails } private readonly BmdSceneCreator _bmdSceneCreator; - private readonly SelectionManager? _selectionManager; + private readonly SelectionManager _selectionManager; private readonly BmdElementLoader _bmdElementLoader; + private readonly BmdGizmoComponent _gizmo; public IWpfGame Scene { get; set; } @@ -94,27 +132,27 @@ public BmdEditorViewModel( IPackFileService packFileService, IEditorManager editorCreator, IStandardDialogs standardDialogs, - MeshBuilderService meshBuilderService, - CapabilityMaterialFactory materialFactory, - GameWorld.Core.Services.ResourceLibrary resourceLibrary, + IFileSystemAccess fileSystemAccess, + IFileSaveService fileSaveService, Shared.Core.Events.IEventHub eventHub, - GameWorld.Core.Services.IGraphicsResourceCreator graphicsResourceCreator, IWpfGame gameWorld, BmdSceneCreator bmdSceneCreator, SelectionManager selectionManager, IComponentInserter componentInserter, - BmdElementLoader bmdElementLoader) + BmdElementLoader bmdElementLoader, + BmdGizmoComponent gizmoComponent) { _packFileService = packFileService; _editorCreator = editorCreator; _standardDialogs = standardDialogs; - _resourceLibrary = resourceLibrary; + _fileSystemAccess = fileSystemAccess; + _fileSaveService = fileSaveService; _eventHub = eventHub; - _graphicsResourceCreator = graphicsResourceCreator; _bmdSceneCreator = bmdSceneCreator; - _selectionManager = selectionManager!; + _selectionManager = selectionManager; _bmdElementLoader = bmdElementLoader; - + _gizmo = gizmoComponent; + Scene = gameWorld; // Ensure all game components are added to the editor @@ -122,55 +160,195 @@ public BmdEditorViewModel( RefreshCommand = new RelayCommand(Refresh); ExportCommand = new RelayCommand(Export); + SaveCommand = new RelayCommand(() => Save()); NavigateToReferencedFileCommand = new RelayCommand(NavigateToReferencedFile); - + GizmoTranslateCommand = new RelayCommand(() => _gizmo.SetMode(GameWorld.Core.Components.Gizmo.GizmoMode.Translate)); + GizmoRotateCommand = new RelayCommand(() => _gizmo.SetMode(GameWorld.Core.Components.Gizmo.GizmoMode.Rotate)); + GizmoScaleCommand = new RelayCommand(() => _gizmo.SetMode(GameWorld.Core.Components.Gizmo.GizmoMode.NonUniformScale)); + GizmoOffCommand = new RelayCommand(_gizmo.Disable); + AddPropCommand = new RelayCommand(AddProp); + AddDecalCommand = new RelayCommand(AddDecal); + AddVfxCommand = new RelayCommand(AddVfx); + AddPointLightCommand = new RelayCommand(AddPointLight); + AddSpotLightCommand = new RelayCommand(AddSpotLight); + AddSoundCommand = new RelayCommand(AddSound); + AddPolyMeshCommand = new RelayCommand(AddPolyMesh); + AddLightProbeCommand = new RelayCommand(AddLightProbe); + AddTerrainHoleCommand = new RelayCommand(AddTerrainHole); + AddCscCommand = new RelayCommand(AddCsc); + // Subscribe to selection changes from 3D view _eventHub.Register(this, OnSelectionChanged); - System.Diagnostics.Debug.WriteLine("BmdEditorViewModel: Subscribed to SelectionChangedEvent"); - - if (_selectionManager == null) - { - System.Diagnostics.Debug.WriteLine("BmdEditorViewModel: WARNING - SelectionManager is null!"); - } - else - { - System.Diagnostics.Debug.WriteLine("BmdEditorViewModel: SelectionManager is properly initialized"); - } } - - public void LoadFile(PackFile packFile) { CurrentFile = packFile; DisplayName = packFile.Name; + var data = packFile.DataSource.ReadData(); + + using var stream = new MemoryStream(data); + var parser = new BmdParser(stream); + + BmdFile = parser.Parse(); + HasUnsavedChanges = false; + + // Create 3D scene structure first + _bmdSceneCreator.CreateSceneFromBmd(BmdFile!, packFile); + + // Update collections (this will load scene content) + PopulateViewModels(); + } + + // ------------------------------------------------------------------- + // "Add" menu - creates a default element in the corresponding BmdFile + // list, then rebuilds the scene/view models from the (already-edited, + // in-memory) BmdFile - same rebuild the initial load does, just + // without re-reading from disk - and selects the new element. + // ------------------------------------------------------------------- + + private void FinishAdd(Func findNewElement) + { + if (BmdFile == null) + return; + + _bmdSceneCreator.CreateSceneFromBmd(BmdFile, CurrentFile); + PopulateViewModels(); + HasUnsavedChanges = true; + + var newElement = findNewElement(); + if (newElement != null) + SelectComponent(newElement); + } + + private void AddProp() + { + if (BmdFile == null) + return; + var result = _standardDialogs.DisplayBrowseDialog([".rigid_model_v2", ".wsmodel"]); + if (result.Result == false || result.File == null) + return; + + BmdFile.PropInfos.Add(BmdElementFactory.CreateProp(_packFileService.GetFullPath(result.File))); + FinishAdd(() => Props.LastOrDefault()); + } + + private void AddDecal() + { + if (BmdFile == null) + return; + var result = _standardDialogs.DisplayBrowseDialog([".rigid_model_v2", ".wsmodel"]); + if (result.Result == false || result.File == null) + return; + + BmdFile.PropInfos.Add(BmdElementFactory.CreateProp(_packFileService.GetFullPath(result.File), isDecal: true)); + FinishAdd(() => Props.LastOrDefault()); + } + + private void AddVfx() + { + if (BmdFile == null) + return; + var input = _standardDialogs.ShowTextInputDialog("VFX path (e.g. env_forest_dust)"); + if (input.Result == false || string.IsNullOrWhiteSpace(input.Text)) + return; + + BmdFile.VfxInfos.Add(BmdElementFactory.CreateVfx(input.Text.Trim())); + FinishAdd(() => VfxInfos.LastOrDefault()); + } + + private void AddPointLight() + { + if (BmdFile == null) + return; + BmdFile.PointLights.Add(BmdElementFactory.CreatePointLight()); + FinishAdd(() => PointLights.LastOrDefault()); + } + + private void AddSpotLight() + { + if (BmdFile == null) + return; + BmdFile.SpotLights.Add(BmdElementFactory.CreateSpotLight()); + FinishAdd(() => SpotLights.LastOrDefault()); + } + + private void AddSound() + { + if (BmdFile == null) + return; + var input = _standardDialogs.ShowTextInputDialog("Sound event (e.g. Play_My_Sound)"); + if (input.Result == false || string.IsNullOrWhiteSpace(input.Text)) + return; + + BmdFile.Sounds.Add(BmdElementFactory.CreateSound(input.Text.Trim())); + FinishAdd(() => Sounds.LastOrDefault()); + } + + private void AddPolyMesh() + { + if (BmdFile == null) + return; + var input = _standardDialogs.ShowTextInputDialog("Material name"); + if (input.Result == false) + return; + + BmdFile.PolyMeshes.Add(BmdElementFactory.CreatePolyMesh(input.Text?.Trim() ?? string.Empty)); + FinishAdd(() => PolyMeshes.LastOrDefault()); + } + + private void AddLightProbe() + { + if (BmdFile == null) + return; + BmdFile.LightProbes.Add(BmdElementFactory.CreateLightProbe()); + FinishAdd(() => LightProbes.LastOrDefault()); + } + + private void AddTerrainHole() + { + if (BmdFile == null) + return; + BmdFile.TerrainHoles.Add(BmdElementFactory.CreateTerrainHole()); + FinishAdd(() => TerrainHoles.LastOrDefault()); + } + + private void AddCsc() + { + if (BmdFile == null) + return; + var result = _standardDialogs.DisplayBrowseDialog([".csc"]); + if (result.Result == false || result.File == null) + return; + + BmdFile.CscInfos.Add(BmdElementFactory.CreateCsc(_packFileService.GetFullPath(result.File))); + FinishAdd(() => CscInfos.LastOrDefault()); + } + + public bool Save() + { + if (BmdFile == null) + return false; + try { - System.Diagnostics.Debug.WriteLine($"BMD Editor - Loading file: {packFile.Name}, Size: {packFile.DataSource.Size} bytes"); - - var data = packFile.DataSource.ReadData(); - System.Diagnostics.Debug.WriteLine($"BMD Editor - Read {data.Length} bytes from file"); - - using var stream = new MemoryStream(data); - var parser = new BmdParser(stream); - - BmdFile = parser.Parse(); - System.Diagnostics.Debug.WriteLine($"BMD Editor - Parsing completed successfully"); - - // Create 3D scene structure first - _bmdSceneCreator.CreateSceneFromBmd(BmdFile!, packFile); - System.Diagnostics.Debug.WriteLine($"BMD Editor - 3D scene structure created"); - - // Update collections (this will load scene content) - PopulateViewModels(); - System.Diagnostics.Debug.WriteLine($"BMD Editor - Collections and scene content loaded"); + var bytes = BmdWriter.Write(BmdFile); + var path = _packFileService.GetFullPath(CurrentFile); + var result = _fileSaveService.Save(path, bytes, prompOnConflict: false); + if (result != null) + { + CurrentFile = result; + HasUnsavedChanges = false; + StatusText = $"Saved {result.Name}"; + return true; + } + return false; } - catch (Exception ex) + catch (Exception e) { - System.Diagnostics.Debug.WriteLine($"BMD Editor - Failed to load BMD file {packFile.Name}: {ex.Message}"); - System.Diagnostics.Debug.WriteLine($"BMD Editor - Stack trace: {ex.StackTrace}"); - throw; + _standardDialogs.ShowExceptionWindow(e, "Failed to save the BMD file. The file on disk is unchanged."); + return false; } } @@ -202,15 +380,69 @@ private void PopulateViewModels() Deployments.Clear(); // Use the BmdElementLoader to populate all collections - _bmdElementLoader.LoadElements(BmdFile, + _bmdElementLoader.LoadElements(BmdFile, AllElements, BmdInfos, BattlefieldBuildings, BattlefieldBuildingFars, CaptureLocations, EFLines, GoOutlines, NonTerrainOutlines, BuildingProjectileEmitters, ZonesTemplates, Props, VfxInfos, PointLights, SpotLights, Sounds, PolyMeshes, LightProbes, TerrainHoles, PlayableAreas, CscInfos, Deployments, loadChildBmds: true); + + // Mark the file dirty and push the edited transform back into the 3D view whenever a + // transform (or other editable) property changes on any of the 10 editable categories. + foreach (var element in AllElements) + element.Modified += () => OnElementModified(element); + + RebuildComponentGroups(); + } + + /// Splits the flat typed collections into named, collapsible sections for the + /// component tree - including separating decal props from regular props. + private void RebuildComponentGroups() + { + ComponentGroups.Clear(); + + void AddGroup(string header, IReadOnlyCollection items) + { + if (items.Count > 0) + ComponentGroups.Add(new BmdCategoryGroupViewModel($"{header} ({items.Count})", items)); + } + + AddGroup("Props", Props.Where(p => !p.Prop.IsDecal).ToList()); + AddGroup("Decals", Props.Where(p => p.Prop.IsDecal).ToList()); + AddGroup("VFX", VfxInfos); + AddGroup("Point Lights", PointLights); + AddGroup("Spot Lights", SpotLights); + AddGroup("Sounds", Sounds); + AddGroup("Poly Meshes", PolyMeshes); + AddGroup("Light Probes", LightProbes); + AddGroup("Terrain Holes", TerrainHoles); + AddGroup("Composite Scenes", CscInfos); + AddGroup("Referenced BMDs", BmdInfos); + AddGroup("Battlefield Buildings", BattlefieldBuildings); + AddGroup("Battlefield Building Fars", BattlefieldBuildingFars); + AddGroup("Capture Locations", CaptureLocations); + AddGroup("EF Lines", EFLines); + AddGroup("Go Outlines", GoOutlines); + AddGroup("Non-Terrain Outlines", NonTerrainOutlines); + AddGroup("Building Projectile Emitters", BuildingProjectileEmitters); + AddGroup("Zones Templates", ZonesTemplates); + AddGroup("Playable Areas", PlayableAreas); + AddGroup("Deployments", Deployments); + } + + private void OnElementModified(BmdElementViewModel element) + { + HasUnsavedChanges = true; + _bmdSceneCreator.RefreshVisual(element); } + /// Marks the file dirty for details-panel fields bound directly to the underlying + /// domain object (flags, strings, etc.) rather than through a dedicated view-model + /// property - those plain POCOs don't raise + /// themselves. + public void OnAuxFieldModified() => HasUnsavedChanges = true; + private void Refresh() { if (CurrentFile != null) @@ -221,32 +453,34 @@ private void Refresh() private void Export() { - // TODO: Implement export functionality - System.Diagnostics.Debug.WriteLine("Export BMD file - not yet implemented"); + if (BmdFile == null) + return; + + var folderResult = _standardDialogs.ShowSystemFolderBrowserDialog(); + if (!folderResult.Result || string.IsNullOrWhiteSpace(folderResult.FolderPath)) + return; + + var baseName = Path.GetFileNameWithoutExtension(CurrentFile.Name); + var project = BmdTerryProjectWriter.Build(BmdFile, BmdReferenceResolver.Create(_packFileService)); + + var terryPath = Path.Combine(folderResult.FolderPath, baseName + ".terry"); + var layerPath = Path.Combine(folderResult.FolderPath, $"{baseName}.{project.LayerEntityId}.layer"); + + _fileSystemAccess.FileWriteAllBytes(terryPath, Encoding.UTF8.GetBytes(project.TerryXml)); + _fileSystemAccess.FileWriteAllBytes(layerPath, Encoding.UTF8.GetBytes(project.LayerXml)); } private void NavigateToReferencedFile(string? fileName) { if (string.IsNullOrEmpty(fileName)) return; - try + // Try to find the referenced file in the pack file system + var referencedFile = _packFileService.FindFile(fileName); + if (referencedFile != null) { - // Try to find the referenced file in the pack file system - var referencedFile = _packFileService.FindFile(fileName); - if (referencedFile != null) - { - // Open the referenced file in the appropriate editor - var openCommand = new OpenEditorCommand(_editorCreator, _packFileService); - openCommand.Execute(referencedFile); - } - else - { - System.Diagnostics.Debug.WriteLine($"Referenced file not found: {fileName}"); - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"Failed to navigate to referenced file {fileName}: {ex.Message}"); + // Open the referenced file in the appropriate editor + var openCommand = new OpenEditorCommand(_editorCreator, _packFileService); + openCommand.Execute(referencedFile); } } @@ -254,62 +488,28 @@ public void SelectComponent(BmdElementViewModel component) { SelectedComponent = component; ComponentDetails = GenerateComponentDetails(component); - - System.Diagnostics.Debug.WriteLine($"SelectComponent called: {component.ElementType} - {component.DisplayName}"); - - // Add visible debug info to component details - ComponentDetails += $"\n\n[DEBUG] SelectComponent called: {component.ElementType} - {component.DisplayName}"; - + _gizmo.SetTarget(component); + // Select the component in the 3D scene using SelectionManager SelectComponentIn3DScene(component); - - System.Diagnostics.Debug.WriteLine($"SelectComponent completed: {component.ElementType} - {component.DisplayName}"); } private void SelectComponentIn3DScene(BmdElementViewModel component) { - System.Diagnostics.Debug.WriteLine($"SelectComponentIn3DScene: Looking for component {component.ElementType} - {component.DisplayName}"); - - // Add visible debug info - ComponentDetails += $"\n[DEBUG] Looking for component: {component.ElementType} - {component.DisplayName}"; - if (_bmdSceneCreator.ComponentNodes.TryGetValue(component, out var sceneNode)) { - System.Diagnostics.Debug.WriteLine($"SelectComponentIn3DScene: Found scene node '{sceneNode.Name}' for component {component.ElementType} - {component.DisplayName}"); - ComponentDetails += $"\n[DEBUG] Found scene node: {sceneNode.Name}"; - // Find the first selectable child node (Rmv2MeshNode implements ISelectable) var selectableNode = FindFirstSelectableNode(sceneNode); if (selectableNode != null) { - System.Diagnostics.Debug.WriteLine($"SelectComponentIn3DScene: Found selectable node '{selectableNode.Name}' for component {component.ElementType} - {component.DisplayName}"); - ComponentDetails += $"\n[DEBUG] Found selectable node: {selectableNode.Name}"; - // Clear current selection and select the new object - var objectSelection = _selectionManager!.GetState(); + var objectSelection = _selectionManager.GetState(); if (objectSelection != null) { objectSelection.Clear(); objectSelection.ModifySelectionSingleObject(selectableNode, false); - System.Diagnostics.Debug.WriteLine($"SelectComponentIn3DScene: Successfully selected 3D object: {component.ElementType} - {component.DisplayName}"); - ComponentDetails += $"\n[DEBUG] Successfully selected 3D object!"; - } - else - { - System.Diagnostics.Debug.WriteLine($"SelectComponentIn3DScene: ObjectSelectionState is null!"); - ComponentDetails += $"\n[DEBUG] ERROR: ObjectSelectionState is null!"; } } - else - { - System.Diagnostics.Debug.WriteLine($"SelectComponentIn3DScene: No selectable node found for component: {component.ElementType} - {component.DisplayName}"); - ComponentDetails += $"\n[DEBUG] ERROR: No selectable node found!"; - } - } - else - { - System.Diagnostics.Debug.WriteLine($"SelectComponentIn3DScene: 3D object not found for component: {component.ElementType} - {component.DisplayName}"); - ComponentDetails += $"\n[DEBUG] ERROR: 3D object not found!"; } } @@ -626,17 +826,14 @@ public void Close() PlayableAreas.Clear(); CscInfos.Clear(); Deployments.Clear(); + ComponentGroups.Clear(); } private void OnSelectionChanged(SelectionChangedEvent selectionEvent) { - System.Diagnostics.Debug.WriteLine($"OnSelectionChanged called: {selectionEvent.NewState?.GetType().Name}"); - if (selectionEvent.NewState is ObjectSelectionState objectSelection) { var selectedObject = objectSelection.GetSingleSelectedObject(); - System.Diagnostics.Debug.WriteLine($"OnSelectionChanged: Selected object is '{selectedObject?.Name ?? "null"}'"); - if (selectedObject != null) { // Find the corresponding BMD element for this scene node @@ -646,54 +843,25 @@ private void OnSelectionChanged(SelectionChangedEvent selectionEvent) // Update the selected component in the UI without triggering another selection change SelectedComponent = component; ComponentDetails = GenerateComponentDetails(component); - - // Add visible debug info - ComponentDetails += $"\n\n[DEBUG] 3D view selected: {component.ElementType} - {component.DisplayName}"; - - System.Diagnostics.Debug.WriteLine($"OnSelectionChanged: 3D view selected component: {component.ElementType} - {component.DisplayName}"); + _gizmo.SetTarget(component); } - else - { - System.Diagnostics.Debug.WriteLine($"OnSelectionChanged: No component found for selected object: {selectedObject.Name}"); - - // Show debug info even when no component is found - if (SelectedComponent != null) - { - ComponentDetails += $"\n\n[DEBUG] 3D view selected object: {selectedObject.Name} (no matching component found)"; - } - } - } - } - else - { - System.Diagnostics.Debug.WriteLine($"OnSelectionChanged: New state is not ObjectSelectionState"); - - if (SelectedComponent != null) - { - ComponentDetails += $"\n\n[DEBUG] Selection changed but not ObjectSelectionState: {selectionEvent.NewState?.GetType().Name}"; } } } private BmdElementViewModel? FindComponentBySceneNode(ISelectable sceneNode) { - System.Diagnostics.Debug.WriteLine($"FindComponentBySceneNode: Looking for scene node '{sceneNode.Name}'"); - // Direct lookup using the ComponentNodes dictionary foreach (var kvp in _bmdSceneCreator.ComponentNodes) { var component = kvp.Key; var node = kvp.Value; - + // Check if this node or any of its children match the selected scene node if (IsNodeOrDescendant(node, sceneNode)) - { - System.Diagnostics.Debug.WriteLine($"FindComponentBySceneNode: Found match: {component.ElementType} - {component.DisplayName}"); return component; - } } - - System.Diagnostics.Debug.WriteLine($"FindComponentBySceneNode: No matching component found for scene node '{sceneNode.Name}' or its descendants"); + return null; } @@ -732,6 +900,33 @@ public abstract class BmdElementViewModel(string elementType, string displayName public string DisplayName { get; } = displayName; public string Description { get; } = description; public virtual ObservableCollection Children { get; } = []; + + /// Raised whenever a transform (or other editable) property changes the + /// underlying domain data - the editor subscribes to mark the file dirty and refresh the + /// 3D view's visual for this element. + public event Action? Modified; + protected void RaiseModified() => Modified?.Invoke(); + + // ------------------------------------------------------------------- + // Gizmo support - every one of the 10 editable categories can be + // translated; rotation/scale are only exposed where the format + // actually carries that data (see the per-category overrides below). + // ------------------------------------------------------------------- + public virtual bool SupportsRotate => false; + public virtual bool SupportsScale => false; + public virtual Vector3 GizmoPosition { get => Vector3.Zero; set { } } + public virtual Quaternion GizmoOrientation => Quaternion.Identity; + public virtual void ApplyTranslateDelta(Vector3 worldDelta) { } + public virtual void ApplyRotateDelta(Vector3 deltaEulerDegrees) { } + public virtual void ApplyScaleFactor(float factor) { } + } + + /// A collapsible, named section of the component tree (e.g. "Props (12)"), grouping + /// one category's elements together instead of showing everything in one flat list. + public class BmdCategoryGroupViewModel(string header, IReadOnlyCollection items) + { + public string Header { get; } = header; + public IReadOnlyCollection Items { get; } = items; } // View models for specific element types @@ -740,55 +935,393 @@ public class BattlefieldBuildingViewModel(BattlefieldBuilding building) : BmdEle public BattlefieldBuilding Building { get; } = building; } - public class PropInfoViewModel(PropInfo prop, string propFilePath) : BmdElementViewModel("Prop", System.IO.Path.GetFileNameWithoutExtension(propFilePath) ?? "Unknown Prop", $"Version: {prop.PropInfoVersion}") + public class PropInfoViewModel(PropInfo prop, string propFilePath) : BmdElementViewModel(prop.IsDecal ? "Decal" : "Prop", System.IO.Path.GetFileNameWithoutExtension(propFilePath) ?? "Unknown Prop", $"Version: {prop.PropInfoVersion}") { public PropInfo Prop { get; } = prop; public string PropName { get; } = System.IO.Path.GetFileNameWithoutExtension(propFilePath) ?? "Unknown Prop"; public string PropFilePath { get; } = propFilePath; + + // Full position/rotation/scale - props (including decals) store a real transform matrix. + public float PositionX { get => Prop.Transform.Translation.X; set => SetPosition(value, PositionY, PositionZ); } + public float PositionY { get => Prop.Transform.Translation.Y; set => SetPosition(PositionX, value, PositionZ); } + public float PositionZ { get => Prop.Transform.Translation.Z; set => SetPosition(PositionX, PositionY, value); } + + public float RotationXDegrees { get => TerryTransform.Decompose(Prop.Transform).EulerDegrees.X; set => SetRotation(value, RotationYDegrees, RotationZDegrees); } + public float RotationYDegrees { get => TerryTransform.Decompose(Prop.Transform).EulerDegrees.Y; set => SetRotation(RotationXDegrees, value, RotationZDegrees); } + public float RotationZDegrees { get => TerryTransform.Decompose(Prop.Transform).EulerDegrees.Z; set => SetRotation(RotationXDegrees, RotationYDegrees, value); } + + public float ScaleX { get => TerryTransform.Decompose(Prop.Transform).Scale.X; set => SetScale(value, ScaleY, ScaleZ); } + public float ScaleY { get => TerryTransform.Decompose(Prop.Transform).Scale.Y; set => SetScale(ScaleX, value, ScaleZ); } + public float ScaleZ { get => TerryTransform.Decompose(Prop.Transform).Scale.Z; set => SetScale(ScaleX, ScaleY, value); } + + void SetPosition(float x, float y, float z) + { + var d = TerryTransform.Decompose(Prop.Transform); + Prop.Transform = TerryTransform.Compose(new Vector3(x, y, z), d.EulerDegrees, d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetRotation(float x, float y, float z) + { + var d = TerryTransform.Decompose(Prop.Transform); + Prop.Transform = TerryTransform.Compose(d.Position, new Vector3(x, y, z), d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetScale(float x, float y, float z) + { + var d = TerryTransform.Decompose(Prop.Transform); + Prop.Transform = TerryTransform.Compose(d.Position, d.EulerDegrees, new Vector3(x, y, z)); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + public override bool SupportsRotate => true; + public override bool SupportsScale => true; + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set => SetPosition(value.X, value.Y, value.Z); } + public override Quaternion GizmoOrientation => TerryTransform.EulerDegreesToQuaternion(new Vector3(RotationXDegrees, RotationYDegrees, RotationZDegrees)); + public override void ApplyTranslateDelta(Vector3 worldDelta) => SetPosition(PositionX + worldDelta.X, PositionY + worldDelta.Y, PositionZ + worldDelta.Z); + public override void ApplyRotateDelta(Vector3 deltaEulerDegrees) => SetRotation(RotationXDegrees + deltaEulerDegrees.X, RotationYDegrees + deltaEulerDegrees.Y, RotationZDegrees + deltaEulerDegrees.Z); + public override void ApplyScaleFactor(float factor) => SetScale(ScaleX * factor, ScaleY * factor, ScaleZ * factor); } public class VfxInfoViewModel(VfxInfo vfx) : BmdElementViewModel("VFX", vfx.VfxString, $"Version: {vfx.VfxInfoVersion}") { public VfxInfo Vfx { get; } = vfx; + + // Full position/rotation/scale - VFX carries a real transform matrix. + public float PositionX { get => Vfx.Transform.Translation.X; set => SetPosition(value, PositionY, PositionZ); } + public float PositionY { get => Vfx.Transform.Translation.Y; set => SetPosition(PositionX, value, PositionZ); } + public float PositionZ { get => Vfx.Transform.Translation.Z; set => SetPosition(PositionX, PositionY, value); } + + public float RotationXDegrees { get => TerryTransform.Decompose(Vfx.Transform).EulerDegrees.X; set => SetRotation(value, RotationYDegrees, RotationZDegrees); } + public float RotationYDegrees { get => TerryTransform.Decompose(Vfx.Transform).EulerDegrees.Y; set => SetRotation(RotationXDegrees, value, RotationZDegrees); } + public float RotationZDegrees { get => TerryTransform.Decompose(Vfx.Transform).EulerDegrees.Z; set => SetRotation(RotationXDegrees, RotationYDegrees, value); } + + public float ScaleX { get => TerryTransform.Decompose(Vfx.Transform).Scale.X; set => SetScale(value, ScaleY, ScaleZ); } + public float ScaleY { get => TerryTransform.Decompose(Vfx.Transform).Scale.Y; set => SetScale(ScaleX, value, ScaleZ); } + public float ScaleZ { get => TerryTransform.Decompose(Vfx.Transform).Scale.Z; set => SetScale(ScaleX, ScaleY, value); } + + void SetPosition(float x, float y, float z) + { + var d = TerryTransform.Decompose(Vfx.Transform); + Vfx.Transform = TerryTransform.Compose(new Vector3(x, y, z), d.EulerDegrees, d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetRotation(float x, float y, float z) + { + var d = TerryTransform.Decompose(Vfx.Transform); + Vfx.Transform = TerryTransform.Compose(d.Position, new Vector3(x, y, z), d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetScale(float x, float y, float z) + { + var d = TerryTransform.Decompose(Vfx.Transform); + Vfx.Transform = TerryTransform.Compose(d.Position, d.EulerDegrees, new Vector3(x, y, z)); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + public override bool SupportsRotate => true; + public override bool SupportsScale => true; + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set => SetPosition(value.X, value.Y, value.Z); } + public override Quaternion GizmoOrientation => TerryTransform.EulerDegreesToQuaternion(new Vector3(RotationXDegrees, RotationYDegrees, RotationZDegrees)); + public override void ApplyTranslateDelta(Vector3 worldDelta) => SetPosition(PositionX + worldDelta.X, PositionY + worldDelta.Y, PositionZ + worldDelta.Z); + public override void ApplyRotateDelta(Vector3 deltaEulerDegrees) => SetRotation(RotationXDegrees + deltaEulerDegrees.X, RotationYDegrees + deltaEulerDegrees.Y, RotationZDegrees + deltaEulerDegrees.Z); + public override void ApplyScaleFactor(float factor) => SetScale(ScaleX * factor, ScaleY * factor, ScaleZ * factor); } - public class PointLightInfoViewModel(PointLightInfo light) : BmdElementViewModel("Point Light", $"Point Light at ({light.Position.X:F1}, {light.Position.Y:F1}, {light.Position.Z:F1})", + public class PointLightInfoViewModel(PointLightInfo light) : BmdElementViewModel("Point Light", $"Point Light at ({light.Position.X:F1}, {light.Position.Y:F1}, {light.Position.Z:F1})", $"Radius: {light.Radius:F1}, Color: ({light.Red:F1}, {light.Green:F1}, {light.Blue:F1})") { public PointLightInfo Light { get; } = light; + + // Position only - a point light is omnidirectional, no rotation/scale in the format. + public float PositionX { get => Light.Position.X; set { Light.Position = new RmvVector3(value, Light.Position.Y, Light.Position.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float PositionY { get => Light.Position.Y; set { Light.Position = new RmvVector3(Light.Position.X, value, Light.Position.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float PositionZ { get => Light.Position.Z; set { Light.Position = new RmvVector3(Light.Position.X, Light.Position.Y, value); NotifyPropertyChanged(); RaiseModified(); } } + + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set { PositionX = value.X; PositionY = value.Y; PositionZ = value.Z; } } + public override void ApplyTranslateDelta(Vector3 worldDelta) => GizmoPosition += worldDelta; + + /// Names match BmdTerryProjectWriter.BuildPointLightEntity's mapping. + public string[] AnimationTypeNames { get; } = ["LAT_NONE", "LAT_RADIUS_SIN", "LAT_RADIUS_SIN_SIN"]; + + public string AnimationTypeName + { + get => Light.AnimationTypeEnum switch { 1 => "LAT_RADIUS_SIN", 2 => "LAT_RADIUS_SIN_SIN", _ => "LAT_NONE" }; + set + { + Light.AnimationTypeEnum = value switch { "LAT_RADIUS_SIN" => 1, "LAT_RADIUS_SIN_SIN" => 2, _ => (byte)0 }; + NotifyPropertyChanged(); + RaiseModified(); + } + } } - public class SpotLightInfoViewModel(SpotLightInfo light) : BmdElementViewModel("Spot Light", $"Spot Light at ({light.Position.X:F1}, {light.Position.Y:F1}, {light.Position.Z:F1})", + public class SpotLightInfoViewModel(SpotLightInfo light) : BmdElementViewModel("Spot Light", $"Spot Light at ({light.Position.X:F1}, {light.Position.Y:F1}, {light.Position.Z:F1})", $"RGB: ({light.IntensityRed:F2},{light.IntensityGreen:F2},{light.IntensityBlue:F2}), Length: {light.Length:F2}") { public SpotLightInfo Light { get; } = light; + + // Position + rotation (from the stored quaternion) - no scale field in the format. + public float PositionX { get => Light.Position.X; set { Light.Position = new RmvVector3(value, Light.Position.Y, Light.Position.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float PositionY { get => Light.Position.Y; set { Light.Position = new RmvVector3(Light.Position.X, value, Light.Position.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float PositionZ { get => Light.Position.Z; set { Light.Position = new RmvVector3(Light.Position.X, Light.Position.Y, value); NotifyPropertyChanged(); RaiseModified(); } } + + Vector3 EulerDegrees => TerryTransform.QuaternionToEulerDegrees(Light.QuartX, Light.QuartY, Light.QuartZ, Light.QuartW); + + public float RotationXDegrees { get => EulerDegrees.X; set => SetRotation(value, RotationYDegrees, RotationZDegrees); } + public float RotationYDegrees { get => EulerDegrees.Y; set => SetRotation(RotationXDegrees, value, RotationZDegrees); } + public float RotationZDegrees { get => EulerDegrees.Z; set => SetRotation(RotationXDegrees, RotationYDegrees, value); } + + void SetRotation(float x, float y, float z) + { + var q = TerryTransform.EulerDegreesToQuaternion(new Vector3(x, y, z)); + Light.QuartX = q.X; + Light.QuartY = q.Y; + Light.QuartZ = q.Z; + Light.QuartW = q.W; + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + public override bool SupportsRotate => true; + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set { PositionX = value.X; PositionY = value.Y; PositionZ = value.Z; } } + public override Quaternion GizmoOrientation => new(Light.QuartX, Light.QuartY, Light.QuartZ, Light.QuartW); + public override void ApplyTranslateDelta(Vector3 worldDelta) => GizmoPosition += worldDelta; + public override void ApplyRotateDelta(Vector3 deltaEulerDegrees) => SetRotation(RotationXDegrees + deltaEulerDegrees.X, RotationYDegrees + deltaEulerDegrees.Y, RotationZDegrees + deltaEulerDegrees.Z); } public class SoundInfoViewModel(SoundInfo sound) : BmdElementViewModel("Sound", sound.SoundString, $"Type: {sound.TypeString}, Version: {sound.Version}") { public SoundInfo Sound { get; } = sound; + + // Position only, per-coordinate - sounds have no rotation/scale, and multi-point sounds + // (SST_LINE_LIST/SST_MULTI_POINT) need each coordinate independently movable. CoordList[0] + // also doubles as "the" position for single-point sounds (SST_SPHERE) and as the gizmo's + // drag target (OffsetAll moves every point together, rigidly). + public ObservableCollection Points { get; } = + new(System.Linq.Enumerable.Range(0, sound.CoordList.Length).Select(i => new SoundPointViewModel(sound, i))); + + public bool HasPoints => Sound.CoordList.Length > 0; + + public float PositionX { get => HasPoints ? Sound.CoordList[0].X : 0; set { if (HasPoints) { Sound.CoordList[0].X = value; NotifyPropertyChanged(); RaiseModified(); } } } + public float PositionY { get => HasPoints ? Sound.CoordList[0].Y : 0; set { if (HasPoints) { Sound.CoordList[0].Y = value; NotifyPropertyChanged(); RaiseModified(); } } } + public float PositionZ { get => HasPoints ? Sound.CoordList[0].Z : 0; set { if (HasPoints) { Sound.CoordList[0].Z = value; NotifyPropertyChanged(); RaiseModified(); } } } + + /// Rigidly offsets every coordinate by the same delta - used by the gizmo drag. + public void OffsetAll(Vector3 delta) + { + for (var i = 0; i < Sound.CoordList.Length; i++) + { + Sound.CoordList[i].X += delta.X; + Sound.CoordList[i].Y += delta.Y; + Sound.CoordList[i].Z += delta.Z; + } + foreach (var p in Points) + p.RefreshFromDomain(); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set => OffsetAll(value - GizmoPosition); } + public override void ApplyTranslateDelta(Vector3 worldDelta) => OffsetAll(worldDelta); + + /// Known values seen across the corpus and referenced by the Terry exporter; the + /// combo box is editable too since this field isn't a strictly closed enum. + public string[] TypeNames { get; } = ["SST_POINT", "SST_SPHERE", "SST_LINE_LIST", "SST_MULTI_POINT", "SST_RIVER"]; + } + + /// One editable coordinate within a 's CoordList. + public class SoundPointViewModel(SoundInfo sound, int index) : NotifyPropertyChangedImpl + { + public int Index => index; + public float X { get => sound.CoordList[index].X; set { sound.CoordList[index].X = value; NotifyPropertyChanged(); } } + public float Y { get => sound.CoordList[index].Y; set { sound.CoordList[index].Y = value; NotifyPropertyChanged(); } } + public float Z { get => sound.CoordList[index].Z; set { sound.CoordList[index].Z = value; NotifyPropertyChanged(); } } + public void RefreshFromDomain() => NotifyPropertyChanged(string.Empty); } public class PolyMeshInfoViewModel(PolyMeshInfo mesh) : BmdElementViewModel("PolyMesh", mesh.MaterialString, $"Vertices: {mesh.VertexList.Length}, Triangles: {mesh.TriangleList.Length / 3}") { public PolyMeshInfo Mesh { get; } = mesh; + + /// Version > 3 carries a real transform matrix (full TRS); version ≤ 3 has + /// no separate transform at all - its vertices are already baked into world space, so + /// "moving" it means bulk-offsetting every vertex (translate only, no rotate/scale). + public bool HasTransform => Mesh.PolyMeshVersion > 3; + + public float PositionX + { + get => HasTransform ? Mesh.Transform.Translation.X : (Mesh.VertexList.Length > 0 ? Mesh.VertexList[0].X : 0); + set { if (HasTransform) SetPosition(value, PositionY, PositionZ); else OffsetVertices(value - PositionX, 0, 0); } + } + public float PositionY + { + get => HasTransform ? Mesh.Transform.Translation.Y : (Mesh.VertexList.Length > 0 ? Mesh.VertexList[0].Y : 0); + set { if (HasTransform) SetPosition(PositionX, value, PositionZ); else OffsetVertices(0, value - PositionY, 0); } + } + public float PositionZ + { + get => HasTransform ? Mesh.Transform.Translation.Z : (Mesh.VertexList.Length > 0 ? Mesh.VertexList[0].Z : 0); + set { if (HasTransform) SetPosition(PositionX, PositionY, value); else OffsetVertices(0, 0, value - PositionZ); } + } + + public float RotationXDegrees { get => HasTransform ? TerryTransform.Decompose(Mesh.Transform).EulerDegrees.X : 0; set { if (HasTransform) SetRotation(value, RotationYDegrees, RotationZDegrees); } } + public float RotationYDegrees { get => HasTransform ? TerryTransform.Decompose(Mesh.Transform).EulerDegrees.Y : 0; set { if (HasTransform) SetRotation(RotationXDegrees, value, RotationZDegrees); } } + public float RotationZDegrees { get => HasTransform ? TerryTransform.Decompose(Mesh.Transform).EulerDegrees.Z : 0; set { if (HasTransform) SetRotation(RotationXDegrees, RotationYDegrees, value); } } + + public float ScaleX { get => HasTransform ? TerryTransform.Decompose(Mesh.Transform).Scale.X : 1; set { if (HasTransform) SetScale(value, ScaleY, ScaleZ); } } + public float ScaleY { get => HasTransform ? TerryTransform.Decompose(Mesh.Transform).Scale.Y : 1; set { if (HasTransform) SetScale(ScaleX, value, ScaleZ); } } + public float ScaleZ { get => HasTransform ? TerryTransform.Decompose(Mesh.Transform).Scale.Z : 1; set { if (HasTransform) SetScale(ScaleX, ScaleY, value); } } + + void SetPosition(float x, float y, float z) + { + var d = TerryTransform.Decompose(Mesh.Transform); + Mesh.Transform = TerryTransform.Compose(new Vector3(x, y, z), d.EulerDegrees, d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetRotation(float x, float y, float z) + { + var d = TerryTransform.Decompose(Mesh.Transform); + Mesh.Transform = TerryTransform.Compose(d.Position, new Vector3(x, y, z), d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetScale(float x, float y, float z) + { + var d = TerryTransform.Decompose(Mesh.Transform); + Mesh.Transform = TerryTransform.Compose(d.Position, d.EulerDegrees, new Vector3(x, y, z)); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void OffsetVertices(float dx, float dy, float dz) + { + for (var i = 0; i < Mesh.VertexList.Length; i++) + { + Mesh.VertexList[i].X += dx; + Mesh.VertexList[i].Y += dy; + Mesh.VertexList[i].Z += dz; + } + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + public override bool SupportsRotate => HasTransform; + public override bool SupportsScale => HasTransform; + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set { PositionX = value.X; PositionY = value.Y; PositionZ = value.Z; } } + public override Quaternion GizmoOrientation => HasTransform ? TerryTransform.EulerDegreesToQuaternion(new Vector3(RotationXDegrees, RotationYDegrees, RotationZDegrees)) : Quaternion.Identity; + public override void ApplyTranslateDelta(Vector3 worldDelta) { if (HasTransform) SetPosition(PositionX + worldDelta.X, PositionY + worldDelta.Y, PositionZ + worldDelta.Z); else OffsetVertices(worldDelta.X, worldDelta.Y, worldDelta.Z); } + public override void ApplyRotateDelta(Vector3 deltaEulerDegrees) { if (HasTransform) SetRotation(RotationXDegrees + deltaEulerDegrees.X, RotationYDegrees + deltaEulerDegrees.Y, RotationZDegrees + deltaEulerDegrees.Z); } + public override void ApplyScaleFactor(float factor) { if (HasTransform) SetScale(ScaleX * factor, ScaleY * factor, ScaleZ * factor); } } - public class LightProbeInfoViewModel(LightProbeInfo probe) : BmdElementViewModel("Light Probe", $"Probe_{probe.Position.X:F2}_{probe.Position.Y:F2}_{probe.Position.Z:F2}", + public class LightProbeInfoViewModel(LightProbeInfo probe) : BmdElementViewModel("Light Probe", $"Probe_{probe.Position.X:F2}_{probe.Position.Y:F2}_{probe.Position.Z:F2}", $"Inner: {probe.InnerRadius:F2}, Outer: {probe.OuterRadius:F2}, Primary: {probe.Primary}") { public LightProbeInfo Probe { get; } = probe; + + // Position only - light probes have no rotation, and radius (not "scale") controls extent. + public float PositionX { get => Probe.Position.X; set { Probe.Position = new RmvVector3(value, Probe.Position.Y, Probe.Position.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float PositionY { get => Probe.Position.Y; set { Probe.Position = new RmvVector3(Probe.Position.X, value, Probe.Position.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float PositionZ { get => Probe.Position.Z; set { Probe.Position = new RmvVector3(Probe.Position.X, Probe.Position.Y, value); NotifyPropertyChanged(); RaiseModified(); } } + + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set { PositionX = value.X; PositionY = value.Y; PositionZ = value.Z; } } + public override void ApplyTranslateDelta(Vector3 worldDelta) => GizmoPosition += worldDelta; } - public class TerrainHoleInfoViewModel(TerrainHoleTriangleInfo hole) : BmdElementViewModel("Terrain Hole", $"Hole at ({hole.FirstVert.X:F1}, {hole.FirstVert.Y:F1}, {hole.FirstVert.Z:F1})", + public class TerrainHoleInfoViewModel(TerrainHoleTriangleInfo hole) : BmdElementViewModel("Terrain Hole", $"Hole at ({hole.FirstVert.X:F1}, {hole.FirstVert.Y:F1}, {hole.FirstVert.Z:F1})", $"Version: {hole.TerrainHoleVersion}") { public TerrainHoleTriangleInfo Hole { get; } = hole; + + // Three independently draggable vertices - a triangle has no single "transform", and no + // rotation/scale concept applies to a set of raw points. + public float FirstVertX { get => Hole.FirstVert.X; set { Hole.FirstVert = new RmvVector3(value, Hole.FirstVert.Y, Hole.FirstVert.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float FirstVertY { get => Hole.FirstVert.Y; set { Hole.FirstVert = new RmvVector3(Hole.FirstVert.X, value, Hole.FirstVert.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float FirstVertZ { get => Hole.FirstVert.Z; set { Hole.FirstVert = new RmvVector3(Hole.FirstVert.X, Hole.FirstVert.Y, value); NotifyPropertyChanged(); RaiseModified(); } } + + public float SecondVertX { get => Hole.SecondVert.X; set { Hole.SecondVert = new RmvVector3(value, Hole.SecondVert.Y, Hole.SecondVert.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float SecondVertY { get => Hole.SecondVert.Y; set { Hole.SecondVert = new RmvVector3(Hole.SecondVert.X, value, Hole.SecondVert.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float SecondVertZ { get => Hole.SecondVert.Z; set { Hole.SecondVert = new RmvVector3(Hole.SecondVert.X, Hole.SecondVert.Y, value); NotifyPropertyChanged(); RaiseModified(); } } + + public float ThirdVertX { get => Hole.ThirdVert.X; set { Hole.ThirdVert = new RmvVector3(value, Hole.ThirdVert.Y, Hole.ThirdVert.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float ThirdVertY { get => Hole.ThirdVert.Y; set { Hole.ThirdVert = new RmvVector3(Hole.ThirdVert.X, value, Hole.ThirdVert.Z); NotifyPropertyChanged(); RaiseModified(); } } + public float ThirdVertZ { get => Hole.ThirdVert.Z; set { Hole.ThirdVert = new RmvVector3(Hole.ThirdVert.X, Hole.ThirdVert.Y, value); NotifyPropertyChanged(); RaiseModified(); } } + + /// Rigidly offsets all three vertices by the same delta - used by the gizmo drag. + public void OffsetAll(Vector3 delta) + { + Hole.FirstVert = new RmvVector3(Hole.FirstVert.X + delta.X, Hole.FirstVert.Y + delta.Y, Hole.FirstVert.Z + delta.Z); + Hole.SecondVert = new RmvVector3(Hole.SecondVert.X + delta.X, Hole.SecondVert.Y + delta.Y, Hole.SecondVert.Z + delta.Z); + Hole.ThirdVert = new RmvVector3(Hole.ThirdVert.X + delta.X, Hole.ThirdVert.Y + delta.Y, Hole.ThirdVert.Z + delta.Z); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + public override Vector3 GizmoPosition { get => new(FirstVertX, FirstVertY, FirstVertZ); set => OffsetAll(value - GizmoPosition); } + public override void ApplyTranslateDelta(Vector3 worldDelta) => OffsetAll(worldDelta); } public class CscInfoViewModel(CscInfo csc) : BmdElementViewModel("CSC Info", csc.SceneFile, $"Version: {csc.Version}") { public CscInfo Csc { get; } = csc; + + // Full position/rotation/scale - composite scene references carry a real transform matrix. + public float PositionX { get => Csc.Transform.Translation.X; set => SetPosition(value, PositionY, PositionZ); } + public float PositionY { get => Csc.Transform.Translation.Y; set => SetPosition(PositionX, value, PositionZ); } + public float PositionZ { get => Csc.Transform.Translation.Z; set => SetPosition(PositionX, PositionY, value); } + + public float RotationXDegrees { get => TerryTransform.Decompose(Csc.Transform).EulerDegrees.X; set => SetRotation(value, RotationYDegrees, RotationZDegrees); } + public float RotationYDegrees { get => TerryTransform.Decompose(Csc.Transform).EulerDegrees.Y; set => SetRotation(RotationXDegrees, value, RotationZDegrees); } + public float RotationZDegrees { get => TerryTransform.Decompose(Csc.Transform).EulerDegrees.Z; set => SetRotation(RotationXDegrees, RotationYDegrees, value); } + + public float ScaleX { get => TerryTransform.Decompose(Csc.Transform).Scale.X; set => SetScale(value, ScaleY, ScaleZ); } + public float ScaleY { get => TerryTransform.Decompose(Csc.Transform).Scale.Y; set => SetScale(ScaleX, value, ScaleZ); } + public float ScaleZ { get => TerryTransform.Decompose(Csc.Transform).Scale.Z; set => SetScale(ScaleX, ScaleY, value); } + + void SetPosition(float x, float y, float z) + { + var d = TerryTransform.Decompose(Csc.Transform); + Csc.Transform = TerryTransform.Compose(new Vector3(x, y, z), d.EulerDegrees, d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetRotation(float x, float y, float z) + { + var d = TerryTransform.Decompose(Csc.Transform); + Csc.Transform = TerryTransform.Compose(d.Position, new Vector3(x, y, z), d.Scale); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + void SetScale(float x, float y, float z) + { + var d = TerryTransform.Decompose(Csc.Transform); + Csc.Transform = TerryTransform.Compose(d.Position, d.EulerDegrees, new Vector3(x, y, z)); + NotifyPropertyChanged(string.Empty); + RaiseModified(); + } + + public override bool SupportsRotate => true; + public override bool SupportsScale => true; + public override Vector3 GizmoPosition { get => new(PositionX, PositionY, PositionZ); set => SetPosition(value.X, value.Y, value.Z); } + public override Quaternion GizmoOrientation => TerryTransform.EulerDegreesToQuaternion(new Vector3(RotationXDegrees, RotationYDegrees, RotationZDegrees)); + public override void ApplyTranslateDelta(Vector3 worldDelta) => SetPosition(PositionX + worldDelta.X, PositionY + worldDelta.Y, PositionZ + worldDelta.Z); + public override void ApplyRotateDelta(Vector3 deltaEulerDegrees) => SetRotation(RotationXDegrees + deltaEulerDegrees.X, RotationYDegrees + deltaEulerDegrees.Y, RotationZDegrees + deltaEulerDegrees.Z); + public override void ApplyScaleFactor(float factor) => SetScale(ScaleX * factor, ScaleY * factor, ScaleZ * factor); } public class BattlefieldBuildingFarViewModel(BattlefieldBuildingFar buildingFar) : BmdElementViewModel("Battlefield Building Far", "", $"Version: {buildingFar.Version}") diff --git a/Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs b/Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs deleted file mode 100644 index fe8bd4176..000000000 --- a/Editors/BmdEditor/ViewModels/BmdSceneViewModel.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Windows.Input; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Shared.Core.PackFiles; -using Shared.Core.PackFiles.Models; -using Shared.Core.ToolCreation; -using Shared.GameFormats.Bmd; -using GameWorld.Core.Rendering.Materials; -using GameWorld.Core.Services; -using Editors.BmdEditor.Services; -using Serilog; -using GameWorld.Core.WpfWindow; - -namespace Editors.BmdEditor.ViewModels -{ - public partial class BmdSceneViewModel : ObservableObject, IDisposable - { - private readonly ILogger _logger = Serilog.Log.ForContext(); - private readonly IPackFileService _packFileService; - private readonly IEditorManager _editorCreator; - private readonly ResourceLibrary _resourceLibrary; - private readonly IGraphicsResourceCreator _graphicsResourceCreator; - private readonly MeshBuilderService _meshBuilderService; - private readonly CapabilityMaterialFactory _materialFactory; - private readonly BmdSceneCreator _bmdSceneCreator; - - private BmdFile? _bmdFile; - private IWpfGame? _scene3D; - - [ObservableProperty] string _statusText = "Ready"; - [ObservableProperty] string _sceneInfo = "No scene loaded"; - [ObservableProperty] int _loadedPropsCount = 0; - [ObservableProperty] bool _showGrid = true; - [ObservableProperty] bool _showProps = true; - [ObservableProperty] bool _showLights = true; - - [ObservableProperty] string _displayName = "BMD 3D Scene"; - - public IWpfGame? Scene3D - { - get => _scene3D; - private set => SetProperty(ref _scene3D, value); - } - - public ICommand ResetCameraCommand { get; } - public ICommand ToggleGridCommand { get; } - public ICommand TogglePropsCommand { get; } - public ICommand ToggleLightsCommand { get; } - - public BmdSceneViewModel( - IPackFileService packFileService, - IEditorManager editorCreator, - ResourceLibrary resourceLibrary, - IGraphicsResourceCreator graphicsResourceCreator, - MeshBuilderService meshBuilderService, - CapabilityMaterialFactory materialFactory, - GameWorld.Core.Components.SceneManager sceneManager, - GameWorld.Core.SceneNodes.Rmv2ModelNodeLoader rmv2ModelNodeLoader) - { - _packFileService = packFileService; - _editorCreator = editorCreator; - _resourceLibrary = resourceLibrary; - _graphicsResourceCreator = graphicsResourceCreator; - _meshBuilderService = meshBuilderService; - _materialFactory = materialFactory; - _bmdSceneCreator = new BmdSceneCreator(packFileService, sceneManager, rmv2ModelNodeLoader, resourceLibrary, meshBuilderService); - - ResetCameraCommand = new RelayCommand(ResetCamera); - ToggleGridCommand = new RelayCommand(ToggleGrid); - TogglePropsCommand = new RelayCommand(ToggleProps); - ToggleLightsCommand = new RelayCommand(ToggleLights); - } - - public void LoadBmdFile(BmdFile bmdFile, PackFile packFile) - { - _bmdFile = bmdFile; - DisplayName = packFile.Name; - - try - { - _logger.Information($"Loading BMD file into 3D scene: {packFile.Name}"); - StatusText = "Loading 3D scene..."; - - // Use the BmdSceneCreator to create the scene - _bmdSceneCreator.CreateSceneFromBmd(bmdFile, packFile); - - // Update counts and info - LoadedPropsCount = Math.Min(bmdFile.Props.Count, bmdFile.PropInfos.Count); - UpdateSceneInfo(); - - StatusText = "Scene loaded successfully"; - _logger.Information($"BMD 3D scene loaded successfully with {LoadedPropsCount} props"); - } - catch (Exception ex) - { - _logger.Error($"Failed to load BMD file into 3D scene: {ex.Message}"); - StatusText = "Failed to load scene"; - throw; - } - } - - - private void UpdateSceneInfo() - { - var info = $"Props: {LoadedPropsCount}"; - if (_bmdFile != null) - { - info += $" | Lights: {_bmdFile.PointLights.Count + _bmdFile.SpotLights.Count}"; - info += $" | VFX: {_bmdFile.VfxInfos.Count}"; - } - SceneInfo = info; - } - - private void ResetCamera() - { - // TODO: Reset camera to default position - _logger.Information("Camera reset"); - } - - private void ToggleGrid() - { - ShowGrid = !ShowGrid; - // TODO: Show/hide grid in scene - _logger.Information($"Grid visibility: {ShowGrid}"); - } - - private void ToggleProps() - { - ShowProps = !ShowProps; - // TODO: Show/hide props in scene - _logger.Information($"Props visibility: {ShowProps}"); - } - - private void ToggleLights() - { - ShowLights = !ShowLights; - // TODO: Show/hide lights in scene - _logger.Information($"Lights visibility: {ShowLights}"); - } - - public void Dispose() - { - // Cleanup resources - Scene3D = null; - _bmdFile = null; - GC.SuppressFinalize(this); - } - } -} diff --git a/Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml b/Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml deleted file mode 100644 index 7ccf9513a..000000000 --- a/Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml.cs b/Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml.cs deleted file mode 100644 index 37407c68e..000000000 --- a/Editors/BmdEditor/Views/Bmd3DSceneViewer.xaml.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Windows.Controls; - -namespace Editors.BmdEditor.Views -{ - /// - /// Interaction logic for Bmd3DSceneViewer.xaml - /// - public partial class Bmd3DSceneViewer : UserControl - { - public Bmd3DSceneViewer() - { - InitializeComponent(); - } - } -} diff --git a/Editors/BmdEditor/Views/BmdEditorView.xaml b/Editors/BmdEditor/Views/BmdEditorView.xaml index 5d26ec688..393b2db29 100644 --- a/Editors/BmdEditor/Views/BmdEditorView.xaml +++ b/Editors/BmdEditor/Views/BmdEditorView.xaml @@ -5,7 +5,8 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:vm="clr-namespace:Editors.BmdEditor.ViewModels" xmlns:views="clr-namespace:Editors.BmdEditor.Views" - mc:Ignorable="d" + xmlns:resources="clr-namespace:Shared.EmbeddedResources;assembly=Shared.EmbeddedResources" + mc:Ignorable="d" d:DesignHeight="600" d:DesignWidth="800"> @@ -19,7 +20,47 @@ + + + @@ -31,9 +72,9 @@ - + - + @@ -44,8 +85,11 @@ - + + + + @@ -102,8 +146,384 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Editors/BmdEditor/Views/BmdEditorView.xaml.cs b/Editors/BmdEditor/Views/BmdEditorView.xaml.cs index 174d4497c..8b4e26d22 100644 --- a/Editors/BmdEditor/Views/BmdEditorView.xaml.cs +++ b/Editors/BmdEditor/Views/BmdEditorView.xaml.cs @@ -82,5 +82,12 @@ private void ComponentsTreeView_SelectedItemChanged(object sender, RoutedPropert viewModel?.SelectComponent(selectedElement); } } + + // Details-panel fields bound straight to the underlying domain object (flags, strings, + // enums) don't go through a dedicated view-model property, so they can't call + // RaiseModified() themselves - these mark the file dirty on commit instead. + private void AuxField_LostFocus(object sender, RoutedEventArgs e) => (DataContext as BmdEditorViewModel)?.OnAuxFieldModified(); + private void AuxField_Click(object sender, RoutedEventArgs e) => (DataContext as BmdEditorViewModel)?.OnAuxFieldModified(); + private void AuxField_SelectionChanged(object sender, SelectionChangedEventArgs e) => (DataContext as BmdEditorViewModel)?.OnAuxFieldModified(); } } diff --git a/Editors/BmdEditor/Views/BmdSceneView.xaml b/Editors/BmdEditor/Views/BmdSceneView.xaml deleted file mode 100644 index 691106577..000000000 --- a/Editors/BmdEditor/Views/BmdSceneView.xaml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - -