diff --git a/Anamnesis/Actor/Pages/ActionPage.xaml b/Anamnesis/Actor/Pages/ActionPage.xaml index 8f9462461..1d00ebd1b 100644 --- a/Anamnesis/Actor/Pages/ActionPage.xaml +++ b/Anamnesis/Actor/Pages/ActionPage.xaml @@ -8,9 +8,9 @@ xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf" xmlns:anaMem="clr-namespace:Anamnesis.Memory" xmlns:ana="clr-namespace:Anamnesis.Views" - xmlns:converters="clr-namespace:Anamnesis.Actor.Converters" + xmlns:converters="clr-namespace:Anamnesis.Actor.Converters" xmlns:controls="clr-namespace:Anamnesis.Actor.Controls" - d:DesignHeight="600" + d:DesignHeight="600" d:DesignWidth="1024" DataContextChanged="OnDataContextChanged" Loaded="OnLoaded" @@ -460,15 +460,25 @@ Icon="User" /> - - - - - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/Anamnesis/Actor/Pages/ActionPage.xaml.cs b/Anamnesis/Actor/Pages/ActionPage.xaml.cs index 565911400..8a4d7ef26 100644 --- a/Anamnesis/Actor/Pages/ActionPage.xaml.cs +++ b/Anamnesis/Actor/Pages/ActionPage.xaml.cs @@ -180,7 +180,7 @@ private void OnResumeAll() AnimationService.Instance.SpeedControlEnabled = true; - foreach (var target in TargetService.Instance.PinnedActors) + foreach (var target in TargetService.Instance.PinnedActors.ToList()) { if (target.IsValid && target.Memory != null && target.Memory.IsValid) { @@ -199,7 +199,7 @@ private void OnPauseAll() AnimationService.Instance.SpeedControlEnabled = true; - foreach (var target in TargetService.Instance.PinnedActors) + foreach (var target in TargetService.Instance.PinnedActors.ToList()) { if (target.IsValid && target.Memory != null && target.Memory.IsValid) { diff --git a/Anamnesis/Actor/Pages/PosePage.xaml b/Anamnesis/Actor/Pages/PosePage.xaml index 093d30b81..16deeba8f 100644 --- a/Anamnesis/Actor/Pages/PosePage.xaml +++ b/Anamnesis/Actor/Pages/PosePage.xaml @@ -2,21 +2,17 @@ x:Class="Anamnesis.Actor.Pages.PosePage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:ana="clr-namespace:Anamnesis.Styles.Controls" xmlns:anaUtils="clr-namespace:Anamnesis" xmlns:controls="clr-namespace:Anamnesis.Actor.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:fa="http://schemas.awesome.incremented/wpf/xaml/fontawesome.sharp" - xmlns:local="clr-namespace:Anamnesis.Actor" - xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:views="clr-namespace:Anamnesis.Actor.Views" xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf" - xmlns:views1="clr-namespace:Anamnesis.Actor.Views" d:DesignHeight="512" d:DesignWidth="1024" DataContextChanged="OnDataContextChanged" Loaded="OnLoaded" + Unloaded="OnUnloaded" mc:Ignorable="d"> @@ -57,26 +53,32 @@ + Visibility="{Binding Skeleton.SelectedBones, Converter={StaticResource NotEmptyToVisibilityConverter}}" + IsReadOnly="{Binding IsMultipleBonesSelected}" + Style="{StaticResource MaterialDesignTextBox}"> + + + + + VerticalAlignment="Top" + Visibility="{Binding IsSingleBoneSelected, Converter={StaticResource B2V}}"/> + Visibility="{Binding Skeleton.SelectedBones, Converter={StaticResource IsEmptyToVisibilityConverter}}" /> @@ -89,24 +91,24 @@ Height="22" MinWidth="32" Style="{StaticResource TransparentIconToggleButton}" - IsEnabled="{Binding Skeleton.CurrentBone.LinkedBonesCount, Converter={StaticResource NotZeroToBool}, FallbackValue=False}" - IsChecked="{Binding Skeleton.CurrentBone.EnableLinkedBones}"> + IsEnabled="{Binding Skeleton.SelectedLinkedCount, Mode=OneWay, Converter={StaticResource NotZeroToBool}, FallbackValue=False}" + IsChecked="{Binding Skeleton.SelectedEnableLinkedBones, Mode=OneWay}"> - + - - - @@ -124,7 +126,7 @@ Padding="6,3" Click="OnParentClicked" Style="{StaticResource TransparentIconButton}" - IsEnabled="{Binding Skeleton.CurrentBone.Parent, Converter={StaticResource NotNullToBoolConverter}}" + IsEnabled="{Binding Skeleton.SelectedBonesParents, Mode=OneWay, Converter={StaticResource NotEmptyToVisibilityConverter}}" anaUtils:Behaviours.Tooltip="Pose_SelectParent"> @@ -151,59 +153,29 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + - + @@ -294,7 +266,7 @@ diff --git a/Anamnesis/Actor/Pages/PosePage.xaml.cs b/Anamnesis/Actor/Pages/PosePage.xaml.cs index 870827f50..90a6f9e7f 100644 --- a/Anamnesis/Actor/Pages/PosePage.xaml.cs +++ b/Anamnesis/Actor/Pages/PosePage.xaml.cs @@ -3,7 +3,9 @@ namespace Anamnesis.Actor.Pages; +using Anamnesis.Actor.Posing; using Anamnesis.Actor.Views; +using Anamnesis.Core; using Anamnesis.Files; using Anamnesis.GUI.Dialogs; using Anamnesis.Memory; @@ -12,6 +14,7 @@ namespace Anamnesis.Actor.Pages; using Serilog; using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Linq; using System.Numerics; @@ -19,7 +22,6 @@ namespace Anamnesis.Actor.Pages; using System.Windows; using System.Windows.Controls; using System.Windows.Input; -using System.Windows.Media.Media3D; using XivToolsWpf; using XivToolsWpf.Math3D.Extensions; using CmQuaternion = System.Numerics.Quaternion; @@ -28,12 +30,10 @@ namespace Anamnesis.Actor.Pages; /// Interaction logic for CharacterPoseView.xaml. /// [AddINotifyPropertyChangedInterface] -public partial class PosePage : UserControl +public partial class PosePage : UserControl, INotifyPropertyChanged { public const double DragThreshold = 20; - public HashSet BoneViews = new(); - private static readonly Type[] PoseFileTypes = new[] { typeof(PoseFile), @@ -55,25 +55,29 @@ public partial class PosePage : UserControl private bool isDragging; private Point origMouseDownPoint; - private Task? writeSkeletonTask; + private Task? skeletonUpdateThread; private bool importPoseRotation = true; private bool importPosePosition = true; private bool importPoseScale = true; + private string? selectedBonesTooltipCache; + private string? selectedBoneNameCache; + private string? selectedBoneTextCache; + public PosePage() { this.InitializeComponent(); this.ContentArea.DataContext = this; - HistoryService.OnHistoryApplied += this.OnHistoryApplied; - // Initialize the debounce timer this.refreshDebounceTimer = new(200) { AutoReset = false }; this.refreshDebounceTimer.Elapsed += async (s, e) => { await this.Refresh(); }; } + public event PropertyChangedEventHandler? PropertyChanged; + private enum PoseImportOptions { Character, // (Default option) Imports full pose without positions to avoid deformations due to race bone position differences. @@ -91,7 +95,31 @@ private enum PoseImportOptions public bool IsFlipping { get; private set; } public ActorMemory? Actor { get; private set; } - public SkeletonVisual3d? Skeleton { get; private set; } + public SkeletonEntity? Skeleton { get; private set; } + + public bool IsSingleBoneSelected => this.Skeleton?.SelectedBones.Count() == 1; + public bool IsMultipleBonesSelected => this.Skeleton?.SelectedBones.Count() > 1; + + public string SelectedBonesText + { + get => this.selectedBoneTextCache ?? string.Empty; + set + { + if (this.Skeleton == null) + return; + + // Setter only handles renaming tooltips for single bone selections + // Ignore everything else + var selectedBones = this.Skeleton.SelectedBones.ToList(); + if (selectedBones.Count == 1) + { + selectedBones.First().Tooltip = value; + } + } + } + + public string SelectedBoneName => this.selectedBoneNameCache ?? string.Empty; + public string SelectedBonesTooltip => this.selectedBonesTooltipCache ?? string.Empty; public bool ImportPoseRotation { @@ -137,21 +165,7 @@ public bool ImportPoseScale private static ILogger Log => Serilog.Log.ForContext(); - public List GetBoneViews(BoneVisual3d bone) - { - List results = new List(); - foreach (BoneView boneView in this.BoneViews) - { - if (boneView.Bone == bone) - { - results.Add(boneView); - } - } - - return results; - } - - private void FlipBone(BoneVisual3d? targetBone, bool shouldFlip = true) + private void FlipBone(Bone? targetBone, bool shouldFlip = true) { if (this.Skeleton == null) throw new Exception("Skeleton is null"); @@ -161,16 +175,15 @@ private void FlipBone(BoneVisual3d? targetBone, bool shouldFlip = true) // Save the positions of the target bone and its children // The transform memory is used to retrieve the parent-relative position of the bone - Dictionary bonePositions = new() + Dictionary bonePositions = new() { { targetBone, targetBone.Position }, }; if (PoseService.Instance.EnableParenting) { - List boneChildren = new(); - targetBone.GetChildren(ref boneChildren); - foreach (BoneVisual3d childBone in boneChildren) + List boneChildren = targetBone.GetDescendants(); + foreach (var childBone in boneChildren) { bonePositions.Add(childBone, childBone.Position); } @@ -193,20 +206,20 @@ private void FlipBone(BoneVisual3d? targetBone, bool shouldFlip = true) * - store the quat on the target bone * - recursively flip on all child bones */ - private void FlipBoneInternal(BoneVisual3d? targetBone, bool shouldFlip = true) + private void FlipBoneInternal(Bone? targetBone, bool shouldFlip = true) { - if (targetBone == null) - throw new ArgumentException("The target bone cannot be null"); + if (targetBone == null || targetBone.TransformMemory == null) + throw new ArgumentException("The target bone and its transform memory cannot be null"); - CmQuaternion newRotation = targetBone!.TransformMemory.Rotation.Mirror(); // character-relative transform - if (shouldFlip && targetBone.BoneName.EndsWith("_l")) + CmQuaternion newRotation = targetBone.TransformMemory.Rotation.Mirror(); // character-relative transform + if (shouldFlip && targetBone.Name.EndsWith("_l")) { - string rightBoneString = targetBone.BoneName.Substring(0, targetBone.BoneName.Length - 2) + "_r"; // removes the "_l" and replaces it with "_r" + string rightBoneString = string.Concat(targetBone.Name.AsSpan(0, targetBone.Name.Length - 2), "_r"); // removes the "_l" and replaces it with "_r" /* Useful debug lines to make sure the correct bones are grabbed... * Log.Information("flipping: " + targetBone.BoneName); * Log.Information("right flip target: " + rightBoneString); */ - BoneVisual3d? rightBone = targetBone.Skeleton.GetBone(rightBoneString); - if (rightBone != null) + Bone? rightBone = targetBone.Skeleton.GetBone(rightBoneString); + if (rightBone != null && rightBone.TransformMemory != null) { CmQuaternion rightRot = rightBone.TransformMemory.Rotation.Mirror(); foreach (TransformMemory transformMemory in targetBone.TransformMemories) @@ -225,10 +238,10 @@ private void FlipBoneInternal(BoneVisual3d? targetBone, bool shouldFlip = true) } else { - Log.Warning("could not find right bone of: " + targetBone.BoneName); + Log.Warning("could not find right bone of: " + targetBone.Name); } } - else if (shouldFlip && targetBone.BoneName.EndsWith("_r")) + else if (shouldFlip && targetBone.Name.EndsWith("_r")) { // do nothing so it doesn't revert... } @@ -244,12 +257,9 @@ private void FlipBoneInternal(BoneVisual3d? targetBone, bool shouldFlip = true) if (PoseService.Instance.EnableParenting) { - foreach (Visual3D? child in targetBone.Children) + foreach (var child in targetBone.Children) { - if (child is BoneVisual3d childBone) - { - this.FlipBoneInternal(childBone, shouldFlip); - } + this.FlipBoneInternal(child, shouldFlip); } } } @@ -258,31 +268,45 @@ private void OnLoaded(object sender, RoutedEventArgs e) { this.OnDataContextChanged(null, default); - PoseService.EnabledChanged += this.OnPoseServiceEnabledChanged; - this.PoseService.PropertyChanged += this.PoseService_PropertyChanged; + HistoryService.OnHistoryApplied += this.OnHistoryApplied; + this.PoseService.PropertyChanged += this.OnPoseServicePropertyChanged; } - private void PoseService_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + private void OnUnloaded(object sender, RoutedEventArgs e) { - Application.Current?.Dispatcher.Invoke(() => + this.PoseService.PropertyChanged -= this.OnPoseServicePropertyChanged; + HistoryService.OnHistoryApplied -= this.OnHistoryApplied; + + if (this.Actor?.ModelObject != null) { - this.Skeleton?.Reselect(); - this.Skeleton?.ReadTransforms(); - }); - } + this.Actor.Refreshed -= this.OnActorRefreshed; + this.Actor.ModelObject.PropertyChanged -= this.OnModelObjectChanged; + } - private void OnPoseServiceEnabledChanged(bool value) - { - if (!value) + if (this.Skeleton != null) { - this.OnClearClicked(null, null); + this.Skeleton.PropertyChanged -= this.OnSkeletonPropertyChanged; } - else + + this.Skeleton?.Clear(); + this.Skeleton = null; + } + + private void OnPoseServicePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + // Don't refresh the skeleton if the selected bones text changes to prevent a loop + if (e.PropertyName == nameof(PoseService.SelectedBonesText)) + return; + + this.Skeleton?.Reselect(); + this.Skeleton?.ReadTransforms(); + + if (e.PropertyName == nameof(this.PoseService.IsEnabled)) { - Application.Current?.Dispatcher.Invoke(() => - { - this.Skeleton?.ReadTransforms(); - }); + if (!this.PoseService.IsEnabled) + this.OnClearClicked(null, null); + + BoneViewManager.Instance.Refresh(); } } @@ -335,8 +359,6 @@ private async Task Refresh() this.FaceGuiView.DataContext = null; this.MatrixView.DataContext = null; - this.BoneViews.Clear(); - if (this.Actor == null || this.Actor.ModelObject == null) { this.Skeleton?.Clear(); @@ -346,18 +368,25 @@ private async Task Refresh() try { - SkeletonVisual3d newSkeleton = this.Skeleton ?? new SkeletonVisual3d(); - await newSkeleton.SetActor(this.Actor); - this.Skeleton = newSkeleton; + if (this.Skeleton != null) + { + this.Skeleton.PropertyChanged -= this.OnSkeletonPropertyChanged; + } + + this.Skeleton = new SkeletonEntity(this.Actor); + BoneViewManager.Instance.SetSkeleton(this.Skeleton); + + this.Skeleton.PropertyChanged += this.OnSkeletonPropertyChanged; this.ThreeDView.DataContext = this.Skeleton; this.BodyGuiView.DataContext = this.Skeleton; this.FaceGuiView.DataContext = this.Skeleton; this.MatrixView.DataContext = this.Skeleton; - if (this.writeSkeletonTask == null || this.writeSkeletonTask.IsCompleted) + // Start the skeleton read/write tasks + if (this.skeletonUpdateThread == null || this.skeletonUpdateThread.IsCompleted || this.skeletonUpdateThread.IsFaulted) { - this.writeSkeletonTask = Task.Run(this.WriteSkeletonThread); + this.skeletonUpdateThread = Task.Run(this.SkeletonUpdateThread); } } catch (Exception ex) @@ -366,6 +395,18 @@ private async Task Refresh() } } + private void OnSkeletonPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SkeletonEntity.SelectedBones)) + { + this.RaisePropertyChanged(nameof(this.IsSingleBoneSelected)); + this.RaisePropertyChanged(nameof(this.IsMultipleBonesSelected)); + + this.UpdateSelectedBonesCache(); // Update selected bones text cache + PoseService.SelectedBonesText = this.SelectedBonesTooltip; + } + } + private async void OnImportClicked(object sender, RoutedEventArgs e) { bool isShiftPressed = Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift); @@ -425,7 +466,7 @@ private async Task ImportPose(PoseImportOptions importOption, PoseFile.Mode mode if (result.File is not PoseFile poseFile) return; - Dictionary facePositions = new(); + Dictionary facePositions = new(); bool mismatchedFaceBones = false; // Disable auto-commit at the beginning @@ -462,7 +503,7 @@ private async Task ImportPose(PoseImportOptions importOption, PoseFile.Mode mode if (importOption == PoseImportOptions.SelectedBones) { // Don't unselected bones after import. Let the user decide what to do with the selection. - var selectedBones = this.Skeleton.SelectedBones.Select(bone => bone.BoneName).ToHashSet(); + var selectedBones = this.Skeleton.SelectedBones.Select(bone => bone.Name).ToHashSet(); poseFile.Apply(this.Actor, this.Skeleton, selectedBones, mode, false); return; } @@ -470,7 +511,7 @@ private async Task ImportPose(PoseImportOptions importOption, PoseFile.Mode mode if (importOption == PoseImportOptions.WeaponsOnly) { this.Skeleton.SelectWeapons(); - var selectedBoneNames = this.Skeleton.SelectedBones.Select(bone => bone.BoneName).ToHashSet(); + var selectedBoneNames = this.Skeleton.SelectedBones.Select(bone => bone.Name).ToHashSet(); poseFile.Apply(this.Actor, this.Skeleton, selectedBoneNames, mode, false); this.Skeleton.ClearSelection(); return; @@ -479,14 +520,14 @@ private async Task ImportPose(PoseImportOptions importOption, PoseFile.Mode mode // Backup face bone positions before importing the body pose. // "Freeze Position" toggle resets them, so restore after import. Relevant only when pose service is enabled. this.Skeleton.SelectHead(); - facePositions = this.Skeleton.SelectedBones.ToDictionary(bone => bone, bone => bone.Position); + facePositions = this.Skeleton.SelectedBones.ToDictionary(bone => bone as Bone, bone => bone.Position); this.Skeleton.ClearSelection(); // Step 1: Import body part of the pose if (importOption is PoseImportOptions.Character or PoseImportOptions.FullTransform or PoseImportOptions.BodyOnly) { this.Skeleton.SelectBody(); - var selectedBoneNames = this.Skeleton.SelectedBones.Select(bone => bone.BoneName).ToHashSet(); + var selectedBoneNames = this.Skeleton.SelectedBones.Select(bone => bone.Name).ToHashSet(); this.Skeleton.ClearSelection(); // Don't import body with positions during default pose import. @@ -512,7 +553,7 @@ private async Task ImportPose(PoseImportOptions importOption, PoseFile.Mode mode if (!mismatchedFaceBones && (importOption is PoseImportOptions.Character or PoseImportOptions.FullTransform or PoseImportOptions.ExpressionOnly)) { this.Skeleton.SelectHead(); - var selectedBones = this.Skeleton.SelectedBones.Select(bone => bone.BoneName).ToHashSet(); + var selectedBones = this.Skeleton.SelectedBones.Select(bone => bone.Name).ToHashSet(); this.Skeleton.ClearSelection(); // Pre-DT faces need to be imported without positions. @@ -557,11 +598,17 @@ private async Task ImportPose(PoseImportOptions importOption, PoseFile.Mode mode private async void OnExportClicked(object sender, RoutedEventArgs e) { + if (this.Actor == null || this.Skeleton == null) + return; + lastSaveDir = await PoseFile.Save(lastSaveDir, this.Actor, this.Skeleton); } private async void OnExportMetaClicked(object sender, RoutedEventArgs e) { + if (this.Actor == null || this.Skeleton == null) + return; + lastSaveDir = await PoseFile.Save(lastSaveDir, this.Actor, this.Skeleton, null, true); } @@ -570,10 +617,10 @@ private async void OnExportSelectedClicked(object sender, RoutedEventArgs e) if (this.Skeleton == null) return; - HashSet bones = new HashSet(); - foreach (BoneVisual3d bone in this.Skeleton.SelectedBones) + var bones = new HashSet(); + foreach (Bone bone in this.Skeleton.SelectedBones) { - bones.Add(bone.BoneName); + bones.Add(bone.Name); } lastSaveDir = await PoseFile.Save(lastSaveDir, this.Actor, this.Skeleton, bones); @@ -602,13 +649,13 @@ private void OnSelectChildrenClicked(object sender, RoutedEventArgs e) if (this.Skeleton == null) return; - List bones = new List(); - foreach (BoneVisual3d bone in this.Skeleton.SelectedBones) + var bones = new List(); + foreach (Bone bone in this.Skeleton.SelectedBones) { - bone.GetChildren(ref bones); + bones.AddRange(bone.GetDescendants().Cast()); } - this.Skeleton.Select(bones, SkeletonVisual3d.SelectMode.Add); + this.Skeleton.Select(bones, SkeletonEntity.SelectMode.Add); } private void OnFlipClicked(object sender, RoutedEventArgs e) @@ -639,10 +686,10 @@ private void OnFlipClicked(object sender, RoutedEventArgs e) // If no bone selected, flip both lumbar and waist bones this.IsFlipping = true; - if (this.Skeleton.CurrentBone == null) + if (!this.Skeleton.HasSelection) { - BoneVisual3d? waistBone = this.Skeleton.GetBone("Waist"); - BoneVisual3d? lumbarBone = this.Skeleton.GetBone("SpineA"); + Bone? waistBone = this.Skeleton.GetBone("Waist"); + Bone? lumbarBone = this.Skeleton.GetBone("SpineA"); this.FlipBone(waistBone); this.FlipBone(lumbarBone); waistBone?.ReadTransform(true); @@ -651,17 +698,22 @@ private void OnFlipClicked(object sender, RoutedEventArgs e) else { // If targeted bone is a limb don't switch the respective left and right sides - BoneVisual3d targetBone = this.Skeleton.CurrentBone; - if (targetBone.BoneName.EndsWith("_l") || targetBone.BoneName.EndsWith("_r")) + if (this.Skeleton.SelectedBones.Any(b => b.Name.EndsWith("_l") || b.Name.EndsWith("_r")) == false) { - this.FlipBone(targetBone, false); + foreach (Bone bone in this.Skeleton.SelectedBones) + { + this.FlipBone(bone); + bone.ReadTransform(true); + } } else { - this.FlipBone(targetBone); + foreach (Bone bone in this.Skeleton.SelectedBones) + { + this.FlipBone(bone, false); + bone.ReadTransform(true); + } } - - targetBone.ReadTransform(true); } this.IsFlipping = false; @@ -679,14 +731,21 @@ private void OnFlipClicked(object sender, RoutedEventArgs e) private void OnParentClicked(object sender, RoutedEventArgs e) { - if (this.Skeleton?.CurrentBone?.Parent == null) + // If any of the selected bones have no parent, don't do anything + if (this.Skeleton == null || this.Skeleton.SelectedBones.Any(b => b.Parent == null) == true) return; - this.Skeleton.Select(this.Skeleton.CurrentBone.Parent); + // Select the parents of the selected bones. + // If the selected bone has no parent, reselect the root bone. + var selectedBonesParents = this.Skeleton.SelectedBones.Select(b => b.Parent ?? b).Distinct().ToList(); + this.Skeleton.Select(selectedBonesParents); } private void OnCanvasMouseDown(object sender, MouseButtonEventArgs e) { + if (e.Handled) + return; + if (e.ChangedButton == MouseButton.Left) { this.isLeftMouseButtonDownOnWindow = true; @@ -696,7 +755,7 @@ private void OnCanvasMouseDown(object sender, MouseButtonEventArgs e) private void OnCanvasMouseMove(object sender, MouseEventArgs e) { - if (this.Skeleton == null) + if (e.Handled || this.Skeleton == null) return; Point curMouseDownPoint = e.GetPosition(this.SelectionCanvas); @@ -722,9 +781,9 @@ private void OnCanvasMouseMove(object sender, MouseEventArgs e) this.DragSelectionBorder.Width = maxx - minx; this.DragSelectionBorder.Height = maxy - miny; - List bones = new List(); + var bones = new List(); - foreach (BoneView bone in this.BoneViews) + foreach (BoneView bone in BoneViewManager.Instance.BoneViews) { if (bone.Bone == null) continue; @@ -738,15 +797,13 @@ private void OnCanvasMouseMove(object sender, MouseEventArgs e) Point relativePoint = bone.TransformToAncestor(this.MouseCanvas).Transform(new Point(0, 0)); if (relativePoint.X > minx && relativePoint.X < maxx && relativePoint.Y > miny && relativePoint.Y < maxy) { - this.Skeleton.Hover(bone.Bone, true, false); + this.Skeleton.Hover(bone.Bone, true); } else { this.Skeleton.Hover(bone.Bone, false); } } - - this.Skeleton.NotifyHover(); } else if (this.isLeftMouseButtonDownOnWindow) { @@ -761,9 +818,9 @@ private void OnCanvasMouseMove(object sender, MouseEventArgs e) } } - private void OnCanvasMouseUp(object sender, System.Windows.Input.MouseButtonEventArgs e) + private void OnCanvasMouseUp(object sender, MouseButtonEventArgs e) { - if (!this.isLeftMouseButtonDownOnWindow) + if (e.Handled || !this.isLeftMouseButtonDownOnWindow) return; this.isLeftMouseButtonDownOnWindow = false; @@ -778,9 +835,9 @@ private void OnCanvasMouseUp(object sender, System.Windows.Input.MouseButtonEven double maxx = minx + this.DragSelectionBorder.Width; double maxy = miny + this.DragSelectionBorder.Height; - List toSelect = new List(); + var toSelect = new List(); - foreach (BoneView bone in this.BoneViews) + foreach (BoneView bone in BoneViewManager.Instance.BoneViews) { if (bone.Bone == null) continue; @@ -797,7 +854,7 @@ private void OnCanvasMouseUp(object sender, System.Windows.Input.MouseButtonEven } } - this.Skeleton.Select(toSelect); + this.Skeleton.Select(toSelect.Where(b => b.Bone != null).Select(b => b.Bone!).ToList()); } this.DragSelectionBorder.Visibility = Visibility.Collapsed; @@ -807,35 +864,39 @@ private void OnCanvasMouseUp(object sender, System.Windows.Input.MouseButtonEven { if (this.Skeleton != null && !this.Skeleton.HasHover) { - this.Skeleton.Select(Enumerable.Empty()); + this.Skeleton.Select(Enumerable.Empty()); } } this.MouseCanvas.ReleaseMouseCapture(); } - private async void OnHistoryApplied() + private void OnHistoryApplied() { if (this.Skeleton == null || this.Skeleton.Actor == null) return; - await Dispatch.MainThread(); this.Skeleton.ReadTransforms(); } - private async Task WriteSkeletonThread() + private async Task SkeletonUpdateThread() { - while (Application.Current != null && this.Skeleton != null) + while (this.Skeleton != null) { - await Dispatch.MainThread(); - if (this.Skeleton == null) return; - this.Skeleton.WriteSkeleton(); + // Only update transforms while the pose service is disabled + if (!PoseService.Instance.IsEnabled) + { + this.Skeleton.ReadTransforms(); + } + else + { + this.Skeleton.WriteSkeleton(); + } - // up to 60 times a second - await Task.Delay(16); + await Task.Delay(16); // Up to 60 times a second } } @@ -865,18 +926,47 @@ private PoseFile.Mode GetSecondaryImportOptionMode() return mode; } - private void RestoreBonePositions(Dictionary bonePositions) + private void RestoreBonePositions(Dictionary bonePositions) { if (this.Skeleton == null || bonePositions.Count == 0) return; // Sort the selected bones based on their hierarchy - var sortedBones = SkeletonVisual3d.SortBonesByHierarchy(bonePositions.Keys.ToList()); + var sortedBones = Bone.SortBonesByHierarchy(bonePositions.Keys.ToList()); foreach (var bone in sortedBones) { bone.Position = bonePositions[bone]; - bone.WriteTransform(this.Skeleton, false); + bone.WriteTransform(false); } } + + private void RaisePropertyChanged(string propertyName) + { + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private void UpdateSelectedBonesCache() + { + if (this.Skeleton == null) + return; + + var selectedBones = this.Skeleton.SelectedBones.ToList(); + int count = selectedBones.Count; + + this.selectedBonesTooltipCache = count switch + { + 0 => (this.TargetService.SelectedActor != null) ? this.TargetService.SelectedActor.DisplayName : string.Empty, + 1 => selectedBones.First().Tooltip, + <= 3 => string.Join(", ", selectedBones.Select(b => b.Tooltip)), + _ => string.Join(", ", selectedBones.Take(3).Select(b => b.Tooltip)) + LocalizationService.GetStringFormatted("Pose_SelectedBones_TooltipTrimmed", (count - 3).ToString()) + }; + + this.selectedBoneNameCache = count == 1 ? selectedBones.First().Name : string.Empty; + this.selectedBoneTextCache = count == 1 ? selectedBones.First().Tooltip : LocalizationService.GetStringFormatted("Pose_SelectedBones_MultiSelected", count.ToString()); + + this.RaisePropertyChanged(nameof(this.SelectedBonesTooltip)); + this.RaisePropertyChanged(nameof(this.SelectedBoneName)); + this.RaisePropertyChanged(nameof(this.SelectedBonesText)); + } } diff --git a/Anamnesis/Actor/Posing/BoneEntity.cs b/Anamnesis/Actor/Posing/BoneEntity.cs new file mode 100644 index 000000000..f8bb86a5d --- /dev/null +++ b/Anamnesis/Actor/Posing/BoneEntity.cs @@ -0,0 +1,131 @@ +// © Anamnesis. +// Licensed under the MIT license. + +namespace Anamnesis.Actor.Posing; + +using Anamnesis.Core; +using Anamnesis.Memory; +using Anamnesis.Services; +using PropertyChanged; +using System; +using System.Collections.Generic; + +public enum BoneCategory +{ + Uncategorized, + Body, + Head, + Hair, + Met, + Top, + MainHand, + OffHand, +} + +/// +/// Derived class of that adds additional functionality for UI-based operations. +/// +[AddINotifyPropertyChangedInterface] +public class BoneEntity : Bone +{ + public BoneEntity(SkeletonEntity skeleton, List transformMemories, string name, int partialSkeletonIndex, BoneEntity? parent = null) + : base(skeleton, transformMemories, name, partialSkeletonIndex, parent) + { + this.Skeleton = skeleton; + } + + /// Gets the skeleton that the bone belongs to. + public new SkeletonEntity Skeleton { get; private set; } + + /// Gets the bone's parent. + public new BoneEntity? Parent => base.Parent as BoneEntity; + + /// Gets the bone's category. + /// + /// This is used to categorize bones for display in the user interface. + /// + public BoneCategory Category => this.Name switch + { + _ when this.Name.StartsWith("mh_", StringComparison.Ordinal) => BoneCategory.MainHand, + _ when this.Name.StartsWith("oh_", StringComparison.Ordinal) => BoneCategory.OffHand, + _ when this.Name.Equals("j_ago", StringComparison.Ordinal) || this.Name.Equals("j_kao", StringComparison.Ordinal) => BoneCategory.Body, + _ => this.PartialSkeletonIndex switch + { + 0 => BoneCategory.Body, + 1 => BoneCategory.Head, + 2 => BoneCategory.Hair, + 3 => BoneCategory.Met, + 4 => BoneCategory.Top, + _ => BoneCategory.Uncategorized, + } + }; + + /// + /// Gets or sets a value indicating whether the bone is enabled. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Gets a value indicating whether the bone is selected. + /// + public bool IsSelected { get; internal set; } + + /// + /// Gets a value indicating whether the bone is hovered. + /// + public bool IsHovered { get; internal set; } + + /// Gets the tooltip key for the bone. + public virtual string TooltipKey => "Pose_" + this.Name; + + /// Gets or sets the tooltip for the bone. + public string Tooltip + { + get + { + string? customName = CustomBoneNameService.GetBoneName(this.Name); + if (!string.IsNullOrEmpty(customName)) + return customName; + + string str = LocalizationService.GetString(this.TooltipKey, true); + return string.IsNullOrEmpty(str) ? this.Name : str; + } + set + { + if (string.IsNullOrEmpty(value) || LocalizationService.GetString(this.TooltipKey, true) == value) + { + CustomBoneNameService.SetBoneName(this.Name, null); + } + else + { + CustomBoneNameService.SetBoneName(this.Name, value); + } + } + } + + /// Determines whether any ancestor bone is selected. + /// True if any ancestor bone is selected; otherwise, false. + public bool IsAncestorSelected() + { + for (BoneEntity? current = this; current != null; current = current.Parent) + { + if (current.IsSelected) + return true; + } + + return false; + } + + /// Determines whether any ancestor bone is hovered. + /// True if any ancestor bone is hovered; otherwise, false. + public bool IsAncestorHovered() + { + for (BoneEntity? current = this; current != null; current = current.Parent) + { + if (current.IsHovered) + return true; + } + + return false; + } +} diff --git a/Anamnesis/Actor/Posing/BoneViewManager.cs b/Anamnesis/Actor/Posing/BoneViewManager.cs new file mode 100644 index 000000000..0588c8827 --- /dev/null +++ b/Anamnesis/Actor/Posing/BoneViewManager.cs @@ -0,0 +1,142 @@ +// © Anamnesis. +// Licensed under the MIT license. + +namespace Anamnesis.Actor.Posing; + +using Anamnesis.Actor.Views; +using Anamnesis.Core; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using XivToolsWpf; + +/// Manages all in the application. +/// +/// This is necessary as the UI elements are initialized out of order. +/// +public class BoneViewManager : IDisposable +{ + private static readonly Lazy Lazy = new(() => new BoneViewManager()); + + private readonly HashSet boneViews = new(); + private SkeletonEntity? skeleton; + + private BoneViewManager() + { + } + + /// + /// Gets the singleton instance of the . + /// + public static BoneViewManager Instance => Lazy.Value; + + /// + /// Gets the collection of bone views managed by this instance. + /// + public HashSet BoneViews => this.boneViews; + + /// + /// Disposes the resources used by the . + /// + public void Dispose() + { + if (this.skeleton != null) + this.skeleton.PropertyChanged -= this.OnSkeletonPropertyChanged; + + this.boneViews.Clear(); + + GC.SuppressFinalize(this); + } + + /// + /// Gets a list of all associated with a specific bone. + /// + /// The bone to get views for. + /// A list of bone views associated with the specified bone. + public List GetBoneViews(Bone bone) => this.BoneViews.Where(bv => bv.Bone == bone).ToList(); + + /// + /// Sets the skeleton entity to be managed by this instance. + /// + /// The skeleton entity to set. + public void SetSkeleton(SkeletonEntity? skeleton) + { + if (this.skeleton == skeleton) + return; + + if (this.skeleton != null) + this.skeleton.PropertyChanged -= this.OnSkeletonPropertyChanged; + + this.skeleton = skeleton; + + if (this.skeleton != null) + this.skeleton.PropertyChanged += this.OnSkeletonPropertyChanged; + } + + /// + /// Adds a bone view to the manager. + /// + /// The bone view to add. + /// True if the bone view was added; otherwise, false. + public bool AddBoneView(BoneView boneView) => this.boneViews.Add(boneView); + + /// + /// Removes a bone view from the manager. + /// + /// The bone view to remove. + /// True if the bone view was removed; otherwise, false. + public bool RemoveBoneView(BoneView boneView) => this.boneViews.Remove(boneView); + + /// + /// Refreshes all bone views managed by this instance. + /// + public async void Refresh() + { + await Dispatch.MainThread(); + + foreach (var boneView in this.boneViews) + { + boneView.RedrawSkeleton(); + boneView.UpdateState(); + } + } + + /// + /// Handles property changes in the skeleton entity. + /// + /// The source of the event. + /// The event data. + private async void OnSkeletonPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + Debug.Assert(this.skeleton != null, "Skeleton should not be null. Possible event handler leak"); + + if (e.PropertyName == nameof(SkeletonEntity.FlipSides) || e.PropertyName == nameof(SkeletonEntity.Bones)) + { + await Dispatch.MainThread(); + + foreach (var boneView in this.boneViews) + { + // Bones that haven't loaded in yet won't have a name. Skip them. + if (boneView.CurrentBoneName == null) + continue; + + boneView.SetBone(boneView.CurrentBoneName); + boneView.UpdateState(); + } + + return; + } + + if (e.PropertyName == nameof(SkeletonEntity.SelectedBones) || e.PropertyName == nameof(SkeletonEntity.HoveredBones)) + { + await Dispatch.MainThread(); + + foreach (var boneView in this.boneViews) + { + boneView.UpdateState(); + } + } + } +} diff --git a/Anamnesis/Actor/Posing/LegacyBoneNameConverter.cs b/Anamnesis/Actor/Posing/LegacyBoneNameConverter.cs index e6d5f90a8..3957165cd 100644 --- a/Anamnesis/Actor/Posing/LegacyBoneNameConverter.cs +++ b/Anamnesis/Actor/Posing/LegacyBoneNameConverter.cs @@ -7,8 +7,8 @@ namespace Anamnesis.Posing; public static class LegacyBoneNameConverter { - private static readonly Dictionary ModernToLegacy = new Dictionary(); - private static readonly Dictionary LegacyToModern = new Dictionary() + private static readonly Dictionary ModernToLegacy = new(); + private static readonly Dictionary LegacyToModern = new() { // Body { "Root", "n_root" }, @@ -194,15 +194,13 @@ static LegacyBoneNameConverter() public static string? GetModernName(string legacyName) { - string? name = null; - LegacyToModern.TryGetValue(legacyName, out name); + LegacyToModern.TryGetValue(legacyName, out string? name); return name; } public static string? GetLegacyName(string modernName) { - string? name = null; - ModernToLegacy.TryGetValue(modernName, out name); + ModernToLegacy.TryGetValue(modernName, out string? name); return name; } } diff --git a/Anamnesis/Actor/Posing/Services/PoseService.cs b/Anamnesis/Actor/Posing/Services/PoseService.cs index 4fec79bfd..1dfae144e 100644 --- a/Anamnesis/Actor/Posing/Services/PoseService.cs +++ b/Anamnesis/Actor/Posing/Services/PoseService.cs @@ -37,7 +37,7 @@ public class PoseService : ServiceBase public static event PoseEvent? EnabledChanged; public static event PoseEvent? FreezeWorldPositionsEnabledChanged; - public static string? SelectedBoneName { get; set; } + public static string? SelectedBonesText { get; set; } public bool IsEnabled { diff --git a/Anamnesis/Actor/Posing/SkeletonEntity.cs b/Anamnesis/Actor/Posing/SkeletonEntity.cs new file mode 100644 index 000000000..fd2386f5b --- /dev/null +++ b/Anamnesis/Actor/Posing/SkeletonEntity.cs @@ -0,0 +1,368 @@ +// © Anamnesis. +// Licensed under the MIT license. + +namespace Anamnesis.Actor.Posing; + +using Anamnesis.Actor; +using Anamnesis.Core; +using Anamnesis.Memory; +using Anamnesis.Services; +using PropertyChanged; +using Serilog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Input; + +/// +/// Derived class of that adds additional functionality for UI-based operations. +/// +[AddINotifyPropertyChangedInterface] +public class SkeletonEntity : Skeleton +{ + private List? selectedBonesCache; + private List? hoveredBonesCache; + + /// + /// Initializes a new instance of the class. + /// + /// The actor memory associated with this skeleton. + public SkeletonEntity(ActorMemory actor) + : base(actor) + { + } + + /// Specifies the bone selection mode. + public enum SelectMode + { + /// Override the current selection. + Override, + + /// Add to the current selection. + Add, + + /// + /// Toggle the current selection. + /// If a bone was selected, it will be unselected and vice versa. + /// + Toggle, + } + + /// Gets a value indicating whether the skeleton has equipment bones. + public bool HasEquipmentBones => this.Bones.Values.OfType().Any(b => b.Category == BoneCategory.Met || b.Category == BoneCategory.Top); + + /// Gets a value indicating whether the skeleton has weapon bones. + public bool HasWeaponBones => this.Bones.Values.OfType().Any(b => b.Category == BoneCategory.MainHand || b.Category == BoneCategory.OffHand); + + /// Gets a value indicating whether the skeleton has any selected bones. + public bool HasSelection => this.Bones.Values.OfType().Any(b => b.IsSelected); + + /// Gets a value indicating whether the skeleton has any hovered bones. + public bool HasHover => this.Bones.Values.OfType().Any(b => b.IsHovered); + + /// Gets the selected bones. + public IEnumerable SelectedBones => this.selectedBonesCache ??= this.Bones.Values.OfType().Where(b => b.IsSelected).ToList(); + + /// Gets the hovered bones. + public IEnumerable HoveredBones => this.hoveredBonesCache ??= this.Bones.Values.OfType().Where(b => b.IsHovered).ToList(); + + /// Gets the count of selected linked bones. + public int SelectedLinkedCount => this.SelectedBones.SelectMany(bone => bone.LinkedBones).Distinct().Count(); + + /// Gets a value indicating whether all selected bones have linked bones enabled. + public bool SelectedEnableLinkedBones => this.SelectedBones.All(b => b.EnableLinkedBones); + + /// Gets the selected linked bones. + public IEnumerable SelectedLinkedBones => this.SelectedBones.SelectMany(bone => bone.LinkedBones.OfType()).Distinct(); + + /// Gets the parents of the selected bones. + public IEnumerable SelectedBonesParents => this.SelectedBones.Select(bone => bone.Parent).Where(parent => parent != null).Cast().Distinct(); + + /// + /// Gets or sets a value indicating whether to flip the sides of the pose GUI. + /// + public bool FlipSides + { + get => SettingsService.Current.FlipPoseGuiSides; + set + { + SettingsService.Current.FlipPoseGuiSides = value; + this.RaisePropertyChanged(nameof(this.FlipSides)); + } + } + + /// + /// Returns a list of bones in the skeleton in a depth-first order. + /// + /// The skeleton to traverse. + /// A list of bones in the skeleton in a depth-first order. + public static IEnumerable TraverseSkeleton(SkeletonEntity skeleton) + { + if (skeleton.Bones == null || skeleton.Bones.IsEmpty) + return Enumerable.Empty(); + + Stack stack = new(skeleton.Bones.Values.OfType().Where(b => b.Parent == null).OrderBy(b => b.Name)); + List result = new(stack.Count); + + while (stack.Count > 0) + { + BoneEntity current = stack.Pop(); + result.Add(current); + + foreach (var child in current.Children.OfType().OrderBy(b => b.Name)) + { + stack.Push(child); + } + } + + return result; + } + + /// + public override BoneEntity? GetBone(string name) => base.GetBone(name) as BoneEntity; + + /// Writes the transforms of the selected bones to the skeleton. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void WriteSkeleton() + { + if (this.Actor == null || this.Actor.ModelObject?.Skeleton == null) + return; + + if (this.HasSelection && PoseService.Instance.IsEnabled) + { + lock (HistoryService.Instance.LockObject) + { + try + { + this.Actor.PauseSynchronization = true; + + // Get all selected bones + var selectedBones = this.selectedBonesCache ??= this.Bones.Values.OfType().Where(b => b.IsSelected).ToList(); + + // Filter out bones that are descendants of other selected bones + HashSet ancestorBones = new(); + bool isAncestor; + + foreach (var bone in selectedBones) + { + isAncestor = false; + foreach (var otherBone in selectedBones) + { + if (bone != otherBone && bone.HasAncestor(otherBone)) + { + isAncestor = true; + break; + } + } + + if (!isAncestor) + { + ancestorBones.Add(bone); + } + } + + // Write transforms for all ancestor bones + foreach (var bone in ancestorBones) + bone.WriteTransform(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to write bone transforms"); + this.ClearSelection(); + } + finally + { + this.Actor.PauseSynchronization = false; + } + } + } + } + + /// Selects a bone. + /// The bone to select. + public void Select(BoneEntity bone) + { + if (Application.Current?.Dispatcher == null) + return; + + // Ensure input-related operations are done on the main thread + Application.Current.Dispatcher.Invoke(() => + { + SelectMode mode = SelectMode.Override; + + if (Keyboard.IsKeyDown(Key.LeftCtrl)) + mode = SelectMode.Toggle; + + if (Keyboard.IsKeyDown(Key.LeftShift)) + mode = SelectMode.Add; + + this.Select(new List { bone }, mode); + }); + } + + /// Selects multiple bones. + /// The bones to select. + public void Select(IEnumerable bones) + { + // Ensure input-related operations are done on the main thread + if (Application.Current?.Dispatcher == null) + return; + + Application.Current.Dispatcher.Invoke(() => + { + SelectMode mode = SelectMode.Override; + + if (Keyboard.IsKeyDown(Key.LeftCtrl)) + mode = SelectMode.Toggle; + + if (Keyboard.IsKeyDown(Key.LeftShift)) + mode = SelectMode.Add; + + this.Select(bones, mode); + }); + } + + /// Selects multiple bones with a specified selection mode. + /// The bones to select. + /// The selection mode. + public void Select(IEnumerable bones, SelectMode mode) + { + if (mode == SelectMode.Override) + this.ClearSelection(); + + foreach (var bone in bones) + bone.IsSelected = mode != SelectMode.Toggle || !bone.IsSelected; + + this.InvalidateSelectedBonesCache(); + } + + /// Selects all head bones, including "j_kao". + public void SelectHead() + { + this.ClearSelection(); + + if (this.GetBone("j_kao") is not BoneEntity headBone) + return; + + List headBones = new() { headBone }; + headBones.AddRange(headBone.GetDescendants().Cast()); + + this.Select(headBones, SelectMode.Add); + } + + /// Selects all body bones. + public void SelectBody() + { + this.SelectHead(); + this.InvertSelection(); + + List additionalBones = new(); + if (this.GetBone("j_kao") is BoneEntity headBone) + additionalBones.Add(headBone); + + this.Select(additionalBones, SelectMode.Add); + } + + /// Selects all weapon bones. + public void SelectWeapons() + { + this.ClearSelection(); + var bonesToSelect = this.Bones.Values.OfType() + .Where(b => b.Category == BoneCategory.MainHand || b.Category == BoneCategory.OffHand) + .ToList(); + + if (this.GetBone("n_buki_l") is BoneEntity boneLeft) + bonesToSelect.Add(boneLeft); + + if (this.GetBone("n_buki_r") is BoneEntity boneRight) + bonesToSelect.Add(boneRight); + + this.Select(bonesToSelect, SelectMode.Add); + } + + /// Inverts the current bone selection. + public void InvertSelection() + { + foreach (var bone in this.Bones.Values.OfType()) + bone.IsSelected = !bone.IsSelected; + + this.InvalidateSelectedBonesCache(); + } + + /// Clears the current bone selection. + public void ClearSelection() + { + var selectedBones = this.SelectedBones.ToList(); + if (selectedBones.Count == 0) + return; + + // Unselect all previously selected bones. + foreach (var bone in selectedBones) + bone.IsSelected = false; + + this.InvalidateSelectedBonesCache(); + } + + /// Sets the hover state of the target bone. + /// The bone to hover. + /// The hover state. + public void Hover(BoneEntity bone, bool isHovered = true) + { + if (bone.IsHovered == isHovered) + return; + + bone.IsHovered = isHovered; + + this.InvalidateHoveredBonesCache(); + } + + /// Clears the skeleton, including the selection. + public override void Clear() + { + this.ClearSelection(); + base.Clear(); + } + + /// Reselects the previously selected bones. + public void Reselect() + { + var selection = new List(this.SelectedBones); + this.ClearSelection(); + this.Select(selection); + } + + /// + protected override Bone CreateBone(Skeleton skeleton, List transformMemories, string name, int partialSkeletonIndex) + { + if (skeleton is SkeletonEntity skeletonEntity) + { + return new BoneEntity(skeletonEntity, transformMemories, name, partialSkeletonIndex); + } + else + { + throw new InvalidOperationException("Expected skeleton to be of type SkeletonEntity."); + } + } + + /// Invalidates the selected bones cache. + private void InvalidateSelectedBonesCache() + { + this.selectedBonesCache = null; + this.RaisePropertyChanged(nameof(this.HasSelection)); + this.RaisePropertyChanged(nameof(this.SelectedBones)); + this.RaisePropertyChanged(nameof(this.SelectedLinkedCount)); + this.RaisePropertyChanged(nameof(this.SelectedEnableLinkedBones)); + this.RaisePropertyChanged(nameof(this.SelectedLinkedBones)); + this.RaisePropertyChanged(nameof(this.SelectedBonesParents)); + } + + /// Invalidates the hovered bones cache. + private void InvalidateHoveredBonesCache() + { + this.hoveredBonesCache = null; + this.RaisePropertyChanged(nameof(this.HasHover)); + this.RaisePropertyChanged(nameof(this.HoveredBones)); + } +} diff --git a/Anamnesis/Actor/Posing/Views/BoneView.xaml b/Anamnesis/Actor/Posing/Views/BoneView.xaml index 0ebb4db33..8514b5fb2 100644 --- a/Anamnesis/Actor/Posing/Views/BoneView.xaml +++ b/Anamnesis/Actor/Posing/Views/BoneView.xaml @@ -3,11 +3,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:ana="clr-namespace:Anamnesis.Styles.Controls" xmlns:XivToolsWpf="clr-namespace:XivToolsWpf.Controls;assembly=XivToolsWpf" + mc:Ignorable="d" DataContextChanged="OnDataContextChanged" - mc:Ignorable="d" - Loaded="OnLoaded" Unloaded="OnUnloaded" MouseEnter="OnMouseEnter" MouseLeave="OnMouseLeave" @@ -21,7 +19,7 @@ - + diff --git a/Anamnesis/Actor/Posing/Views/BoneView.xaml.cs b/Anamnesis/Actor/Posing/Views/BoneView.xaml.cs index 5f5264176..7ebc2c9e6 100644 --- a/Anamnesis/Actor/Posing/Views/BoneView.xaml.cs +++ b/Anamnesis/Actor/Posing/Views/BoneView.xaml.cs @@ -3,63 +3,84 @@ namespace Anamnesis.Actor.Views; -using Anamnesis.Actor.Pages; +using Anamnesis.Actor.Posing; using MaterialDesignThemes.Wpf; using Serilog; using System; using System.Collections.Generic; -using System.Threading.Tasks; +using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media; using System.Windows.Shapes; +using XivToolsWpf; using XivToolsWpf.DependencyProperties; -public partial class BoneView : UserControl, IBone +/// +/// Interaction logic for BoneView.xaml. +/// Represents a 2D bone selector for a in the actor skeleton, +/// providing mechanisms for displaying and interacting with it. +/// +public partial class BoneView : UserControl { + /// Dependency property for the label of the bone view. public static readonly IBind LabelDp = Binder.Register(nameof(Label)); + + /// Dependency property for the name of the bone. public static readonly IBind NameDp = Binder.Register(nameof(BoneName)); + + /// Dependency property for the flipped name of the bone. public static readonly IBind FlippedNameDp = Binder.Register(nameof(FlippedBoneName)); - private readonly List linesToChildren = new List(); - private readonly List mouseLinesToChildren = new List(); + private readonly List linesToChildren = new(); + private readonly List mouseLinesToChildren = new(); - private SkeletonVisual3d? skeleton; + private SkeletonEntity? skeleton; + /// + /// Initializes a new instance of the class. + /// public BoneView() { this.InitializeComponent(); this.ContentArea.DataContext = this; - this.BindDataContext(); + + BoneViewManager.Instance.AddBoneView(this); this.IsEnabledChanged += this.OnIsEnabledChanged; } - public BoneVisual3d? Bone { get; private set; } + /// Gets the bone associated with this view. + public BoneEntity? Bone { get; private set; } + /// Gets or sets the label of the bone view. public string Label { get => LabelDp.Get(this); set => LabelDp.Set(this, value); } + /// Gets or sets the name of the bone. public string BoneName { get => NameDp.Get(this); set => NameDp.Set(this, value); } + /// Gets or sets the flipped name of the bone. public string FlippedBoneName { get => FlippedNameDp.Get(this); set => FlippedNameDp.Set(this, value); } - public BoneVisual3d? Visual => this.Bone; - + /// + /// Gets the current name of the bone, considering whether the skeleton is flipped. + /// public string CurrentBoneName { + [MethodImpl(MethodImplOptions.AggressiveInlining)] get { if (this.skeleton == null) @@ -72,59 +93,23 @@ public string CurrentBoneName } } - private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) - { - this.BindDataContext(); - } - - private void OnIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) - { - this.UpdateState(); - } - - private void BindDataContext() - { - try - { - if (this.skeleton != null) - this.skeleton.PropertyChanged -= this.OnSkeletonPropertyChanged; - - if (this.DataContext is SkeletonVisual3d viewModel) - { - this.skeleton = viewModel; - this.SetBone(this.CurrentBoneName); - this.skeleton.PropertyChanged += this.OnSkeletonPropertyChanged; - } - else if (this.DataContext is BoneVisual3d bone) - { - this.skeleton = bone.Skeleton; - this.SetBone(bone); - this.skeleton.PropertyChanged += this.OnSkeletonPropertyChanged; - } - else - { - this.IsEnabled = false; - } - } - catch (Exception ex) - { - this.IsEnabled = false; - this.ToolTip = ex.Message; - Log.Error(ex, "Failed to bind bone view"); - } - } + /// Sets the bone by name. + /// The name of the bone to set. + public void SetBone(string name) => this.SetBone(this.skeleton?.GetBone(name)); - private void OnSkeletonPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + /// Sets the bone. + /// The bone to set. + public void SetBone(BoneEntity? bone) { - bool refreshBone = this.Bone == null || e.PropertyName == nameof(SkeletonVisual3d.FlipSides) || e.PropertyName == nameof(SkeletonVisual3d.AllBones); - - if (refreshBone && this.DataContext is SkeletonVisual3d) - this.SetBone(this.CurrentBoneName); - - this.UpdateState(); + this.Bone = bone; + this.IsEnabled = bone != null; + BoneViewManager.Instance.AddBoneView(this); } - private void DrawSkeleton() + /// + /// Redraws the skeleton, updating the lines connecting instances. + /// + public void RedrawSkeleton() { foreach (Line line in this.linesToChildren) { @@ -146,140 +131,78 @@ private void DrawSkeleton() this.linesToChildren.Clear(); - PosePage? page = this.FindParent(); - if (page == null) + if (!this.IsEnabled || this.Bone?.Parent == null) return; - BoneVisual3d? parent = this.Bone?.Parent; - if (parent != null) + foreach (BoneView childView in BoneViewManager.Instance.GetBoneViews(this.Bone.Parent)) { - List parentViews = page.GetBoneViews(parent); + if (childView.Visibility != Visibility.Visible) + continue; - foreach (BoneView childView in parentViews) - { - if (childView.Visibility != Visibility.Visible) - continue; + var scale = 1.0; - var scale = 1.0; - - // Determine line stroke thickness (if placed inside a Viewbox element) - if (this.Parent is Canvas cvs && cvs.Parent is Viewbox vbox) - { - scale = vbox.ActualWidth / cvs.ActualWidth; - } - - if (this.Parent is Canvas c1 && childView.Parent is Canvas c2 && c1 == c2) - { - Line line = new Line(); - line.SnapsToDevicePixels = true; - line.StrokeThickness = 1 / scale; - line.Stroke = Brushes.Gray; - line.IsHitTestVisible = false; - - // Note: Canvas.GetLeft and Canvas.GetTop return the element corner, including margin, padding, and border. - // Adjust for these properties in coordinate calculations. - double parentWidth = this.ActualWidth + this.Margin.Left + this.Margin.Right + this.Padding.Left + this.Padding.Right + - this.BorderThickness.Right; - double parentHeight = this.ActualHeight + this.Margin.Top + this.Margin.Bottom + this.Padding.Top + this.Padding.Bottom + - this.BorderThickness.Bottom; - double childWidth = childView.ActualWidth + childView.Margin.Left + childView.Margin.Right + childView.Padding.Left + childView.Padding.Right + - childView.BorderThickness.Right; - double childHeight = childView.ActualHeight + childView.Margin.Top + childView.Margin.Bottom + childView.Padding.Top + childView.Padding.Bottom + - childView.BorderThickness.Bottom; - - line.X1 = Canvas.GetLeft(this) + (parentWidth / 2); - line.Y1 = Canvas.GetTop(this) + (parentHeight / 2); - line.X2 = Canvas.GetLeft(childView) + (childWidth / 2); - line.Y2 = Canvas.GetTop(childView) + (childHeight / 2); - - c1.Children.Insert(0, line); - this.linesToChildren.Add(line); - - // A transparent line to make mouse operations easier - Line line2 = new Line(); - line2.StrokeThickness = 25; - line2.Stroke = Brushes.Transparent; - - line2.MouseEnter += childView.OnMouseEnter; - line2.MouseLeave += childView.OnMouseLeave; - line2.MouseUp += childView.OnMouseUp; - - line2.X1 = line.X1; - line2.Y1 = line.Y1; - line2.X2 = line.X2; - line2.Y2 = line.Y2; - - c1.Children.Insert(0, line2); - this.mouseLinesToChildren.Add(line2); - } + // Determine line stroke thickness (if placed inside a Viewbox element) + if (this.Parent is Canvas cvs && cvs.Parent is Viewbox vbox) + { + scale = vbox.ActualWidth / cvs.ActualWidth; } - } - } - private void SetBone(string name) - { - this.SetBone(this.skeleton?.GetBone(name)); - } + double x1 = this.GetCenterX(); + double y1 = this.GetCenterY(); + double x2 = childView.GetCenterX(); + double y2 = childView.GetCenterY(); - private void SetBone(BoneVisual3d? bone) - { - PosePage? page = this.FindParent(); - if (page != null) - page.BoneViews.Add(this); + if (double.IsNaN(x1) || double.IsNaN(y1) || double.IsNaN(x2) || double.IsNaN(y2)) + continue; - this.Bone = bone; - - if (this.Bone != null) - { - this.IsEnabled = true; - - // Wait for all bone views to load, then draw the skeleton - Application.Current.Dispatcher.InvokeAsync(async () => + if (this.Parent is Canvas c1 && childView.Parent is Canvas c2 && c1 == c2) { - await Task.Delay(1); - this.DrawSkeleton(); - this.UpdateState(); - }); - } - else - { - this.IsEnabled = false; - this.UpdateState(); + Line line = new() + { + SnapsToDevicePixels = true, + StrokeThickness = 1 / scale, + Stroke = Brushes.Gray, + IsHitTestVisible = false, + X1 = x1, + Y1 = y1, + X2 = x2, + Y2 = y2, + }; + + c1.Children.Insert(0, line); + this.linesToChildren.Add(line); + + // A transparent line to make mouse operations easier + var line2 = new Line + { + StrokeThickness = 25, + Stroke = Brushes.Transparent, + X1 = x1, + Y1 = y1, + X2 = x2, + Y2 = y2, + }; + + line2.MouseEnter += childView.OnMouseEnter; + line2.MouseLeave += childView.OnMouseLeave; + line2.MouseUp += childView.OnMouseUp; + + c1.Children.Insert(0, line2); + this.mouseLinesToChildren.Add(line2); + } } } - private void OnMouseEnter(object sender, MouseEventArgs e) - { - if (!this.IsEnabled || this.skeleton == null || this.Bone == null) - return; - - this.skeleton.Hover(this.Bone, true); - } - - private void OnMouseLeave(object sender, MouseEventArgs e) - { - if (!this.IsEnabled || this.skeleton == null || this.Bone == null) - return; - - this.skeleton.Hover(this.Bone, false); - } - - private void OnMouseUp(object sender, MouseButtonEventArgs e) - { - if (!this.IsEnabled) - return; - - if (this.skeleton == null || this.Bone == null) - return; - - this.skeleton.Select(this); - } - - private void UpdateState() + /// + /// Updates the state of the bone view, including visual appearance based on + /// selection and hover states. + /// + public void UpdateState() { if (this.Bone == null) { this.ErrorEllipse.Visibility = Visibility.Visible; + this.ForegroundElipse.Visibility = Visibility.Hidden; this.BackgroundElipse.Visibility = Visibility.Collapsed; return; } @@ -287,12 +210,13 @@ private void UpdateState() this.ErrorEllipse.Visibility = Visibility.Collapsed; this.BackgroundElipse.Visibility = Visibility.Visible; - PaletteHelper ph = new PaletteHelper(); + PaletteHelper ph = new(); ITheme theme = ph.GetTheme(); if (!this.IsEnabled || this.skeleton == null) { this.SetState(new SolidColorBrush(Colors.Transparent), 1); + this.ForegroundElipse.Visibility = Visibility.Hidden; this.BackgroundElipse.Opacity = 0.5; this.BackgroundElipse.StrokeThickness = 0; return; @@ -301,10 +225,10 @@ private void UpdateState() this.BackgroundElipse.Opacity = 1; this.BackgroundElipse.StrokeThickness = 1; - bool hovered = this.skeleton.GetIsBoneHovered(this.Bone); - bool selected = this.skeleton.GetIsBoneSelected(this.Bone); - bool parentSelected = this.skeleton.GetIsBoneParentsSelected(this.Bone); - bool parentHovered = this.skeleton.GetIsBoneParentsHovered(this.Bone); + bool hovered = this.Bone.IsHovered; + bool selected = this.Bone.IsSelected; + bool parentSelected = this.Bone.IsAncestorSelected(); + bool parentHovered = this.Bone.IsAncestorHovered(); Color color = parentHovered ? theme.PrimaryMid.Color : theme.BodyLight; double thickness = parentSelected || selected || parentHovered ? 2 : 1; @@ -312,8 +236,7 @@ private void UpdateState() // Scale thickness based on viewbox scale (if applicable) if (this.Parent is Canvas cvs && cvs.Parent is Viewbox vbox) { - var scale = vbox.ActualWidth / cvs.ActualWidth; - thickness /= scale; + thickness /= vbox.ActualWidth / cvs.ActualWidth; } this.ForegroundElipse.Visibility = (selected || hovered) ? Visibility.Visible : Visibility.Hidden; @@ -321,6 +244,94 @@ private void UpdateState() this.SetState(new SolidColorBrush(color), thickness); } + /// Handles the data context changed event. + /// The sender of the event. + /// The event data. + private async void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e) + { + await Dispatch.MainThread(); + + try + { + if (this.DataContext is SkeletonEntity viewModel) + { + this.skeleton = viewModel; + this.SetBone(this.CurrentBoneName); + } + else if (this.DataContext is BoneEntity bone) + { + this.skeleton = bone.Skeleton; + this.SetBone(bone); + } + else + { + this.IsEnabled = false; + } + } + catch (Exception ex) + { + this.IsEnabled = false; + this.ToolTip = ex.Message; + Log.Error(ex, "Failed to bind bone view"); + } + } + + /// Handles the IsEnabledChanged event. + /// The sender of the event. + /// The event data. + private async void OnIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e) + { + await Dispatch.MainThread(); + + this.UpdateState(); + } + + /// Gets the center X coordinate of the bone view. + /// The center X coordinate. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetCenterX() => Canvas.GetLeft(this) + ((this.ActualWidth + this.Margin.Left + this.Margin.Right + this.Padding.Left + this.Padding.Right + this.BorderThickness.Right) / 2); + + /// Gets the center Y coordinate of the bone view. + /// The center Y coordinate. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private double GetCenterY() => Canvas.GetTop(this) + ((this.ActualHeight + this.Margin.Top + this.Margin.Bottom + this.Padding.Top + this.Padding.Bottom + this.BorderThickness.Bottom) / 2); + + /// Handles the MouseEnter event. + /// The sender of the event. + /// The event data. + private void OnMouseEnter(object sender, MouseEventArgs e) + { + if (!this.IsEnabled || this.skeleton == null || this.Bone == null) + return; + + this.skeleton.Hover(this.Bone, true); + } + + /// Handles the MouseLeave event. + /// The sender of the event. + /// The event data. + private void OnMouseLeave(object sender, MouseEventArgs e) + { + if (!this.IsEnabled || this.skeleton == null || this.Bone == null) + return; + + this.skeleton.Hover(this.Bone, false); + } + + /// Handles the MouseUp event. + /// The sender of the event. + /// The event data. + private void OnMouseUp(object sender, MouseButtonEventArgs e) + { + if (!this.IsEnabled || this.skeleton == null || this.Bone == null) + return; + + this.skeleton.Select(this.Bone); + } + + /// Sets the state of the bone view. + /// The stroke brush. + /// The stroke thickness. private void SetState(Brush stroke, double thickness) { this.BackgroundElipse.StrokeThickness = thickness; @@ -332,21 +343,36 @@ private void SetState(Brush stroke, double thickness) } } - private void OnLoaded(object sender, RoutedEventArgs e) + /// Handles the Unloaded event. + /// The sender of the event. + /// The event data. + private void OnUnloaded(object sender, RoutedEventArgs e) { - PosePage? page = this.FindParent(); - if (page == null) - return; + BoneViewManager.Instance.RemoveBoneView(this); - page.BoneViews.Add(this); - } + foreach (Line line in this.mouseLinesToChildren) + { + if (line.Parent is Panel parentPanel) + { + parentPanel.Children.Remove(line); + } - private void OnUnloaded(object sender, RoutedEventArgs e) - { - PosePage? page = this.FindParent(); - if (page == null) - return; + line.MouseEnter -= this.OnMouseEnter; + line.MouseLeave -= this.OnMouseLeave; + line.MouseUp -= this.OnMouseUp; + } + + foreach (Line line in this.linesToChildren) + { + if (line.Parent is Panel parentPanel) + { + parentPanel.Children.Remove(line); + } + } + + this.mouseLinesToChildren.Clear(); + this.linesToChildren.Clear(); - page.BoneViews.Remove(this); + this.IsEnabledChanged -= this.OnIsEnabledChanged; } } diff --git a/Anamnesis/Actor/Posing/Views/Pose3DView.xaml b/Anamnesis/Actor/Posing/Views/Pose3DView.xaml index 6ce0eec40..27cb4877f 100644 --- a/Anamnesis/Actor/Posing/Views/Pose3DView.xaml +++ b/Anamnesis/Actor/Posing/Views/Pose3DView.xaml @@ -2,7 +2,6 @@ x:Class="Anamnesis.Actor.Views.Pose3DView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:ana="clr-namespace:Anamnesis.Styles.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:views="clr-namespace:Anamnesis.Actor.Views" @@ -15,7 +14,6 @@ mc:Ignorable="d"> - @@ -23,22 +21,34 @@ + Background="Transparent" + MouseWheel="OnViewportMouseWheel" + MouseDown="OnViewportMouseDown" + MouseMove="OnViewportMouseMove" + MouseUp="OnViewportMouseUp"> + + - -