From 5039bc666f7ea5f0a3333a29425f865832b0a4b5 Mon Sep 17 00:00:00 2001 From: ww-rm Date: Wed, 18 Jun 2025 00:53:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SpineViewer/Models/PreferenceModel.cs | 36 ---- SpineViewer/Models/SpineObjectConfigModel.cs | 38 ---- SpineViewer/Models/SpineObjectModel.cs | 178 ++++++++++-------- SpineViewer/Models/WorkspaceModel.cs | 57 ++++++ SpineViewer/Resources/Strings/en.xaml | 2 + SpineViewer/Resources/Strings/ja.xaml | 2 + SpineViewer/Resources/Strings/zh.xaml | 2 + SpineViewer/Services/DialogService.cs | 14 +- SpineViewer/Utils/JsonHelper.cs | 88 +++++++++ .../ObservableCollectionWithLock.cs | 2 +- .../StringFormatMultiValueConverter.cs | 4 +- .../Exporters/BaseExporterViewModel.cs | 17 +- .../CustomFFmpegExporterViewModel.cs | 5 +- .../Exporters/FFmpegVideoExporterViewModel.cs | 5 +- .../Exporters/FrameExporterViewModel.cs | 5 +- .../FrameSequenceExporterViewModel.cs | 5 +- .../MainWindow/MainWindowViewModel.cs | 59 ++++-- .../MainWindow/PreferenceViewModel.cs | 25 +-- .../MainWindow/SFMLRendererViewModel.cs | 37 ++++ .../MainWindow/SpineObjectListViewModel.cs | 155 +++++++++++---- .../MainWindow/SpineObjectTabViewModel.cs | 2 +- SpineViewer/Views/MainWindow.xaml | 9 +- 22 files changed, 495 insertions(+), 252 deletions(-) create mode 100644 SpineViewer/Models/WorkspaceModel.cs create mode 100644 SpineViewer/Utils/JsonHelper.cs rename SpineViewer/{Extensions => Utils}/ObservableCollectionWithLock.cs (94%) rename SpineViewer/{Extensions => Utils}/StringFormatMultiValueConverter.cs (88%) diff --git a/SpineViewer/Models/PreferenceModel.cs b/SpineViewer/Models/PreferenceModel.cs index 2a90d8a..afe9d3f 100644 --- a/SpineViewer/Models/PreferenceModel.cs +++ b/SpineViewer/Models/PreferenceModel.cs @@ -80,41 +80,5 @@ namespace SpineViewer.Models private AppLanguage _appLanguage; #endregion - - #region 序列化与反序列 - - /// - /// 保存 Json 文件的格式参数 - /// - private static readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip - }; - - /// - /// 从文件反序列对象, 可能抛出异常 - /// - public static PreferenceModel Deserialize(string path) - { - if (!File.Exists(path)) throw new FileNotFoundException("Preference file not found", path); - var json = File.ReadAllText(path, Encoding.UTF8); - var model = JsonSerializer.Deserialize(json, _jsonOptions); - return model ?? throw new JsonException($"null data in file '{path}'"); - } - - /// - /// 保存至文件, 可能抛出异常 - /// - public void Serialize(string path) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)); - var json = JsonSerializer.Serialize(this, _jsonOptions); - File.WriteAllText(path, json, Encoding.UTF8); - } - - #endregion } } diff --git a/SpineViewer/Models/SpineObjectConfigModel.cs b/SpineViewer/Models/SpineObjectConfigModel.cs index b776704..27a1730 100644 --- a/SpineViewer/Models/SpineObjectConfigModel.cs +++ b/SpineViewer/Models/SpineObjectConfigModel.cs @@ -13,8 +13,6 @@ namespace SpineViewer.Models { public class SpineObjectConfigModel { - public bool IsShown { get; set; } = true; - public bool UsePma { get; set; } public string Physics { get; set; } = ISkeleton.Physics.Update.ToString(); @@ -54,41 +52,5 @@ namespace SpineViewer.Models public bool DebugPoints { get; set; } public bool DebugClippings { get; set; } - - #region 序列化与反序列 - - /// - /// 保存 Json 文件的格式参数 - /// - private static readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip - }; - - /// - /// 从文件反序列对象, 可能抛出异常 - /// - public static SpineObjectConfigModel Deserialize(string path) - { - if (!File.Exists(path)) throw new FileNotFoundException("Config file not found", path); - var json = File.ReadAllText(path, Encoding.UTF8); - var model = JsonSerializer.Deserialize(json, _jsonOptions); - return model ?? throw new JsonException($"null data in file '{path}'"); - } - - /// - /// 保存至文件, 可能抛出异常 - /// - public void Serialize(string path) - { - Directory.CreateDirectory(Path.GetDirectoryName(path)); - var json = JsonSerializer.Serialize(this, _jsonOptions); - File.WriteAllText(path, json, Encoding.UTF8); - } - - #endregion } } diff --git a/SpineViewer/Models/SpineObjectModel.cs b/SpineViewer/Models/SpineObjectModel.cs index a12b474..292a6db 100644 --- a/SpineViewer/Models/SpineObjectModel.cs +++ b/SpineViewer/Models/SpineObjectModel.cs @@ -61,7 +61,6 @@ namespace SpineViewer.Models DebugPoints = _loadOptions.DebugPoints, DebugClippings = _loadOptions.DebugClippings }; - _skins = _spineObject.Data.Skins.Select(v => v.Name).ToImmutableArray(); _slotAttachments = _spineObject.Data.SlotAttachments.ToFrozenDictionary(it => it.Key, it => it.Value.Keys); _animations = _spineObject.Data.Animations.Select(v => v.Name).ToImmutableArray(); @@ -71,6 +70,19 @@ namespace SpineViewer.Models _spineObject.AnimationState.SetAnimation(0, _spineObject.Data.Animations[0], true); } + /// + /// 从工作区配置进行构造 + /// + public SpineObjectModel(SpineObjectWorkspaceConfigModel cfg) + { + _spineObject = new(cfg.SkelPath, cfg.AtlasPath); + _skins = _spineObject.Data.Skins.Select(v => v.Name).ToImmutableArray(); + _slotAttachments = _spineObject.Data.SlotAttachments.ToFrozenDictionary(it => it.Key, it => it.Value.Keys); + _animations = _spineObject.Data.Animations.Select(v => v.Name).ToImmutableArray(); + ObjectConfig = cfg.ObjectConfig; + _isShown = cfg.IsShown; + } + public event EventHandler? SkinStatusChanged; public event EventHandler? SlotAttachmentChanged; @@ -345,99 +357,107 @@ namespace SpineViewer.Models lock (_lock) return _spineObject.GetCurrentBounds(); } - /// - /// 导出参数对象 - /// - public SpineObjectConfigModel Dump() + public SpineObjectConfigModel ObjectConfig { - lock (_lock) + get { - SpineObjectConfigModel config = new() + lock (_lock) { - Scale = Math.Abs(_spineObject.Skeleton.ScaleX), - FlipX = _spineObject.Skeleton.ScaleX < 0, - FlipY = _spineObject.Skeleton.ScaleY < 0, - X = _spineObject.Skeleton.X, - Y = _spineObject.Skeleton.Y, + SpineObjectConfigModel config = new() + { + Scale = Math.Abs(_spineObject.Skeleton.ScaleX), + FlipX = _spineObject.Skeleton.ScaleX < 0, + FlipY = _spineObject.Skeleton.ScaleY < 0, + X = _spineObject.Skeleton.X, + Y = _spineObject.Skeleton.Y, - IsShown = _isShown, - UsePma = _spineObject.UsePma, - Physics = _spineObject.Physics.ToString(), + UsePma = _spineObject.UsePma, + Physics = _spineObject.Physics.ToString(), - DebugTexture = _spineObject.DebugTexture, - DebugBounds = _spineObject.DebugBounds, - DebugBones = _spineObject.DebugBones, - DebugRegions = _spineObject.DebugRegions, - DebugMeshHulls = _spineObject.DebugMeshHulls, - DebugMeshes = _spineObject.DebugMeshes, - DebugBoundingBoxes = _spineObject.DebugBoundingBoxes, - DebugPaths = _spineObject.DebugPaths, - DebugPoints = _spineObject.DebugPoints, - DebugClippings = _spineObject.DebugClippings - }; + DebugTexture = _spineObject.DebugTexture, + DebugBounds = _spineObject.DebugBounds, + DebugBones = _spineObject.DebugBones, + DebugRegions = _spineObject.DebugRegions, + DebugMeshHulls = _spineObject.DebugMeshHulls, + DebugMeshes = _spineObject.DebugMeshes, + DebugBoundingBoxes = _spineObject.DebugBoundingBoxes, + DebugPaths = _spineObject.DebugPaths, + DebugPoints = _spineObject.DebugPoints, + DebugClippings = _spineObject.DebugClippings + }; - config.LoadedSkins.AddRange(_spineObject.Data.Skins.Select(it => it.Name).Where(_spineObject.GetSkinStatus)); + config.LoadedSkins.AddRange(_spineObject.Data.Skins.Select(it => it.Name).Where(_spineObject.GetSkinStatus)); - foreach (var slot in _spineObject.Skeleton.Slots) config.SlotAttachment[slot.Name] = slot.Attachment?.Name; + foreach (var slot in _spineObject.Skeleton.Slots) config.SlotAttachment[slot.Name] = slot.Attachment?.Name; - // XXX: 处理空动画 - config.Animations.AddRange(_spineObject.AnimationState.IterTracks().Select(tr => tr?.Animation.Name)); + // XXX: 处理空动画 + config.Animations.AddRange(_spineObject.AnimationState.IterTracks().Select(tr => tr?.Animation.Name)); - return config; + return config; + } + } + set + { + + lock (_lock) + { + _spineObject.Skeleton.ScaleX = value.Scale; + _spineObject.Skeleton.ScaleY = value.Scale; + OnPropertyChanged(nameof(Scale)); + SetProperty(_spineObject.Skeleton.ScaleX < 0, value.FlipX, v => _spineObject.Skeleton.ScaleX *= -1, nameof(FlipX)); + SetProperty(_spineObject.Skeleton.ScaleY < 0, value.FlipY, v => _spineObject.Skeleton.ScaleY *= -1, nameof(FlipY)); + SetProperty(_spineObject.Skeleton.X, value.X, v => _spineObject.Skeleton.X = v, nameof(X)); + SetProperty(_spineObject.Skeleton.Y, value.Y, v => _spineObject.Skeleton.Y = v, nameof(Y)); + SetProperty(_spineObject.UsePma, value.UsePma, v => _spineObject.UsePma = v, nameof(UsePma)); + SetProperty(_spineObject.Physics, Enum.Parse(value.Physics ?? "Update", true), v => _spineObject.Physics = v, nameof(Physics)); + + foreach (var name in _spineObject.Data.Skins.Select(v => v.Name).Except(value.LoadedSkins)) + if (_spineObject.SetSkinStatus(name, false)) + SkinStatusChanged?.Invoke(this, new(name, false)); + foreach (var name in value.LoadedSkins) + if (_spineObject.SetSkinStatus(name, true)) + SkinStatusChanged?.Invoke(this, new(name, true)); + + foreach (var (slotName, attachmentName) in value.SlotAttachment) + if (_spineObject.SetAttachment(slotName, attachmentName)) + SlotAttachmentChanged?.Invoke(this, new(slotName, attachmentName)); + + // XXX: 处理空动画 + _spineObject.AnimationState.ClearTracks(); + int trackIndex = 0; + foreach (var name in value.Animations) + { + if (!string.IsNullOrEmpty(name)) + _spineObject.AnimationState.SetAnimation(trackIndex, name, true); + AnimationChanged?.Invoke(this, new(trackIndex, name)); + trackIndex++; + } + + SetProperty(_spineObject.DebugTexture, value.DebugTexture, v => _spineObject.DebugTexture = v, nameof(DebugTexture)); + SetProperty(_spineObject.DebugBounds, value.DebugBounds, v => _spineObject.DebugBounds = v, nameof(DebugBounds)); + SetProperty(_spineObject.DebugBones, value.DebugBones, v => _spineObject.DebugBones = v, nameof(DebugBones)); + SetProperty(_spineObject.DebugRegions, value.DebugRegions, v => _spineObject.DebugRegions = v, nameof(DebugRegions)); + SetProperty(_spineObject.DebugMeshHulls, value.DebugMeshHulls, v => _spineObject.DebugMeshHulls = v, nameof(DebugMeshHulls)); + SetProperty(_spineObject.DebugMeshes, value.DebugMeshes, v => _spineObject.DebugMeshes = v, nameof(DebugMeshes)); + SetProperty(_spineObject.DebugBoundingBoxes, value.DebugBoundingBoxes, v => _spineObject.DebugBoundingBoxes = v, nameof(DebugBoundingBoxes)); + SetProperty(_spineObject.DebugPaths, value.DebugPaths, v => _spineObject.DebugPaths = v, nameof(DebugPaths)); + SetProperty(_spineObject.DebugPoints, value.DebugPoints, v => _spineObject.DebugPoints = v, nameof(DebugPoints)); + SetProperty(_spineObject.DebugClippings, value.DebugClippings, v => _spineObject.DebugClippings = v, nameof(DebugClippings)); + } } } - /// - /// 从参数对象加载参数值 - /// - public void Load(SpineObjectConfigModel config) + public SpineObjectWorkspaceConfigModel WorkspaceConfig { - lock (_lock) + get { - _spineObject.Skeleton.ScaleX = config.Scale; - _spineObject.Skeleton.ScaleY = config.Scale; - OnPropertyChanged(nameof(Scale)); - SetProperty(_spineObject.Skeleton.ScaleX < 0, config.FlipX, v => _spineObject.Skeleton.ScaleX *= -1, nameof(FlipX)); - SetProperty(_spineObject.Skeleton.ScaleY < 0, config.FlipY, v => _spineObject.Skeleton.ScaleY *= -1, nameof(FlipY)); - SetProperty(_spineObject.Skeleton.X, config.X, v => _spineObject.Skeleton.X = v, nameof(X)); - SetProperty(_spineObject.Skeleton.Y, config.Y, v => _spineObject.Skeleton.Y = v, nameof(Y)); - - IsShown = config.IsShown; - SetProperty(_spineObject.UsePma, config.UsePma, v => _spineObject.UsePma = v, nameof(UsePma)); - SetProperty(_spineObject.Physics, Enum.Parse(config.Physics ?? "Update", true), v => _spineObject.Physics = v, nameof(Physics)); - - foreach (var name in _spineObject.Data.Skins.Select(v => v.Name).Except(config.LoadedSkins)) - if (_spineObject.SetSkinStatus(name, false)) - SkinStatusChanged?.Invoke(this, new(name, false)); - foreach (var name in config.LoadedSkins) - if (_spineObject.SetSkinStatus(name, true)) - SkinStatusChanged?.Invoke(this, new(name, true)); - - foreach (var (slotName, attachmentName) in config.SlotAttachment) - if (_spineObject.SetAttachment(slotName, attachmentName)) - SlotAttachmentChanged?.Invoke(this, new(slotName, attachmentName)); - - // XXX: 处理空动画 - _spineObject.AnimationState.ClearTracks(); - int trackIndex = 0; - foreach (var name in config.Animations) + return new() { - if (!string.IsNullOrEmpty(name)) - _spineObject.AnimationState.SetAnimation(trackIndex, name, true); - AnimationChanged?.Invoke(this, new(trackIndex, name)); - trackIndex++; - } - - SetProperty(_spineObject.DebugTexture, config.DebugTexture, v => _spineObject.DebugTexture = v, nameof(DebugTexture)); - SetProperty(_spineObject.DebugBounds, config.DebugBounds, v => _spineObject.DebugBounds = v, nameof(DebugBounds)); - SetProperty(_spineObject.DebugBones, config.DebugBones, v => _spineObject.DebugBones = v, nameof(DebugBones)); - SetProperty(_spineObject.DebugRegions, config.DebugRegions, v => _spineObject.DebugRegions = v, nameof(DebugRegions)); - SetProperty(_spineObject.DebugMeshHulls, config.DebugMeshHulls, v => _spineObject.DebugMeshHulls = v, nameof(DebugMeshHulls)); - SetProperty(_spineObject.DebugMeshes, config.DebugMeshes, v => _spineObject.DebugMeshes = v, nameof(DebugMeshes)); - SetProperty(_spineObject.DebugBoundingBoxes, config.DebugBoundingBoxes, v => _spineObject.DebugBoundingBoxes = v, nameof(DebugBoundingBoxes)); - SetProperty(_spineObject.DebugPaths, config.DebugPaths, v => _spineObject.DebugPaths = v, nameof(DebugPaths)); - SetProperty(_spineObject.DebugPoints, config.DebugPoints, v => _spineObject.DebugPoints = v, nameof(DebugPoints)); - SetProperty(_spineObject.DebugClippings, config.DebugClippings, v => _spineObject.DebugClippings = v, nameof(DebugClippings)); + SkelPath = SkelPath, + AtlasPath = AtlasPath, + IsShown = IsShown, + ObjectConfig = ObjectConfig + }; } } diff --git a/SpineViewer/Models/WorkspaceModel.cs b/SpineViewer/Models/WorkspaceModel.cs new file mode 100644 index 0000000..2a42630 --- /dev/null +++ b/SpineViewer/Models/WorkspaceModel.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows.Media; + +namespace SpineViewer.Models +{ + public class WorkspaceModel + { + public RendererWorkspaceConfigModel RendererConfig { get; set; } = new(); + public List LoadedSpineObjects { get; set; } = []; + } + + public class RendererWorkspaceConfigModel + { + public uint ResolutionX { get; set; } = 100; + + public uint ResolutionY { get; set; } = 100; + + public float CenterX { get; set; } + + public float CenterY { get; set; } + + public float Zoom { get; set; } = 1f; + + public float Rotation { get; set; } + + public bool FlipX { get; set; } + + public bool FlipY { get; set; } = true; + + public uint MaxFps { get; set; } = 30; + + public bool ShowAxis { get; set; } = true; + + public Color BackgroundColor { get; set; } + + // TODO: 背景图片 + //public string? BackgroundImagePath { get; set; } + + //public ? BackgroundImageDisplayMode { get; set; } + } + + public class SpineObjectWorkspaceConfigModel + { + public string SkelPath { get; set; } = ""; + public string AtlasPath { get; set; } = ""; + public bool IsShown { get; set; } = true; + public SpineObjectConfigModel ObjectConfig { get; set; } = new(); + } + +} diff --git a/SpineViewer/Resources/Strings/en.xaml b/SpineViewer/Resources/Strings/en.xaml index 2b1301e..cadde50 100644 --- a/SpineViewer/Resources/Strings/en.xaml +++ b/SpineViewer/Resources/Strings/en.xaml @@ -11,6 +11,8 @@ Experimental Features Open... + Open Workspace... + Save Workspace... Preferences Preferences... Exit diff --git a/SpineViewer/Resources/Strings/ja.xaml b/SpineViewer/Resources/Strings/ja.xaml index 50d9bab..83a119a 100644 --- a/SpineViewer/Resources/Strings/ja.xaml +++ b/SpineViewer/Resources/Strings/ja.xaml @@ -11,6 +11,8 @@ 実験機能 開く... + ワークスペースを開く... + ワークスペースを保存... 設定 設定... 終了 diff --git a/SpineViewer/Resources/Strings/zh.xaml b/SpineViewer/Resources/Strings/zh.xaml index 789e986..983b8e4 100644 --- a/SpineViewer/Resources/Strings/zh.xaml +++ b/SpineViewer/Resources/Strings/zh.xaml @@ -11,6 +11,8 @@ 实验性功能 打开... + 打开工作区... + 保存工作区... 首选项 首选项... 退出 diff --git a/SpineViewer/Services/DialogService.cs b/SpineViewer/Services/DialogService.cs index e818f5d..25ac263 100644 --- a/SpineViewer/Services/DialogService.cs +++ b/SpineViewer/Services/DialogService.cs @@ -78,12 +78,12 @@ namespace SpineViewer.Services return false; } - public static bool ShowOpenFileDialog(out string? fileName, string initialDirectory = "", string filter = "All|*.*") + public static bool ShowOpenJsonDialog(out string? fileName, string initialDirectory = "") { var dialog = new OpenFileDialog() { InitialDirectory = initialDirectory, - Filter = filter + Filter = "Json|*.jcfg;*.json|All|*.*" }; if (dialog.ShowDialog() is true) { @@ -94,14 +94,14 @@ namespace SpineViewer.Services return false; } - public static bool ShowSaveFileDialog(ref string? fileName, string initialDirectory = "", string defaultExt = "", string filter = "All|*.*") + public static bool ShowSaveJsonDialog(ref string? fileName, string initialDirectory = "") { - var dialog = new SaveFileDialog() - { + var dialog = new SaveFileDialog() + { FileName = fileName, InitialDirectory = initialDirectory, - DefaultExt = defaultExt, - Filter = filter, + DefaultExt = ".jcfg", + Filter = "Json|*.jcfg;*.json|All|*.*", }; if (dialog.ShowDialog() is true) { diff --git a/SpineViewer/Utils/JsonHelper.cs b/SpineViewer/Utils/JsonHelper.cs new file mode 100644 index 0000000..14fbd6f --- /dev/null +++ b/SpineViewer/Utils/JsonHelper.cs @@ -0,0 +1,88 @@ +using Microsoft.Win32; +using NLog; +using SpineViewer.Models; +using SpineViewer.Services; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; + +namespace SpineViewer.Utils +{ + public static class JsonHelper + { + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + /// + /// 保存 Json 文件的格式参数 + /// + public static JsonSerializerOptions JsonOptions => _jsonOptions; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + /// + /// 从文件反序列对象, 不会抛出异常 + /// + public static bool Deserialize(string path, out T obj) + { + if (!File.Exists(path)) + { + _logger.Error("Json file {0} not found", path); + MessagePopupService.Error($"Json file {path} not found"); + } + else + { + try + { + var json = File.ReadAllText(path, Encoding.UTF8); + var model = JsonSerializer.Deserialize(json, _jsonOptions); + if (model is T m) + { + obj = m; + return true; + } + _logger.Error("Null data in file {0}", path); + MessagePopupService.Error($"Null data in file {path}"); + } + catch (Exception ex) + { + _logger.Error("Failed to read json file {0}, {1}", path, ex.Message); + _logger.Trace(ex.ToString()); + MessagePopupService.Error($"Failed to read json file {path}, {ex.ToString()}"); + } + } + obj = default; + return false; + } + + /// + /// 保存至文件, 不会抛出异常 + /// + public static bool Serialize(T obj, string path) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonSerializer.Serialize(obj, _jsonOptions); + File.WriteAllText(path, json, Encoding.UTF8); + } + catch (Exception ex) + { + _logger.Error("Failed to save json file {0}, {1}", path, ex.Message); + _logger.Trace(ex.ToString()); + MessagePopupService.Error($"Failed to save json file {path}, {ex.ToString()}"); + return false; + } + return true; + } + } +} diff --git a/SpineViewer/Extensions/ObservableCollectionWithLock.cs b/SpineViewer/Utils/ObservableCollectionWithLock.cs similarity index 94% rename from SpineViewer/Extensions/ObservableCollectionWithLock.cs rename to SpineViewer/Utils/ObservableCollectionWithLock.cs index b6a48b6..37dc297 100644 --- a/SpineViewer/Extensions/ObservableCollectionWithLock.cs +++ b/SpineViewer/Utils/ObservableCollectionWithLock.cs @@ -6,7 +6,7 @@ using System.Text; using System.Threading.Tasks; using System.Windows.Data; -namespace SpineViewer.Extensions +namespace SpineViewer.Utils { public class ObservableCollectionWithLock : ObservableCollection { diff --git a/SpineViewer/Extensions/StringFormatMultiValueConverter.cs b/SpineViewer/Utils/StringFormatMultiValueConverter.cs similarity index 88% rename from SpineViewer/Extensions/StringFormatMultiValueConverter.cs rename to SpineViewer/Utils/StringFormatMultiValueConverter.cs index dada843..a3ce857 100644 --- a/SpineViewer/Extensions/StringFormatMultiValueConverter.cs +++ b/SpineViewer/Utils/StringFormatMultiValueConverter.cs @@ -7,7 +7,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Data; -namespace SpineViewer.Extensions +namespace SpineViewer.Utils { public class StringFormatMultiValueConverter : IMultiValueConverter { @@ -16,7 +16,7 @@ namespace SpineViewer.Extensions if (values == null || values.Length <= 0) return DependencyProperty.UnsetValue; - if (App.Current.TryFindResource(parameter) is string format) + if (Application.Current.TryFindResource(parameter) is string format) { return string.Format(culture, format, values); } diff --git a/SpineViewer/ViewModels/Exporters/BaseExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/BaseExporterViewModel.cs index 4baff6e..c078c48 100644 --- a/SpineViewer/ViewModels/Exporters/BaseExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/BaseExporterViewModel.cs @@ -5,6 +5,7 @@ using SFMLRenderer; using Spine; using Spine.Exporters; using SpineViewer.Extensions; +using SpineViewer.Models; using SpineViewer.Resources; using SpineViewer.ViewModels.MainWindow; using System; @@ -134,9 +135,21 @@ namespace SpineViewer.ViewModels.Exporters return null; } - public RelayCommand Cmd_Export => _cmd_Export ??= new(Export_Execute, args => args is not null && args.Count > 0); + public RelayCommand Cmd_Export => _cmd_Export ??= new(Export_Execute, Export_CanExecute); private RelayCommand? _cmd_Export; - protected abstract void Export_Execute(IList? args); + private void Export_Execute(IList? args) + { + if (!Export_CanExecute(args)) return; + Export(args.Cast().ToArray()); + // XXX: 导出途中应该停掉渲染好一些, 让性能专注在导出上 + } + + private bool Export_CanExecute(IList? args) + { + return args is not null && args.Count > 0; + } + + protected abstract void Export(SpineObjectModel[] models); } } diff --git a/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs index 085d426..2b5caa9 100644 --- a/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs @@ -47,11 +47,10 @@ namespace SpineViewer.ViewModels.Exporters return null; } - protected override void Export_Execute(IList? args) + protected override void Export(SpineObjectModel[] models) { - if (args is null || args.Count <= 0) return; if (!DialogService.ShowCustomFFmpegExporterDialog(this)) return; - SpineObject[] spines = args.Cast().Select(m => m.GetSpineObject()).ToArray(); + SpineObject[] spines = models.Select(m => m.GetSpineObject()).ToArray(); ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_CustomFFmpegExporterTitle); foreach (var sp in spines) sp.Dispose(); } diff --git a/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs index 3ed8111..d206531 100644 --- a/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs @@ -34,11 +34,10 @@ namespace SpineViewer.ViewModels.Exporters private string FormatSuffix => $".{_format.ToString().ToLower()}"; - protected override void Export_Execute(IList? args) + protected override void Export(SpineObjectModel[] models) { - if (args is null || args.Count <= 0) return; if (!DialogService.ShowFFmpegVideoExporterDialog(this)) return; - SpineObject[] spines = args.Cast().Select(m => m.GetSpineObject()).ToArray(); + SpineObject[] spines = models.Select(m => m.GetSpineObject()).ToArray(); ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FFmpegVideoExporterTitle); foreach (var sp in spines) sp.Dispose(); } diff --git a/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs index 2f3d9aa..d0e752c 100644 --- a/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs @@ -38,11 +38,10 @@ namespace SpineViewer.ViewModels.Exporters } } - protected override void Export_Execute(IList? args) + protected override void Export(SpineObjectModel[] models) { - if (args is null || args.Count <= 0) return; if (!DialogService.ShowFrameExporterDialog(this)) return; - SpineObject[] spines = args.Cast().Select(m => m.GetSpineObject(true)).ToArray(); + SpineObject[] spines = models.Select(m => m.GetSpineObject(true)).ToArray(); ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FrameExporterTitle); foreach (var sp in spines) sp.Dispose(); } diff --git a/SpineViewer/ViewModels/Exporters/FrameSequenceExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/FrameSequenceExporterViewModel.cs index 81483ac..add965f 100644 --- a/SpineViewer/ViewModels/Exporters/FrameSequenceExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/FrameSequenceExporterViewModel.cs @@ -17,11 +17,10 @@ namespace SpineViewer.ViewModels.Exporters { public class FrameSequenceExporterViewModel(MainWindowViewModel vmMain) : VideoExporterViewModel(vmMain) { - protected override void Export_Execute(IList? args) + protected override void Export(SpineObjectModel[] models) { - if (args is null || args.Count <= 0) return; if (!DialogService.ShowFrameSequenceExporterDialog(this)) return; - SpineObject[] spines = args.Cast().Select(m => m.GetSpineObject()).ToArray(); + SpineObject[] spines = models.Select(m => m.GetSpineObject()).ToArray(); ProgressService.RunAsync((pr, ct) => ExportTask(spines, pr, ct), AppResource.Str_FrameSequenceExporterTitle); foreach (var sp in spines) sp.Dispose(); } diff --git a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs index 04f8b81..e5155ed 100644 --- a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs @@ -1,22 +1,10 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using HandyControl.Controls; using NLog; using SFMLRenderer; -using Spine; -using Spine.Exporters; -using SpineViewer.Extensions; using SpineViewer.Models; using SpineViewer.Services; -using SpineViewer.ViewModels.Exporters; -using System; -using System.Collections; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.IO; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; +using SpineViewer.Utils; using System.Windows.Shell; namespace SpineViewer.ViewModels.MainWindow @@ -84,6 +72,34 @@ namespace SpineViewer.ViewModels.MainWindow public SFMLRendererViewModel SFMLRendererViewModel => _sfmlRendererViewModel; private readonly SFMLRendererViewModel _sfmlRendererViewModel; + /// + /// 打开工作区 + /// + public RelayCommand Cmd_OpenWorkspace => _cmd_OpenWorkspace ??= new(OpenWorkspace_Execute); + private RelayCommand? _cmd_OpenWorkspace; + + private void OpenWorkspace_Execute() + { + if (!DialogService.ShowOpenJsonDialog(out var fileName)) return; + if (JsonHelper.Deserialize(fileName, out var obj)) + { + Workspace = obj; + } + } + + /// + /// 保存工作区 + /// + public RelayCommand Cmd_SaveWorkspace => _cmd_SaveWorkspace ??= new(SaveWorkspace_Execute); + private RelayCommand? _cmd_SaveWorkspace; + + private void SaveWorkspace_Execute() + { + string fileName = "workspace.jcfg"; + if (!DialogService.ShowSaveJsonDialog(ref fileName)) return; + JsonHelper.Serialize(Workspace, fileName); + } + /// /// 显示诊断信息对话框 /// @@ -96,6 +112,23 @@ namespace SpineViewer.ViewModels.MainWindow public RelayCommand Cmd_ShowAboutDialog => _cmd_ShowAboutDialog ??= new(() => { DialogService.ShowAboutDialog(); }); private RelayCommand? _cmd_ShowAboutDialog; + public WorkspaceModel Workspace + { + get + { + return new() + { + RendererConfig = _sfmlRendererViewModel.WorkspaceConfig, + LoadedSpineObjects = _spineObjectListViewModel.LoadedSpineObjects + }; + } + set + { + _sfmlRendererViewModel.WorkspaceConfig = value.RendererConfig; + _spineObjectListViewModel.LoadedSpineObjects = value.LoadedSpineObjects; + } + } + /// /// 调试命令 /// diff --git a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs index 257662b..87377ba 100644 --- a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs @@ -4,6 +4,7 @@ using NLog; using Spine.SpineWrappers; using SpineViewer.Models; using SpineViewer.Services; +using SpineViewer.Utils; using System; using System.Collections.Generic; using System.Collections.Immutable; @@ -49,15 +50,7 @@ namespace SpineViewer.ViewModels.MainWindow private static void SavePreference(PreferenceModel m) { - try - { - m.Serialize(PreferenceFilePath); - } - catch (Exception ex) - { - _logger.Error("Failed to save preference to {0}, {1}", PreferenceFilePath, ex.Message); - _logger.Trace(ex.ToString()); - } + JsonHelper.Serialize(m, PreferenceFilePath); } /// @@ -70,18 +63,8 @@ namespace SpineViewer.ViewModels.MainWindow /// public void LoadPreference() { - if (!File.Exists(PreferenceFilePath)) return; - - try - { - var m = PreferenceModel.Deserialize(PreferenceFilePath); - Preference = m; - } - catch (Exception ex) - { - _logger.Error("Failed to load preference from {0}, {1}", PreferenceFilePath, ex.Message); - _logger.Trace(ex.ToString()); - } + if (JsonHelper.Deserialize(PreferenceFilePath, out var obj)) + Preference = obj; } /// diff --git a/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs b/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs index 7125d4d..54a7d60 100644 --- a/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs @@ -7,6 +7,7 @@ using SpineViewer.Extensions; using SpineViewer.Models; using SpineViewer.Resources; using SpineViewer.Services; +using SpineViewer.Utils; using System; using System.Collections.Generic; using System.Collections.Specialized; @@ -423,5 +424,41 @@ namespace SpineViewer.ViewModels.MainWindow _renderer.SetActive(false); } } + + public RendererWorkspaceConfigModel WorkspaceConfig + { + // TODO: 背景图片 + get + { + return new() + { + ResolutionX = ResolutionX, + ResolutionY = ResolutionY, + CenterX = CenterX, + CenterY = CenterY, + Zoom = Zoom, + Rotation = Rotation, + FlipX = FlipX, + FlipY = FlipY, + MaxFps = MaxFps, + ShowAxis = ShowAxis, + BackgroundColor = BackgroundColor, + }; + } + set + { + ResolutionX = value.ResolutionX; + ResolutionY = value.ResolutionY; + CenterX = value.CenterX; + CenterY = value.CenterY; + Zoom = value.Zoom; + Rotation = value.Rotation; + FlipX = value.FlipX; + FlipY = value.FlipY; + MaxFps = value.MaxFps; + ShowAxis = value.ShowAxis; + BackgroundColor = value.BackgroundColor; + } + } } } diff --git a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs index 135b850..d058952 100644 --- a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs @@ -6,6 +6,7 @@ using SpineViewer.Extensions; using SpineViewer.Models; using SpineViewer.Resources; using SpineViewer.Services; +using SpineViewer.Utils; using SpineViewer.ViewModels.Exporters; using System; using System.Collections; @@ -100,7 +101,7 @@ namespace SpineViewer.ViewModels.MainWindow private void AddSpineObject_Execute() { - throw new NotImplementedException(); + MessagePopupService.Info("Not Implemented, try next version :)"); } /// @@ -170,7 +171,7 @@ namespace SpineViewer.ViewModels.MainWindow try { var spNew = new SpineObjectModel(sp.SkelPath, sp.AtlasPath); - spNew.Load(sp.Dump()); + spNew.ObjectConfig = sp.ObjectConfig; _spineObjectModels[idx] = spNew; sp.Dispose(); } @@ -224,7 +225,7 @@ namespace SpineViewer.ViewModels.MainWindow try { var spNew = new SpineObjectModel(sp.SkelPath, sp.AtlasPath); - spNew.Load(sp.Dump()); + spNew.ObjectConfig = sp.ObjectConfig; _spineObjectModels[idx] = spNew; sp.Dispose(); success++; @@ -312,7 +313,7 @@ namespace SpineViewer.ViewModels.MainWindow { if (!CopySpineObjectConfig_CanExecute(args)) return; var sp = (SpineObjectModel)args[0]; - _copiedSpineObjectConfigModel = sp.Dump(); + _copiedSpineObjectConfigModel = sp.ObjectConfig; _logger.Info("Copy config from model: {0}", sp.Name); } @@ -334,7 +335,7 @@ namespace SpineViewer.ViewModels.MainWindow if (!ApplySpineObjectConfig_CanExecute(args)) return; foreach (SpineObjectModel sp in args) { - sp.Load(_copiedSpineObjectConfigModel); + sp.ObjectConfig = _copiedSpineObjectConfigModel; _logger.Info("Apply config to model: {0}", sp.Name); } } @@ -353,22 +354,15 @@ namespace SpineViewer.ViewModels.MainWindow private void ApplySpineObjectConfigFromFile_Execute(IList? args) { if (!ApplySpineObjectConfigFromFile_CanExecute(args)) return; - if (!DialogService.ShowOpenFileDialog(out var fileName, filter: "Json Config|*.jcfg|All|*.*")) return; - try + if (!DialogService.ShowOpenJsonDialog(out var fileName)) return; + if (JsonHelper.Deserialize(fileName, out var config)) { - var config = SpineObjectConfigModel.Deserialize(fileName); foreach (SpineObjectModel sp in args) { - sp.Load(config); + sp.ObjectConfig = config; _logger.Info("Apply config to model: {0}", sp.Name); } } - catch (Exception ex) - { - _logger.Error("Failed to apply config file {0}, {1}", fileName, ex.Message); - _logger.Trace(ex.ToString()); - return; - } } private bool ApplySpineObjectConfigFromFile_CanExecute(IList? args) @@ -385,27 +379,11 @@ namespace SpineViewer.ViewModels.MainWindow { if (!SaveSpineObjectConfigToFile_CanExecute(args)) return; var sp = (SpineObjectModel)args[0]; - var config = sp.Dump(); + var config = sp.ObjectConfig; string fileName = $"{Path.ChangeExtension(Path.GetFileName(sp.SkelPath), ".jcfg")}"; - if (!DialogService.ShowSaveFileDialog( - ref fileName, - initialDirectory: sp.AssetsDir, - defaultExt: ".jcfg", - filter:"Json Config|*.jcfg|All|*.*") - ) - return; - try - { - sp.Dump().Serialize(fileName); - _logger.Info("{0} config save to {1}", sp.Name, fileName); - } - catch (Exception ex) - { - _logger.Error("Failed to save config file {0}, {1}", fileName, ex.Message); - _logger.Trace(ex.ToString()); - return; - } + if (!DialogService.ShowSaveJsonDialog(ref fileName, sp.AssetsDir)) return; + JsonHelper.Serialize(sp.ObjectConfig, fileName); } private bool SaveSpineObjectConfigToFile_CanExecute(IList? args) @@ -505,7 +483,7 @@ namespace SpineViewer.ViewModels.MainWindow /// 安全地在末尾添加一个模型, 发生错误会输出日志 /// /// 是否添加成功 - public bool AddSpineObject(string skelPath, string? atlasPath = null) + private bool AddSpineObject(string skelPath, string? atlasPath = null) { try { @@ -520,5 +498,112 @@ namespace SpineViewer.ViewModels.MainWindow } return false; } + + private bool AddSpineObject(SpineObjectWorkspaceConfigModel cfg) + { + try + { + var sp = new SpineObjectModel(cfg); + lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp); + return true; + } + catch (Exception ex) + { + _logger.Trace(ex.ToString()); + _logger.Error("Failed to load: {0}, {1}", cfg.SkelPath, ex.Message); + } + return false; + } + + public List LoadedSpineObjects + { + get + { + List loadedSpineObjects = []; + lock (_spineObjectModels.Lock) + { + foreach (var sp in _spineObjectModels) + { + loadedSpineObjects.Add(sp.WorkspaceConfig); + } + } + return loadedSpineObjects; + } + set + { + AddSpineObjectFromWorkspaceList(value); + } + } + + private void AddSpineObjectFromWorkspaceList(List models) + { + lock (_spineObjectModels.Lock) + { + var spines = _spineObjectModels.ToArray(); + _spineObjectModels.Clear(); + foreach (var sp in spines) + { + sp.Dispose(); + } + } + + if (models.Count > 1) + { + ProgressService.RunAsync((pr, ct) => AddSpineObjectFromWorkspaceListTask( + models, pr, ct), + AppResource.Str_AddSpineObjectsTitle + ); + } + else if (models.Count > 0) + { + AddSpineObject(models[0]); + _logger.LogCurrentProcessMemoryUsage(); + } + } + + private void AddSpineObjectFromWorkspaceListTask(List models, IProgressReporter reporter, CancellationToken ct) + { + int totalCount = models.Count; + int success = 0; + int error = 0; + + _vmMain.ProgressState = TaskbarItemProgressState.Normal; + _vmMain.ProgressValue = 0; + + reporter.Total = totalCount; + reporter.Done = 0; + reporter.ProgressText = $"[0/{totalCount}]"; + for (int i = 0; i < totalCount; i++) + { + if (ct.IsCancellationRequested) break; + + var cfg = models[i]; + reporter.ProgressText = $"[{i}/{totalCount}] {cfg}"; + + if (AddSpineObject(cfg)) + success++; + else + error++; + + reporter.Done = i + 1; + reporter.ProgressText = $"[{i + 1}/{totalCount}] {cfg}"; + _vmMain.ProgressValue = (i + 1f) / totalCount; + } + _vmMain.ProgressState = TaskbarItemProgressState.None; + + if (error > 0) + _logger.Warn("Batch load {0} successfully, {1} failed", success, error); + else + _logger.Info("{0} skel loaded successfully", success); + + _logger.LogCurrentProcessMemoryUsage(); + + // 从工作区加载需要同步一次时间轴 + lock (_spineObjectModels.Lock) + { + foreach (var sp in _spineObjectModels) + sp.ResetAnimationsTime(); + } + } } } diff --git a/SpineViewer/ViewModels/MainWindow/SpineObjectTabViewModel.cs b/SpineViewer/ViewModels/MainWindow/SpineObjectTabViewModel.cs index 6b24919..19fb81a 100644 --- a/SpineViewer/ViewModels/MainWindow/SpineObjectTabViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SpineObjectTabViewModel.cs @@ -786,7 +786,7 @@ namespace SpineViewer.ViewModels.MainWindow { get { - /// XXX: 空轨道和多选不相同都会返回 null + // XXX: 空轨道和多选不相同都会返回 null if (_spines.Length <= 0) return null; var val = _spines[0].GetAnimation(_trackIndex); if (_spines.Skip(1).Any(it => it.GetAnimation(_trackIndex) != val)) return null; diff --git a/SpineViewer/Views/MainWindow.xaml b/SpineViewer/Views/MainWindow.xaml index 3462723..b9db125 100644 --- a/SpineViewer/Views/MainWindow.xaml +++ b/SpineViewer/Views/MainWindow.xaml @@ -6,7 +6,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:hc="https://handyorg.github.io/handycontrol" xmlns:vm="clr-namespace:SpineViewer.ViewModels.MainWindow" - xmlns:ext="clr-namespace:SpineViewer.Extensions" + xmlns:utils="clr-namespace:SpineViewer.Utils" xmlns:SFMLRenderer="clr-namespace:SFMLRenderer;assembly=SFMLRenderer" mc:Ignorable="d" Title="{Binding Title}" @@ -35,7 +35,7 @@ - + @@ -56,7 +56,8 @@ - + + @@ -244,7 +245,6 @@ - @@ -257,7 +257,6 @@