From e4d655012b8db40d530e6635616d205ce112e740 Mon Sep 17 00:00:00 2001 From: jhq223 Date: Wed, 15 Oct 2025 03:35:15 +0800 Subject: [PATCH 01/13] feat: Add --skin and --hide-slot CLI arguments --- SpineViewerCLI/SpineViewerCLI.cs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/SpineViewerCLI/SpineViewerCLI.cs b/SpineViewerCLI/SpineViewerCLI.cs index 2c91c5e..284f1db 100644 --- a/SpineViewerCLI/SpineViewerCLI.cs +++ b/SpineViewerCLI/SpineViewerCLI.cs @@ -10,13 +10,15 @@ namespace SpineViewerCLI public class CLI { const string USAGE = @" -usage: SpineViewerCLI.exe [--skel PATH] [--atlas PATH] [--output PATH] [--animation STR] [--pma] [--fps INT] [--loop] [--crf INT] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet] +usage: SpineViewerCLI.exe [--skel PATH] [--atlas PATH] [--output PATH] [--animation STR] [--skin STR] [--hide-slot STR] [--pma] [--fps INT] [--loop] [--crf INT] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet] options: --skel PATH Path to the .skel file --atlas PATH Path to the .atlas file, default searches in the skel file directory --output PATH Output file path --animation STR Animation name + --skin STR Skin name to apply. Can be used multiple times to stack skins. + --hide-slot STR Slot name to hide. Can be used multiple times. --pma Use premultiplied alpha, default false --fps INT Frames per second, default 24 --loop Whether to loop the animation, default false @@ -37,6 +39,8 @@ options: string? atlasPath = null; string? output = null; string? animation = null; + var skins = new List(); + var hideSlots = new List(); bool pma = false; uint fps = 24; bool loop = false; @@ -70,6 +74,12 @@ options: case "--animation": animation = args[++i]; break; + case "--skin": + skins.Add(args[++i]); + break; + case "--hide-slot": + hideSlots.Add(args[++i]); + break; case "--pma": pma = true; break; @@ -133,6 +143,24 @@ options: var sp = new SpineObject(skelPath, atlasPath); sp.UsePma = pma; + foreach (var skinName in skins) + { + if (!sp.SetSkinStatus(skinName, true)) + { + var availableSkins = string.Join(", ", sp.Data.Skins.Select(s => s.Name)); + Console.Error.WriteLine($"Error: Skin '{skinName}' not found. Available skins: {availableSkins}"); + Environment.Exit(2); + } + } + + foreach (var slotName in hideSlots) + { + if (!sp.SetSlotVisible(slotName, false)) + { + if (!quiet) Console.WriteLine($"Warning: Slot '{slotName}' not found, cannot hide."); + } + } + if (string.IsNullOrEmpty(animation)) { var availableAnimations = string.Join(", ", sp.Data.Animations); From 03c599264ea5aacf29fff31e0ec43360a8a9a73e Mon Sep 17 00:00:00 2001 From: jhq223 Date: Thu, 16 Oct 2025 19:56:13 +0800 Subject: [PATCH 02/13] feat(cli): Add single-frame image export Extends the CLI to support exporting single frames as images (.png, .jpg, etc.) in addition to video. The export logic now determines the output type based on the file extension of the `--output` path. - Adds new arguments: `--time` to specify the frame and `--quality` for image compression. - Uses `FrameExporter` for recognized image formats. - Updates the help message with the new options. --- SpineViewerCLI/SpineViewerCLI.cs | 156 +++++++++++++++++++++++-------- 1 file changed, 115 insertions(+), 41 deletions(-) diff --git a/SpineViewerCLI/SpineViewerCLI.cs b/SpineViewerCLI/SpineViewerCLI.cs index 284f1db..6f531cb 100644 --- a/SpineViewerCLI/SpineViewerCLI.cs +++ b/SpineViewerCLI/SpineViewerCLI.cs @@ -4,31 +4,34 @@ using SFML.Graphics; using SFML.System; using Spine; using Spine.Exporters; +using SkiaSharp; namespace SpineViewerCLI { public class CLI { const string USAGE = @" -usage: SpineViewerCLI.exe [--skel PATH] [--atlas PATH] [--output PATH] [--animation STR] [--skin STR] [--hide-slot STR] [--pma] [--fps INT] [--loop] [--crf INT] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet] +usage: SpineViewerCLI.exe [--skel PATH] [--atlas PATH] [--output PATH] [--animation STR] [--skin STR] [--hide-slot STR] [--pma] [--fps INT] [--loop] [--crf INT] [--time FLOAT] [--quality INT] [--width INT] [--height INT] [--centerx INT] [--centery INT] [--zoom FLOAT] [--speed FLOAT] [--color HEX] [--quiet] options: --skel PATH Path to the .skel file --atlas PATH Path to the .atlas file, default searches in the skel file directory - --output PATH Output file path + --output PATH Output file path. Extension determines export type (.mp4, .webm for video; .png, .jpg for frame) --animation STR Animation name --skin STR Skin name to apply. Can be used multiple times to stack skins. --hide-slot STR Slot name to hide. Can be used multiple times. --pma Use premultiplied alpha, default false - --fps INT Frames per second, default 24 - --loop Whether to loop the animation, default false - --crf INT Constant Rate Factor i.e. video quality, from 0 (lossless) to 51 (worst), default 23 + --fps INT Frames per second (for video), default 24 + --loop Whether to loop the animation (for video), default false + --crf INT Constant Rate Factor (for video), from 0 (lossless) to 51 (worst), default 23 + --time FLOAT Time in seconds to export a single frame, default 0 + --quality INT Quality for lossy image formats (jpg, webp), from 0 to 100, default 80 --width INT Output width, default 512 --height INT Output height, default 512 --centerx INT Center X offset, default automatically finds bounds --centery INT Center Y offset, default automatically finds bounds --zoom FLOAT Zoom level, default 1.0 - --speed FLOAT Speed of animation, default 1.0 + --speed FLOAT Speed of animation (for video), default 1.0 --color HEX Background color as a hex RGBA color, default 000000ff (opaque black) --quiet Removes console progress log, default false "; @@ -45,6 +48,8 @@ options: uint fps = 24; bool loop = false; int crf = 23; + float time = 0f; + int quality = 80; uint? width = null; uint? height = null; int? centerx = null; @@ -92,6 +97,12 @@ options: case "--crf": crf = int.Parse(args[++i]); break; + case "--time": + time = float.Parse(args[++i]); + break; + case "--quality": + quality = int.Parse(args[++i]); + break; case "--width": width = uint.Parse(args[++i]); break; @@ -133,12 +144,7 @@ options: Console.Error.WriteLine("Missing --output"); Environment.Exit(2); } - if (!Enum.TryParse(Path.GetExtension(output).TrimStart('.'), true, out var videoFormat)) - { - var validExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat))); - Console.Error.WriteLine($"Invalid output extension. Supported formats are: {validExtensions}"); - Environment.Exit(2); - } + var outputExtension = Path.GetExtension(output).TrimStart('.').ToLowerInvariant(); var sp = new SpineObject(skelPath, atlasPath); sp.UsePma = pma; @@ -163,51 +169,119 @@ options: if (string.IsNullOrEmpty(animation)) { - var availableAnimations = string.Join(", ", sp.Data.Animations); + var availableAnimations = string.Join(", ", sp.Data.Animations.Select(a => a.Name)); Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}"); Environment.Exit(2); } var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop); + trackEntry.TrackTime = time; sp.Update(0); - FFmpegVideoExporter exporter; - if (width is uint w && height is uint h && centerx is int cx && centery is int cy) + if (Enum.TryParse(outputExtension, true, out var videoFormat)) { - exporter = new FFmpegVideoExporter(w, h) + FFmpegVideoExporter exporter; + if (width is uint w && height is uint h && centerx is int cx && centery is int cy) { - Center = (cx, cy), - Size = (w / zoom, -h / zoom), - }; + exporter = new FFmpegVideoExporter(w, h) + { + Center = (cx, cy), + Size = (w / zoom, -h / zoom), + }; + } + else + { + var bounds = GetFloatRectCanvasBounds(GetSpineObjectAnimationBounds(sp, fps), new(width ?? 512, height ?? 512)); + exporter = new FFmpegVideoExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height)) + { + Center = bounds.Position + bounds.Size / 2, + Size = (bounds.Width, -bounds.Height), + }; + } + exporter.Duration = trackEntry.Animation.Duration; + exporter.Fps = fps; + exporter.Format = videoFormat; + exporter.Loop = loop; + exporter.Crf = crf; + exporter.Speed = speed; + exporter.BackgroundColor = backgroundColor; + + if (!quiet) + exporter.ProgressReporter = (total, done, text) => Console.Write($"\r{text}"); + + using var cts = new CancellationTokenSource(); + exporter.Export(output, cts.Token, sp); + + if (!quiet) + Console.WriteLine("\nVideo export complete."); + } + else if (TryGetImageFormat(outputExtension, out var imageFormat)) + { + if (!quiet) Console.WriteLine($"Exporting single frame at {time:F2}s to {output}..."); + + FrameExporter exporter; + if (width is uint w && height is uint h && centerx is int cx && centery is int cy) + { + exporter = new FrameExporter(w, h) + { + Center = (cx, cy), + Size = (w / zoom, -h / zoom), + }; + } + else + { + + var frameBounds = GetSpineObjectBounds(sp); + var bounds = GetFloatRectCanvasBounds(frameBounds, new(width ?? 512, height ?? 512)); + exporter = new FrameExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height)) + { + Center = bounds.Position + bounds.Size / 2, + Size = (bounds.Width, -bounds.Height), + }; + } + exporter.Format = imageFormat; + exporter.Quality = quality; + exporter.BackgroundColor = backgroundColor; + + exporter.Export(output, sp); + + if (!quiet) + Console.WriteLine("Frame export complete."); } else { - var bounds = GetFloatRectCanvasBounds(GetSpineObjectAnimationBounds(sp, fps), new(width ?? 512, height ?? 512)); - exporter = new FFmpegVideoExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height)) - { - Center = bounds.Position + bounds.Size / 2, - Size = (bounds.Width, -bounds.Height), - }; + var validVideoExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat))); + var validImageExtensions = "png, jpg, jpeg, webp, bmp"; + Console.Error.WriteLine($"Invalid output extension. Supported video formats are: {validVideoExtensions}. Supported image formats are: {validImageExtensions}."); + Environment.Exit(2); } - exporter.Duration = trackEntry.Animation.Duration; - exporter.Fps = fps; - exporter.Format = videoFormat; - exporter.Loop = loop; - exporter.Crf = crf; - exporter.Speed = speed; - exporter.BackgroundColor = backgroundColor; - - if (!quiet) - exporter.ProgressReporter = (total, done, text) => Console.Write($"\r{text}"); - - using var cts = new CancellationTokenSource(); - exporter.Export(output, cts.Token, sp); - - if (!quiet) - Console.WriteLine(); Environment.Exit(0); } + private static bool TryGetImageFormat(string extension, out SKEncodedImageFormat format) + { + switch (extension) + { + case "png": + format = SKEncodedImageFormat.Png; + return true; + case "jpg": + case "jpeg": + format = SKEncodedImageFormat.Jpeg; + return true; + case "webp": + format = SKEncodedImageFormat.Webp; + return true; + case "bmp": + format = SKEncodedImageFormat.Bmp; + return true; + default: + format = default; + return false; + } + } + + public static SpineObject CopySpineObject(SpineObject sp) { var spineObject = new SpineObject(sp, true); From dc472cf2a8ff8d83e2f6fd9435949614af3066d1 Mon Sep 17 00:00:00 2001 From: jhq223 Date: Thu, 16 Oct 2025 20:57:50 +0800 Subject: [PATCH 03/13] Fix: Resolve frame export logic and slot visibility issues This commit addresses two critical bugs in the single-frame export functionality of the CLI tool. 1. **Corrects Export Mode Detection for Ambiguous Formats (.webp):** - Previously, any output format also supported by the video exporter (like `.webp`) would incorrectly trigger video export mode, ignoring the `--time` argument intended for single-frame captures. - The logic is now updated to prioritize the presence of the `--time` argument. If this argument is provided, the tool is forced into single-frame export mode, correctly handling formats like static `.webp`. - This was implemented by changing the `time` variable to a nullable float (`float?`) to reliably detect if the argument was passed. 2. **Fixes "Slot Not Found" Error for `--hide-slot`:** - The operation to hide slots was being performed *before* the animation was applied to the skeleton. This caused failures when trying to hide slots that are only activated or have attachments during a specific animation. - The slot visibility logic has been moved to execute *after* the animation state is set and the skeleton is updated to the target frame. This ensures that the skeleton is in its final pose, making all relevant slots available for modification. --- SpineViewerCLI/SpineViewerCLI.cs | 104 +++++++++++++++++-------------- 1 file changed, 57 insertions(+), 47 deletions(-) diff --git a/SpineViewerCLI/SpineViewerCLI.cs b/SpineViewerCLI/SpineViewerCLI.cs index 6f531cb..4343768 100644 --- a/SpineViewerCLI/SpineViewerCLI.cs +++ b/SpineViewerCLI/SpineViewerCLI.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.IO; using SFML.Graphics; using SFML.System; using Spine; @@ -24,7 +23,7 @@ options: --fps INT Frames per second (for video), default 24 --loop Whether to loop the animation (for video), default false --crf INT Constant Rate Factor (for video), from 0 (lossless) to 51 (worst), default 23 - --time FLOAT Time in seconds to export a single frame, default 0 + --time FLOAT Time in seconds to export a single frame. Providing this argument forces frame export mode. --quality INT Quality for lossy image formats (jpg, webp), from 0 to 100, default 80 --width INT Output width, default 512 --height INT Output height, default 512 @@ -48,7 +47,7 @@ options: uint fps = 24; bool loop = false; int crf = 23; - float time = 0f; + float? time = null; int quality = 80; uint? width = null; uint? height = null; @@ -159,6 +158,20 @@ options: } } + if (string.IsNullOrEmpty(animation)) + { + var availableAnimations = string.Join(", ", sp.Data.Animations.Select(a => a.Name)); + Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}"); + Environment.Exit(2); + } + + var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop); + if (time.HasValue) + { + trackEntry.TrackTime = time.Value; + } + sp.Update(0); + foreach (var slotName in hideSlots) { if (!sp.SetSlotVisible(slotName, false)) @@ -167,17 +180,48 @@ options: } } - if (string.IsNullOrEmpty(animation)) + if (time.HasValue) { - var availableAnimations = string.Join(", ", sp.Data.Animations.Select(a => a.Name)); - Console.Error.WriteLine($"Missing --animation. Available animations for {sp.Name}: {availableAnimations}"); - Environment.Exit(2); - } - var trackEntry = sp.AnimationState.SetAnimation(0, animation, loop); - trackEntry.TrackTime = time; - sp.Update(0); + if (TryGetImageFormat(outputExtension, out var imageFormat)) + { + if (!quiet) Console.WriteLine($"Exporting single frame at {time.Value:F2}s to {output}..."); - if (Enum.TryParse(outputExtension, true, out var videoFormat)) + FrameExporter exporter; + if (width is uint w && height is uint h && centerx is int cx && centery is int cy) + { + exporter = new FrameExporter(w, h) + { + Center = (cx, cy), + Size = (w / zoom, -h / zoom), + }; + } + else + { + var frameBounds = GetSpineObjectBounds(sp); + var bounds = GetFloatRectCanvasBounds(frameBounds, new(width ?? 512, height ?? 512)); + exporter = new FrameExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height)) + { + Center = bounds.Position + bounds.Size / 2, + Size = (bounds.Width, -bounds.Height), + }; + } + exporter.Format = imageFormat; + exporter.Quality = quality; + exporter.BackgroundColor = backgroundColor; + + exporter.Export(output, sp); + + if (!quiet) + Console.WriteLine("Frame export complete."); + } + else + { + var validImageExtensions = "png, jpg, jpeg, webp, bmp"; + Console.Error.WriteLine($"Error: --time argument requires a valid image format extension. Supported formats are: {validImageExtensions}."); + Environment.Exit(2); + } + } + else if (Enum.TryParse(outputExtension, true, out var videoFormat)) { FFmpegVideoExporter exporter; if (width is uint w && height is uint h && centerx is int cx && centery is int cy) @@ -214,44 +258,11 @@ options: if (!quiet) Console.WriteLine("\nVideo export complete."); } - else if (TryGetImageFormat(outputExtension, out var imageFormat)) - { - if (!quiet) Console.WriteLine($"Exporting single frame at {time:F2}s to {output}..."); - - FrameExporter exporter; - if (width is uint w && height is uint h && centerx is int cx && centery is int cy) - { - exporter = new FrameExporter(w, h) - { - Center = (cx, cy), - Size = (w / zoom, -h / zoom), - }; - } - else - { - - var frameBounds = GetSpineObjectBounds(sp); - var bounds = GetFloatRectCanvasBounds(frameBounds, new(width ?? 512, height ?? 512)); - exporter = new FrameExporter(width ?? (uint)Math.Ceiling(bounds.Width), height ?? (uint)Math.Ceiling(bounds.Height)) - { - Center = bounds.Position + bounds.Size / 2, - Size = (bounds.Width, -bounds.Height), - }; - } - exporter.Format = imageFormat; - exporter.Quality = quality; - exporter.BackgroundColor = backgroundColor; - - exporter.Export(output, sp); - - if (!quiet) - Console.WriteLine("Frame export complete."); - } else { var validVideoExtensions = string.Join(", ", Enum.GetNames(typeof(FFmpegVideoExporter.VideoFormat))); var validImageExtensions = "png, jpg, jpeg, webp, bmp"; - Console.Error.WriteLine($"Invalid output extension. Supported video formats are: {validVideoExtensions}. Supported image formats are: {validImageExtensions}."); + Console.Error.WriteLine($"Invalid output extension or missing --time for image export. Supported video formats are: {validVideoExtensions}. Supported image formats (with --time) are: {validImageExtensions}."); Environment.Exit(2); } @@ -281,7 +292,6 @@ options: } } - public static SpineObject CopySpineObject(SpineObject sp) { var spineObject = new SpineObject(sp, true); From c90713ffe75fc3678efaf4fc96be8cb92aa4db23 Mon Sep 17 00:00:00 2001 From: ww-rm Date: Thu, 16 Oct 2025 22:33:36 +0800 Subject: [PATCH 04/13] change tolower to tolowerinvariant --- SpineViewer/App.xaml.cs | 4 ++-- SpineViewer/Extensions/WpfExtension.cs | 2 +- .../ViewModels/Exporters/CustomFFmpegExporterViewModel.cs | 2 +- .../ViewModels/Exporters/FFmpegVideoExporterViewModel.cs | 2 +- SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs | 2 +- SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs | 2 +- SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs | 4 ++-- SpineViewer/Views/MainWindow.xaml | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/SpineViewer/App.xaml.cs b/SpineViewer/App.xaml.cs index e3064ac..359deba 100644 --- a/SpineViewer/App.xaml.cs +++ b/SpineViewer/App.xaml.cs @@ -331,7 +331,7 @@ namespace SpineViewer get => _language; set { - var uri = $"Resources/Strings/{value.ToString().ToLower()}.xaml"; + var uri = $"Resources/Strings/{value.ToString().ToLowerInvariant()}.xaml"; try { Resources.MergedDictionaries.Add(new() { Source = new(uri, UriKind.Relative) }); @@ -351,7 +351,7 @@ namespace SpineViewer get => _skin; set { - var uri = $"Resources/Skins/{value.ToString().ToLower()}.xaml"; + var uri = $"Resources/Skins/{value.ToString().ToLowerInvariant()}.xaml"; try { Resources.MergedDictionaries.Add(new() { Source = new(uri, UriKind.Relative) }); diff --git a/SpineViewer/Extensions/WpfExtension.cs b/SpineViewer/Extensions/WpfExtension.cs index b1aa789..d5cd487 100644 --- a/SpineViewer/Extensions/WpfExtension.cs +++ b/SpineViewer/Extensions/WpfExtension.cs @@ -40,7 +40,7 @@ namespace SpineViewer.Extensions //public static void SaveToFile(this BitmapSource bitmap, string path) //{ - // var ext = Path.GetExtension(path)?.ToLower(); + // var ext = Path.GetExtension(path)?.ToLowerInvariant(); // BitmapEncoder encoder = ext switch // { // ".jpg" or ".jpeg" => new JpegBitmapEncoder(), diff --git a/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs index 7dcc382..f1afde6 100644 --- a/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/CustomFFmpegExporterViewModel.cs @@ -36,7 +36,7 @@ namespace SpineViewer.ViewModels.Exporters public string? CustomArgs { get => _customArgs; set => SetProperty(ref _customArgs, value); } protected string? _customArgs; - private string FormatSuffix => $".{_format.ToString().ToLower()}"; + private string FormatSuffix => $".{_format.ToString().ToLowerInvariant()}"; public override string? Validate() { diff --git a/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs index fb2f532..474c03b 100644 --- a/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/FFmpegVideoExporterViewModel.cs @@ -77,7 +77,7 @@ namespace SpineViewer.ViewModels.Exporters public bool EnableParamProfile => _format == FFmpegVideoExporter.VideoFormat.Mov; - private string FormatSuffix => $".{_format.ToString().ToLower()}"; + private string FormatSuffix => $".{_format.ToString().ToLowerInvariant()}"; protected override void Export(SpineObjectModel[] models) { diff --git a/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs b/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs index a30363a..5a27099 100644 --- a/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs +++ b/SpineViewer/ViewModels/Exporters/FrameExporterViewModel.cs @@ -34,7 +34,7 @@ namespace SpineViewer.ViewModels.Exporters { if (_format == SKEncodedImageFormat.Heif) return ".jpeg"; else if (_format == SKEncodedImageFormat.Jpegxl) return ".jpeg"; - else return $".{_format.ToString().ToLower()}"; + else return $".{_format.ToString().ToLowerInvariant()}"; } } diff --git a/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs b/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs index 0f85b90..3a56496 100644 --- a/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/ExplorerListViewModel.cs @@ -333,7 +333,7 @@ namespace SpineViewer.ViewModels.MainWindow { foreach (var file in Directory.EnumerateFiles(_currentDirectory, "*.*", SearchOption.AllDirectories)) { - var lowerPath = file.ToLower(); + var lowerPath = file.ToLowerInvariant(); if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith)) _items.Add(new(file)); } diff --git a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs index 0615ac4..c60d7e6 100644 --- a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs @@ -450,7 +450,7 @@ namespace SpineViewer.ViewModels.MainWindow { if (File.Exists(path)) { - var lowerPath = path.ToLower(); + var lowerPath = path.ToLowerInvariant(); if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith)) validPaths.Add(path); } @@ -458,7 +458,7 @@ namespace SpineViewer.ViewModels.MainWindow { foreach (var file in Directory.EnumerateFiles(path, "*.*", SearchOption.AllDirectories)) { - var lowerPath = file.ToLower(); + var lowerPath = file.ToLowerInvariant(); if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith)) validPaths.Add(file); } diff --git a/SpineViewer/Views/MainWindow.xaml b/SpineViewer/Views/MainWindow.xaml index 2949858..1a16917 100644 --- a/SpineViewer/Views/MainWindow.xaml +++ b/SpineViewer/Views/MainWindow.xaml @@ -73,7 +73,7 @@ - + From b178e48e84033947e413cb19dbb5e2882eaab561 Mon Sep 17 00:00:00 2001 From: ww-rm Date: Thu, 16 Oct 2025 23:54:07 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E5=8E=BB=E9=99=A4=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=9A=84=E6=9C=80=E5=B0=8F=E5=8C=96=E6=8F=90=E7=A4=BA=E5=BC=B9?= =?UTF-8?q?=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SpineViewer/Models/PreferenceModel.cs | 2 +- SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs | 4 ++-- SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs | 2 +- SpineViewer/Views/MainWindow.xaml.cs | 5 ----- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/SpineViewer/Models/PreferenceModel.cs b/SpineViewer/Models/PreferenceModel.cs index 4ae94d6..1575935 100644 --- a/SpineViewer/Models/PreferenceModel.cs +++ b/SpineViewer/Models/PreferenceModel.cs @@ -102,7 +102,7 @@ namespace SpineViewer.Models private bool _wallpaperView; [ObservableProperty] - private bool? _closeToTray = null; + private bool _closeToTray; [ObservableProperty] private bool _autoRun; diff --git a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs index 33247c5..bdf235c 100644 --- a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs @@ -35,12 +35,12 @@ namespace SpineViewer.ViewModels.MainWindow public bool IsShuttingDownFromTray => _isShuttingDownFromTray; private bool _isShuttingDownFromTray; - public bool? CloseToTray + public bool CloseToTray { get => _closeToTray; set => SetProperty(ref _closeToTray, value); } - private bool? _closeToTray = null; + private bool _closeToTray; public string AutoRunWorkspaceConfigPath { diff --git a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs index 41d42a8..5b07df6 100644 --- a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs @@ -295,7 +295,7 @@ namespace SpineViewer.ViewModels.MainWindow set => SetProperty(_vmMain.SFMLRendererViewModel.WallpaperView, value, v => _vmMain.SFMLRendererViewModel.WallpaperView = v); } - public bool? CloseToTray + public bool CloseToTray { get => _vmMain.CloseToTray; set => SetProperty(_vmMain.CloseToTray, value, v => _vmMain.CloseToTray = v); diff --git a/SpineViewer/Views/MainWindow.xaml.cs b/SpineViewer/Views/MainWindow.xaml.cs index 59fe17e..3edb6f5 100644 --- a/SpineViewer/Views/MainWindow.xaml.cs +++ b/SpineViewer/Views/MainWindow.xaml.cs @@ -238,11 +238,6 @@ public partial class MainWindow : Window { if (!_vm.IsShuttingDownFromTray) { - if (_vm.CloseToTray is null) - { - _vm.PreferenceViewModel.CloseToTray = MessagePopupService.YesNo(AppResource.Str_CloseToTrayQuest); - _vm.PreferenceViewModel.SavePreference(); - } if (_vm.CloseToTray is true) { Hide(); From 02445d36e55ae7af5492205d9b85e0dafd455491 Mon Sep 17 00:00:00 2001 From: ww-rm Date: Fri, 17 Oct 2025 22:41:49 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{LastStateModel.cs => UserStateModel.cs} | 2 +- SpineViewer/Utils/PropertyWatcher.cs | 31 +++ .../MainWindow/MainWindowViewModel.cs | 9 +- SpineViewer/Views/MainWindow.xaml | 4 +- SpineViewer/Views/MainWindow.xaml.cs | 229 ++++++++++++------ 5 files changed, 191 insertions(+), 84 deletions(-) rename SpineViewer/Models/{LastStateModel.cs => UserStateModel.cs} (97%) create mode 100644 SpineViewer/Utils/PropertyWatcher.cs diff --git a/SpineViewer/Models/LastStateModel.cs b/SpineViewer/Models/UserStateModel.cs similarity index 97% rename from SpineViewer/Models/LastStateModel.cs rename to SpineViewer/Models/UserStateModel.cs index 17eb9d3..f353a62 100644 --- a/SpineViewer/Models/LastStateModel.cs +++ b/SpineViewer/Models/UserStateModel.cs @@ -8,7 +8,7 @@ using System.Windows.Media; namespace SpineViewer.Models { - public class LastStateModel + public class UserStateModel { #region 画面布局状态 diff --git a/SpineViewer/Utils/PropertyWatcher.cs b/SpineViewer/Utils/PropertyWatcher.cs new file mode 100644 index 0000000..6cbd471 --- /dev/null +++ b/SpineViewer/Utils/PropertyWatcher.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; + +namespace SpineViewer.Utils +{ + public static class PropertyWatcher + { + public static IDisposable Watch(DependencyObject target, DependencyProperty property, Action callback) + { + var dpd = DependencyPropertyDescriptor.FromProperty(property, target.GetType()); + if (dpd == null) return null; + + EventHandler handler = (s, e) => callback(); + dpd.AddValueChanged(target, handler); + + return new Unsubscriber(() => dpd.RemoveValueChanged(target, handler)); + } + + private class Unsubscriber : IDisposable + { + private readonly Action _dispose; + public Unsubscriber(Action dispose) => _dispose = dispose; + public void Dispose() => _dispose(); + } + } +} diff --git a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs index bdf235c..c86ee78 100644 --- a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs @@ -32,7 +32,11 @@ namespace SpineViewer.ViewModels.MainWindow /// /// 指示是否通过托盘图标进行退出 /// - public bool IsShuttingDownFromTray => _isShuttingDownFromTray; + public bool IsShuttingDownFromTray + { + get => _isShuttingDownFromTray; + private set => SetProperty(ref _isShuttingDownFromTray, value); + } private bool _isShuttingDownFromTray; public bool CloseToTray @@ -109,8 +113,7 @@ namespace SpineViewer.ViewModels.MainWindow public RelayCommand Cmd_ExitFromTray => _cmd_ExitFromTray ??= new(() => { - _isShuttingDownFromTray = true; - OnPropertyChanged(nameof(IsShuttingDownFromTray)); + IsShuttingDownFromTray = true; App.Current.Shutdown(); }); private RelayCommand? _cmd_ExitFromTray; diff --git a/SpineViewer/Views/MainWindow.xaml b/SpineViewer/Views/MainWindow.xaml index 1a16917..b6c8c6e 100644 --- a/SpineViewer/Views/MainWindow.xaml +++ b/SpineViewer/Views/MainWindow.xaml @@ -9,12 +9,12 @@ xmlns:SFMLRenderer="clr-namespace:SFMLRenderer;assembly=SFMLRenderer" mc:Ignorable="d" x:Name="_mainWindow" + d:DataContext="{d:DesignInstance Type={x:Type vm:MainWindowViewModel}}" Title="{Binding Title}" - Background="{DynamicResource RegionBrush}" Width="1500" Height="800" + Background="{DynamicResource RegionBrush}" WindowStartupLocation="CenterScreen" - d:DataContext="{d:DesignInstance Type={x:Type vm:MainWindowViewModel}}" PreviewKeyDown="MainWindow_PreviewKeyDown" LocationChanged="MainWindow_LocationChanged" SizeChanged="MainWindow_SizeChanged"> diff --git a/SpineViewer/Views/MainWindow.xaml.cs b/SpineViewer/Views/MainWindow.xaml.cs index 3edb6f5..26adac4 100644 --- a/SpineViewer/Views/MainWindow.xaml.cs +++ b/SpineViewer/Views/MainWindow.xaml.cs @@ -1,6 +1,4 @@ using NLog; -using NLog.Layouts; -using NLog.Targets; using SFMLRenderer; using Spine; using SpineViewer.Models; @@ -25,6 +23,7 @@ using System.Windows.Documents; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; +using System.Windows.Threading; namespace SpineViewer.Views; @@ -36,7 +35,7 @@ public partial class MainWindow : Window /// /// 上一次状态文件保存路径 /// - public static readonly string LastStateFilePath = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "laststate.json"); + public static readonly string UserStateFilePath = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "userstate.json"); private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); @@ -46,6 +45,10 @@ public partial class MainWindow : Window private readonly SFMLRenderWindow _wallpaperRenderWindow; private readonly MainWindowViewModel _vm; + private readonly List _userStateWatchers = []; + private DispatcherTimer _saveUserStateTimer; + private readonly TimeSpan _saveTimerDelay = TimeSpan.FromSeconds(3); + public MainWindow() { InitializeComponent(); @@ -104,79 +107,6 @@ public partial class MainWindow : Window LogManager.ReconfigExistingLoggers(); } - private void LoadLastState() - { - if (JsonHelper.Deserialize(LastStateFilePath, out var m, true)) - { - Left = m.WindowLeft; - Top = m.WindowTop; - Width = m.WindowWidth; - Height = m.WindowHeight; - if (m.WindowState == WindowState.Maximized) - { - WindowState = WindowState.Maximized; - } - else - { - WindowState = WindowState.Normal; - } - - _rootGrid.ColumnDefinitions[0].Width = new(m.RootGridCol0Width, GridUnitType.Star); - _rootGrid.ColumnDefinitions[2].Width = new(m.RootGridCol2Width, GridUnitType.Star); - - _modelListGrid.RowDefinitions[0].Height = new(m.ModelListRow0Height, GridUnitType.Star); - _modelListGrid.RowDefinitions[2].Height = new(m.ModelListRow2Height, GridUnitType.Star); - - _explorerGrid.RowDefinitions[0].Height = new(m.ExplorerGridRow0Height, GridUnitType.Star); - _explorerGrid.RowDefinitions[2].Height = new(m.ExplorerGridRow2Height, GridUnitType.Star); - - _rightPanelGrid.RowDefinitions[0].Height = new(m.RightPanelGridRow0Height, GridUnitType.Star); - _rightPanelGrid.RowDefinitions[2].Height = new(m.RightPanelGridRow2Height, GridUnitType.Star); - - _vm.SFMLRendererViewModel.SetResolution(m.ResolutionX, m.ResolutionY); - _vm.SFMLRendererViewModel.MaxFps = m.MaxFps; - _vm.SFMLRendererViewModel.Speed = m.Speed; - _vm.SFMLRendererViewModel.ShowAxis = m.ShowAxis; - _vm.SFMLRendererViewModel.BackgroundColor = m.BackgroundColor; - _vm.SFMLRendererViewModel.BackgroundImageMode = m.BackgroundImageMode; - } - } - - private void SaveLastState() - { - var rb = RestoreBounds; - var m = new LastStateModel() - { - WindowLeft = rb.Left, - WindowTop = rb.Top, - WindowWidth = rb.Width, - WindowHeight = rb.Height, - WindowState = WindowState, - - RootGridCol0Width = _rootGrid.ColumnDefinitions[0].Width.Value, - RootGridCol2Width = _rootGrid.ColumnDefinitions[2].Width.Value, - - ModelListRow0Height = _modelListGrid.RowDefinitions[0].Height.Value, - ModelListRow2Height = _modelListGrid.RowDefinitions[2].Height.Value, - - ExplorerGridRow0Height = _explorerGrid.RowDefinitions[0].Height.Value, - ExplorerGridRow2Height = _explorerGrid.RowDefinitions[2].Height.Value, - - RightPanelGridRow0Height = _rightPanelGrid.RowDefinitions[0].Height.Value, - RightPanelGridRow2Height = _rightPanelGrid.RowDefinitions[2].Height.Value, - - ResolutionX = _vm.SFMLRendererViewModel.ResolutionX, - ResolutionY = _vm.SFMLRendererViewModel.ResolutionY, - MaxFps = _vm.SFMLRendererViewModel.MaxFps, - Speed = _vm.SFMLRendererViewModel.Speed, - ShowAxis = _vm.SFMLRendererViewModel.ShowAxis, - BackgroundColor = _vm.SFMLRendererViewModel.BackgroundColor, - BackgroundImageMode = _vm.SFMLRendererViewModel.BackgroundImageMode, - }; - - JsonHelper.Serialize(m, LastStateFilePath); - } - #region MainWindow 事件处理 private void MainWindow_SourceInitialized(object? sender, EventArgs e) @@ -206,7 +136,29 @@ public partial class MainWindow : Window // 加载首选项 _vm.PreferenceViewModel.LoadPreference(); - LoadLastState(); + // 还原上一次用户历史状态 + LoadUserState(); + + // 添加用户状态监听器 + _userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WidthProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.HeightProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.LeftProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.TopProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(this, MainWindow.WindowStateProperty, DelayedSaveUserState)); + + _userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[0], ColumnDefinition.WidthProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(_rootGrid.ColumnDefinitions[2], ColumnDefinition.WidthProperty, DelayedSaveUserState)); + + _userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(_modelListGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState)); + + _userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(_explorerGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState)); + + _userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[0], RowDefinition.HeightProperty, DelayedSaveUserState)); + _userStateWatchers.Add(PropertyWatcher.Watch(_rightPanelGrid.RowDefinitions[2], RowDefinition.HeightProperty, DelayedSaveUserState)); + + _vm.SFMLRendererViewModel.PropertyChanged += SFMLRendererUserStateChanged; } private void MainWindow_ContentRendered(object? sender, EventArgs e) @@ -246,7 +198,14 @@ public partial class MainWindow : Window } } - SaveLastState(); + // 保存当前用户状态 + SaveUserState(); + + // 撤除所有状态监听器 + _vm.SFMLRendererViewModel.PropertyChanged -= SFMLRendererUserStateChanged; + foreach (var w in _userStateWatchers) w.Dispose(); + _userStateWatchers.Clear(); + _vm.SFMLRendererViewModel.StopRender(); } @@ -255,6 +214,118 @@ public partial class MainWindow : Window } + private void LoadUserState() + { + if (JsonHelper.Deserialize(UserStateFilePath, out var m, true)) + { + Left = m.WindowLeft; + Top = m.WindowTop; + Width = m.WindowWidth; + Height = m.WindowHeight; + if (m.WindowState == WindowState.Maximized) + { + WindowState = WindowState.Maximized; + } + else + { + WindowState = WindowState.Normal; + } + + _rootGrid.ColumnDefinitions[0].Width = new(m.RootGridCol0Width, GridUnitType.Star); + _rootGrid.ColumnDefinitions[2].Width = new(m.RootGridCol2Width, GridUnitType.Star); + + _modelListGrid.RowDefinitions[0].Height = new(m.ModelListRow0Height, GridUnitType.Star); + _modelListGrid.RowDefinitions[2].Height = new(m.ModelListRow2Height, GridUnitType.Star); + + _explorerGrid.RowDefinitions[0].Height = new(m.ExplorerGridRow0Height, GridUnitType.Star); + _explorerGrid.RowDefinitions[2].Height = new(m.ExplorerGridRow2Height, GridUnitType.Star); + + _rightPanelGrid.RowDefinitions[0].Height = new(m.RightPanelGridRow0Height, GridUnitType.Star); + _rightPanelGrid.RowDefinitions[2].Height = new(m.RightPanelGridRow2Height, GridUnitType.Star); + + _vm.SFMLRendererViewModel.SetResolution(m.ResolutionX, m.ResolutionY); + _vm.SFMLRendererViewModel.MaxFps = m.MaxFps; + _vm.SFMLRendererViewModel.Speed = m.Speed; + _vm.SFMLRendererViewModel.ShowAxis = m.ShowAxis; + _vm.SFMLRendererViewModel.BackgroundColor = m.BackgroundColor; + _vm.SFMLRendererViewModel.BackgroundImageMode = m.BackgroundImageMode; + } + } + + private void SaveUserState() + { + var rb = RestoreBounds; + var m = new UserStateModel() + { + WindowLeft = rb.Left, + WindowTop = rb.Top, + WindowWidth = rb.Width, + WindowHeight = rb.Height, + WindowState = WindowState, + + RootGridCol0Width = _rootGrid.ColumnDefinitions[0].Width.Value, + RootGridCol2Width = _rootGrid.ColumnDefinitions[2].Width.Value, + + ModelListRow0Height = _modelListGrid.RowDefinitions[0].Height.Value, + ModelListRow2Height = _modelListGrid.RowDefinitions[2].Height.Value, + + ExplorerGridRow0Height = _explorerGrid.RowDefinitions[0].Height.Value, + ExplorerGridRow2Height = _explorerGrid.RowDefinitions[2].Height.Value, + + RightPanelGridRow0Height = _rightPanelGrid.RowDefinitions[0].Height.Value, + RightPanelGridRow2Height = _rightPanelGrid.RowDefinitions[2].Height.Value, + + ResolutionX = _vm.SFMLRendererViewModel.ResolutionX, + ResolutionY = _vm.SFMLRendererViewModel.ResolutionY, + MaxFps = _vm.SFMLRendererViewModel.MaxFps, + Speed = _vm.SFMLRendererViewModel.Speed, + ShowAxis = _vm.SFMLRendererViewModel.ShowAxis, + BackgroundColor = _vm.SFMLRendererViewModel.BackgroundColor, + BackgroundImageMode = _vm.SFMLRendererViewModel.BackgroundImageMode, + }; + + JsonHelper.Serialize(m, UserStateFilePath); + } + + /// + /// 的延时版本, 避免一次性大量执行 + /// + private void DelayedSaveUserState() + { + // 第一次调用时创建定时器 + if (_saveUserStateTimer == null) + { + _saveUserStateTimer = new() { Interval = _saveTimerDelay }; + _saveUserStateTimer.Tick += (s, e) => + { + _saveUserStateTimer.Stop(); + SaveUserState(); + }; + } + + // 每次触发都重置间隔和计时 + _saveUserStateTimer.Stop(); + _saveUserStateTimer.Start(); + } + + private void SFMLRendererUserStateChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(SFMLRendererViewModel.ResolutionX): + case nameof(SFMLRendererViewModel.ResolutionY): + case nameof(SFMLRendererViewModel.MaxFps): + case nameof(SFMLRendererViewModel.Speed): + case nameof(SFMLRendererViewModel.ShowAxis): + case nameof(SFMLRendererViewModel.BackgroundColor): + case nameof(SFMLRendererViewModel.BackgroundImageMode): + DelayedSaveUserState(); + break; + default: + break; + } + } + #endregion #region ColorPicker 弹窗事件处理 @@ -741,6 +812,8 @@ public partial class MainWindow : Window _logger.Warn("Warn"); _logger.Error("Error"); _logger.Fatal("Fatal"); + var _tabContentHost = (ContentPresenter?)_mainTabControl.Template.FindName("PART_SelectedContentHost", _mainTabControl); + _mainTabControl.Visibility = Visibility.Collapsed; return; #endif } From f5d3f93cde2c980205779f4cd487a59d6ce6aa04 Mon Sep 17 00:00:00 2001 From: ww-rm Date: Sun, 19 Oct 2025 00:05:46 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E5=9B=BE=E6=A0=87=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SpineViewer/Resources/Geometries.xaml | 5 +++++ SpineViewer/Resources/Theme.xaml | 23 ++++++++++++++++++++ SpineViewer/Views/MainWindow.xaml | 30 ++++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/SpineViewer/Resources/Geometries.xaml b/SpineViewer/Resources/Geometries.xaml index da435a8..2ed3191 100644 --- a/SpineViewer/Resources/Geometries.xaml +++ b/SpineViewer/Resources/Geometries.xaml @@ -13,4 +13,9 @@ M320 96c17.7 0 32 14.3 32 32l0 256c0 17.7-14.3 32-32 32L64 416c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l256 0zM64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64z M0 96C0 60.7 28.7 32 64 32l132.1 0c19.1 0 37.4 7.6 50.9 21.1L289.9 96 448 96c35.3 0 64 28.7 64 64l0 256c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM64 80c-8.8 0-16 7.2-16 16l0 320c0 8.8 7.2 16 16 16l384 0c8.8 0 16-7.2 16-16l0-256c0-8.8-7.2-16-16-16l-161.4 0c-10.6 0-20.8-4.2-28.3-11.7L213.1 87c-4.5-4.5-10.6-7-17-7L64 80z M472 224c13.3 0 24-10.7 24-24l0-144c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 80.1-20-23.5C387 63.4 325.1 32 256 32C132.3 32 32 132.3 32 256s100.3 224 224 224c50.4 0 97-16.7 134.4-44.8c10.6-8 12.7-23 4.8-33.6s-23-12.7-33.6-4.8C332.2 418.9 295.7 432 256 432c-97.2 0-176-78.8-176-176s78.8-176 176-176c54.3 0 102.9 24.6 135.2 63.4l.1 .2s0 0 0 0L418.9 176 328 176c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0z + M348 62.7C330.7 52.7 309.3 52.7 292 62.7L207.8 111.3C190.5 121.3 179.8 139.8 179.8 159.8L179.8 261.7L91.5 312.7C74.2 322.7 63.5 341.2 63.5 361.2L63.5 458.5C63.5 478.5 74.2 497 91.5 507L175.8 555.6C193.1 565.6 214.5 565.6 231.8 555.6L320.1 504.6L408.4 555.6C425.7 565.6 447.1 565.6 464.4 555.6L548.5 507C565.8 497 576.5 478.5 576.5 458.5L576.5 361.2C576.5 341.2 565.8 322.7 548.5 312.7L460.2 261.7L460.2 159.8C460.2 139.8 449.5 121.3 432.2 111.3L348 62.7zM135.5 342.7L203.8 303.3L272.1 342.7L203.8 382.1L135.5 342.7zM111.5 384.3L179.8 423.7L179.8 502.5L115.5 465.4C113 464 111.5 461.3 111.5 458.5L111.5 384.3zM227.8 502.5L227.8 423.7L296.1 384.3L296.1 463.1L227.8 502.5zM344 384.3L412.3 423.7L412.3 502.5L344 463.1L344 384.3zM460.3 502.5L460.3 423.7L528.6 384.3L528.6 458.5C528.6 461.4 527.1 464 524.6 465.4L460.3 502.5zM504.6 342.7L436.3 382.1L368 342.7L436.3 303.3L504.6 342.7zM344 301.2L344 222.4L412.3 183L412.3 261.8L344 301.2zM388.3 141.4L320 180.8L251.8 141.4L316 104.3C318.5 102.9 321.5 102.9 324 104.3L388.3 141.4zM227.8 182.9L296.1 222.3L296.1 301.1L227.8 261.7L227.8 182.9z + M160 144C151.2 144 144 151.2 144 160L144 480C144 488.8 151.2 496 160 496L480 496C488.8 496 496 488.8 496 480L496 160C496 151.2 488.8 144 480 144L160 144zM96 160C96 124.7 124.7 96 160 96L480 96C515.3 96 544 124.7 544 160L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 160zM224 192C241.7 192 256 206.3 256 224C256 241.7 241.7 256 224 256C206.3 256 192 241.7 192 224C192 206.3 206.3 192 224 192zM360 264C368.5 264 376.4 268.5 380.7 275.8L460.7 411.8C465.1 419.2 465.1 428.4 460.8 435.9C456.5 443.4 448.6 448 440 448L200 448C191.1 448 182.8 443 178.7 435.1C174.6 427.2 175.2 417.6 180.3 410.3L236.3 330.3C240.8 323.9 248.1 320.1 256 320.1C263.9 320.1 271.2 323.9 275.7 330.3L292.9 354.9L339.4 275.9C343.7 268.6 351.6 264.1 360.1 264.1z + M304 112L192 112C183.2 112 176 119.2 176 128L176 512C176 520.8 183.2 528 192 528L448 528C456.8 528 464 520.8 464 512L464 272L376 272C336.2 272 304 239.8 304 200L304 112zM444.1 224L352 131.9L352 200C352 213.3 362.7 224 376 224L444.1 224zM128 128C128 92.7 156.7 64 192 64L325.5 64C342.5 64 358.8 70.7 370.8 82.7L493.3 205.3C505.3 217.3 512 233.6 512 250.6L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 128z + M269.5 156.7L283.2 96L356.8 96L370.5 156.7C372.2 164.1 377.3 170.3 384.3 173.4C395.1 178.2 405.3 184.1 414.7 191C420.8 195.5 428.8 196.9 436.1 194.6L495.6 176.1L532.4 239.9L486.6 282.2C481 287.4 478.2 294.9 479 302.4C480.3 313.9 480.3 326.1 479 337.6C478.2 345.2 481 352.7 486.6 357.8L532.4 400.1L495.6 463.9L436.1 445.4C428.8 443.1 420.9 444.5 414.7 449C405.3 455.9 395.1 461.9 384.3 466.6C377.3 469.7 372.2 475.9 370.5 483.3L356.8 544L283.2 544L269.5 483.3C267.8 475.9 262.7 469.7 255.7 466.6C244.9 461.8 234.7 455.9 225.3 449C219.2 444.5 211.2 443.1 203.9 445.4L144.4 463.9L107.6 400.1L153.4 357.8C159 352.6 161.8 345.1 161 337.6C159.7 326.1 159.7 313.9 161 302.4C161.8 294.8 159 287.3 153.4 282.2L107.6 239.9L144.4 176.1L203.9 194.6C211.2 196.9 219.1 195.5 225.3 191C234.7 184.1 244.9 178.1 255.7 173.4C262.7 170.3 267.8 164.1 269.5 156.7zM276.8 48C258.1 48 241.9 61 237.8 79.2L225.2 134.8C218.9 138 212.9 141.5 207 145.3L152.6 128.4C134.7 122.8 115.4 130.4 106.1 146.6L62.9 221.4C53.6 237.6 56.7 258.1 70.4 270.8L112.3 309.5C112 316.4 112 323.5 112.3 330.5L70.4 369.2C56.7 381.9 53.5 402.4 62.9 418.6L106.1 493.4C115.4 509.6 134.8 517.1 152.6 511.6L207.1 494.7C213 498.5 219 502 225.3 505.2L237.9 560.8C242 579 258.2 592 276.9 592L363.3 592C382 592 398.2 579 402.3 560.8L414.9 505.2C421.2 502 427.2 498.5 433.1 494.7L487.6 511.6C505.5 517.2 524.8 509.6 534.1 493.4L577.3 418.6C586.6 402.4 583.5 381.9 569.8 369.2L527.9 330.5C528.2 323.6 528.2 316.5 527.9 309.5L569.8 270.8C583.5 258.1 586.6 237.6 577.3 221.4L534 146.6C524.6 130.4 505.3 122.9 487.5 128.4L433 145.3C427.1 141.5 421.1 138 414.8 134.8L402.3 79.2C398.1 61 381.9 48 363.2 48L276.8 48zM368 320C368 346.5 346.5 368 320 368C293.5 368 272 346.5 272 320C272 293.5 293.5 272 320 272C346.5 272 368 293.5 368 320zM320 224C267 224 224 267 224 320C224 373 267 416 320 416C373 416 416 373 416 320C416 267 373 224 320 224z + M88 136C74.7 136 64 146.7 64 160C64 173.3 74.7 184 88 184L179.7 184C189.9 216.5 220.2 240 256 240C291.8 240 322.1 216.5 332.3 184L552 184C565.3 184 576 173.3 576 160C576 146.7 565.3 136 552 136L332.3 136C322.1 103.5 291.8 80 256 80C220.2 80 189.9 103.5 179.7 136L88 136zM88 296C74.7 296 64 306.7 64 320C64 333.3 74.7 344 88 344L339.7 344C349.9 376.5 380.2 400 416 400C451.8 400 482.1 376.5 492.3 344L552 344C565.3 344 576 333.3 576 320C576 306.7 565.3 296 552 296L492.3 296C482.1 263.5 451.8 240 416 240C380.2 240 349.9 263.5 339.7 296L88 296zM88 456C74.7 456 64 466.7 64 480C64 493.3 74.7 504 88 504L147.7 504C157.9 536.5 188.2 560 224 560C259.8 560 290.1 536.5 300.3 504L552 504C565.3 504 576 493.3 576 480C576 466.7 565.3 456 552 456L300.3 456C290.1 423.5 259.8 400 224 400C188.2 400 157.9 423.5 147.7 456L88 456zM224 512C206.3 512 192 497.7 192 480C192 462.3 206.3 448 224 448C241.7 448 256 462.3 256 480C256 497.7 241.7 512 224 512zM416 352C398.3 352 384 337.7 384 320C384 302.3 398.3 288 416 288C433.7 288 448 302.3 448 320C448 337.7 433.7 352 416 352zM224 160C224 142.3 238.3 128 256 128C273.7 128 288 142.3 288 160C288 177.7 273.7 192 256 192C238.3 192 224 177.7 224 160z diff --git a/SpineViewer/Resources/Theme.xaml b/SpineViewer/Resources/Theme.xaml index df5eeee..bfff007 100644 --- a/SpineViewer/Resources/Theme.xaml +++ b/SpineViewer/Resources/Theme.xaml @@ -76,6 +76,29 @@ + + + + diff --git a/SpineViewer/Utils/JsonHelper.cs b/SpineViewer/Utils/JsonHelper.cs index 6efb3d2..f37c220 100644 --- a/SpineViewer/Utils/JsonHelper.cs +++ b/SpineViewer/Utils/JsonHelper.cs @@ -32,6 +32,7 @@ namespace SpineViewer.Utils private static readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true, + IndentSize = 4, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip @@ -47,7 +48,6 @@ namespace SpineViewer.Utils if (!quietForNotExist) { _logger.Error("Json file {0} not found", path); - MessagePopupService.Error($"Json file {path} not found"); } } else @@ -62,13 +62,11 @@ namespace SpineViewer.Utils return true; } _logger.Error("Null data in file {0}", path); - MessagePopupService.Error($"Null data in file {path}"); } catch (Exception ex) { _logger.Trace(ex.ToString()); _logger.Error("Failed to read json file {0}, {1}", path, ex.Message); - MessagePopupService.Error($"Failed to read json file {path}, {ex.ToString()}"); } } obj = default; @@ -90,11 +88,24 @@ namespace SpineViewer.Utils { _logger.Trace(ex.ToString()); _logger.Error("Failed to save json file {0}, {1}", path, ex.Message); - MessagePopupService.Error($"Failed to save json file {path}, {ex.ToString()}"); return false; } return true; } + + public static string Serialize(T obj) + { + try + { + return JsonSerializer.Serialize(obj, _jsonOptions); + } + catch (Exception ex) + { + _logger.Trace(ex.ToString()); + _logger.Error("Failed to serialize json object {0}", ex.Message); + return string.Empty; + } + } } public class ColorJsonConverter : JsonConverter diff --git a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs index c60d7e6..a6cda79 100644 --- a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs @@ -34,6 +34,7 @@ namespace SpineViewer.ViewModels.MainWindow /// 临时对象, 存储复制的模型参数 /// private SpineObjectConfigModel? _copiedSpineObjectConfigModel = null; + private SpineObjectConfigApplyFlag _copiedConfigFlag = SpineObjectConfigApplyFlag.All; public SpineObjectListViewModel(MainWindowViewModel mainViewModel) { @@ -99,6 +100,127 @@ namespace SpineViewer.ViewModels.MainWindow } } + /// + /// 从路径列表添加对象 + /// + /// 可以是文件和文件夹 + public void AddSpineObjectFromFileList(IEnumerable paths) + { + List validPaths = []; + foreach (var path in paths) + { + if (File.Exists(path)) + { + var lowerPath = path.ToLowerInvariant(); + if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith)) + validPaths.Add(path); + } + else if (Directory.Exists(path)) + { + foreach (var file in Directory.EnumerateFiles(path, "*.*", SearchOption.AllDirectories)) + { + var lowerPath = file.ToLowerInvariant(); + if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith)) + validPaths.Add(file); + } + } + } + + if (validPaths.Count > 1) + { + if (validPaths.Count > 100) + { + if (!MessagePopupService.OKCancel(string.Format(AppResource.Str_TooManyItemsToAddQuest, validPaths.Count))) + return; + } + ProgressService.RunAsync((pr, ct) => AddSpineObjectsTask( + validPaths.ToArray(), pr, ct), + AppResource.Str_AddSpineObjectsTitle + ); + } + else if (validPaths.Count > 0) + { + InsertSpineObject(validPaths[0]); + _logger.LogCurrentProcessMemoryUsage(); + } + } + + /// + /// 用于后台添加模型的任务方法 + /// + private void AddSpineObjectsTask(string[] paths, IProgressReporter reporter, CancellationToken ct) + { + int totalCount = paths.Length; + 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 skelPath = paths[i]; + reporter.ProgressText = $"[{i}/{totalCount}] {skelPath}"; + + if (InsertSpineObject(skelPath)) + success++; + else + error++; + + reporter.Done = i + 1; + reporter.ProgressText = $"[{i + 1}/{totalCount}] {skelPath}"; + _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(); + } + + /// + /// 安全地在列表头添加一个模型, 发生错误会输出日志 + /// + /// 是否添加成功 + private bool InsertSpineObject(string skelPath, string? atlasPath = null) + { + try + { + var sp = new SpineObjectModel(skelPath, atlasPath); + lock (_spineObjectModels.Lock) _spineObjectModels.Insert(0, sp); + if (Application.Current.Dispatcher.CheckAccess()) + { + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp)); + } + else + { + Application.Current.Dispatcher.Invoke(() => + { + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp)); + }); + } + return true; + } + catch (Exception ex) + { + _logger.Trace(ex.ToString()); + _logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message); + } + return false; + } + + #region 模型列表管理菜单项实现 + /// /// 弹窗添加单模型命令 /// @@ -349,18 +471,53 @@ namespace SpineViewer.ViewModels.MainWindow return true; } + #endregion + + #region 模型参数管理菜单项实现 + /// /// 复制模型参数 /// - public RelayCommand Cmd_CopySpineObjectConfig => _cmd_CopySpineObjectConfig ??= new(CopySpineObjectConfig_Execute, CopySpineObjectConfig_CanExecute); + public RelayCommand Cmd_CopySpineObjectConfig => _cmd_CopySpineObjectConfig ??= new( + args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.All), + CopySpineObjectConfig_CanExecute + ); private RelayCommand? _cmd_CopySpineObjectConfig; - private void CopySpineObjectConfig_Execute(IList? args) + /// + /// 复制模型参数 (仅皮肤) + /// + public RelayCommand Cmd_CopySpineObjectSkinConfig => _cmd_CopySpineObjectSkinConfig ??= new( + args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.Skin), + CopySpineObjectConfig_CanExecute + ); + private RelayCommand? _cmd_CopySpineObjectSkinConfig; + + /// + /// 复制模型参数 (仅插槽附件) + /// + public RelayCommand Cmd_CopySpineObjectSlotAttachmentConfig => _cmd_CopySpineObjectSlotAttachmentConfig ??= new( + args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.SlotAttachement), + CopySpineObjectConfig_CanExecute + ); + private RelayCommand? _cmd_CopySpineObjectSlotAttachmentConfig; + + /// + /// 复制模型参数 (仅插槽可见性) + /// + public RelayCommand Cmd_CopySpineObjectSlotVisibilityConfig => _cmd_CopySpineObjectSlotVisibilityConfig ??= new( + args => CopySpineObjectConfig_Execute(args, SpineObjectConfigApplyFlag.SlotVisibility), + CopySpineObjectConfig_CanExecute + ); + private RelayCommand? _cmd_CopySpineObjectSlotVisibilityConfig; + + private void CopySpineObjectConfig_Execute(IList? args, SpineObjectConfigApplyFlag flag) { if (!CopySpineObjectConfig_CanExecute(args)) return; var sp = (SpineObjectModel)args[0]; _copiedSpineObjectConfigModel = sp.ObjectConfig; - _logger.Info("Copy config from model: {0}", sp.Name); + _copiedConfigFlag = flag; + _logger.Info("Copy config[{0}] from model: {1}", flag, sp.Name); } private bool CopySpineObjectConfig_CanExecute(IList? args) @@ -381,8 +538,8 @@ namespace SpineViewer.ViewModels.MainWindow if (!ApplySpineObjectConfig_CanExecute(args)) return; foreach (SpineObjectModel sp in args) { - sp.ObjectConfig = _copiedSpineObjectConfigModel; - _logger.Info("Apply config to model: {0}", sp.Name); + sp.ApplyObjectConfig(_copiedSpineObjectConfigModel, _copiedConfigFlag); + _logger.Info("Apply config[{0}] to model: {1}", _copiedConfigFlag, sp.Name); } } @@ -439,124 +596,9 @@ namespace SpineViewer.ViewModels.MainWindow return true; } - /// - /// 从路径列表添加对象 - /// - /// 可以是文件和文件夹 - public void AddSpineObjectFromFileList(IEnumerable paths) - { - List validPaths = []; - foreach (var path in paths) - { - if (File.Exists(path)) - { - var lowerPath = path.ToLowerInvariant(); - if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith)) - validPaths.Add(path); - } - else if (Directory.Exists(path)) - { - foreach (var file in Directory.EnumerateFiles(path, "*.*", SearchOption.AllDirectories)) - { - var lowerPath = file.ToLowerInvariant(); - if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith)) - validPaths.Add(file); - } - } - } + #endregion - if (validPaths.Count > 1) - { - if (validPaths.Count > 100) - { - if (!MessagePopupService.OKCancel(string.Format(AppResource.Str_TooManyItemsToAddQuest, validPaths.Count))) - return; - } - ProgressService.RunAsync((pr, ct) => AddSpineObjectsTask( - validPaths.ToArray(), pr, ct), - AppResource.Str_AddSpineObjectsTitle - ); - } - else if (validPaths.Count > 0) - { - InsertSpineObject(validPaths[0]); - _logger.LogCurrentProcessMemoryUsage(); - } - } - - /// - /// 用于后台添加模型的任务方法 - /// - private void AddSpineObjectsTask(string[] paths, IProgressReporter reporter, CancellationToken ct) - { - int totalCount = paths.Length; - 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 skelPath = paths[i]; - reporter.ProgressText = $"[{i}/{totalCount}] {skelPath}"; - - if (InsertSpineObject(skelPath)) - success++; - else - error++; - - reporter.Done = i + 1; - reporter.ProgressText = $"[{i + 1}/{totalCount}] {skelPath}"; - _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(); - } - - /// - /// 安全地在列表头添加一个模型, 发生错误会输出日志 - /// - /// 是否添加成功 - private bool InsertSpineObject(string skelPath, string? atlasPath = null) - { - try - { - var sp = new SpineObjectModel(skelPath, atlasPath); - lock (_spineObjectModels.Lock) _spineObjectModels.Insert(0, sp); - if (Application.Current.Dispatcher.CheckAccess()) - { - RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); - RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp)); - } - else - { - Application.Current.Dispatcher.Invoke(() => - { - RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); - RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp)); - }); - } - return true; - } - catch (Exception ex) - { - _logger.Trace(ex.ToString()); - _logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message); - } - return false; - } + #region 工作区参数实现 public List LoadedSpineObjects { @@ -681,5 +723,7 @@ namespace SpineViewer.ViewModels.MainWindow } return false; } + + #endregion } } diff --git a/SpineViewer/Views/MainWindow.xaml b/SpineViewer/Views/MainWindow.xaml index dbc4429..98cb0c3 100644 --- a/SpineViewer/Views/MainWindow.xaml +++ b/SpineViewer/Views/MainWindow.xaml @@ -170,21 +170,32 @@ Command="{Binding Cmd_MoveDownSpineObject}" CommandParameter="{Binding PlacementTarget.SelectedItems, RelativeSource={RelativeSource AncestorType={x:Type ContextMenu}}}"/> - + - + + + - + - - + Date: Sun, 19 Oct 2025 20:18:02 +0800 Subject: [PATCH 12/13] update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f27ff94..5d4e46c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## v0.16.8 + +- 去除首次的最小化提示弹框 +- 窗口布局改变后实时保存 +- 增加侧边栏图标和折叠功能 +- 增加皮肤和插槽参数面板的全部启用/禁用菜单项 +- 修改窗口默认大小 +- 支持复制并应用单独的模型皮肤或插槽参数 + ## v0.16.7 - 修复空帧导致的包围盒计算错误 From 862926b43e8b1e13c9c64b1613dbe545c5b7f1e8 Mon Sep 17 00:00:00 2001 From: ww-rm Date: Sun, 19 Oct 2025 20:18:11 +0800 Subject: [PATCH 13/13] update to v0.16.8 --- Spine/Spine.csproj | 2 +- SpineViewer/SpineViewer.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Spine/Spine.csproj b/Spine/Spine.csproj index 1affb8e..8bcc50c 100644 --- a/Spine/Spine.csproj +++ b/Spine/Spine.csproj @@ -7,7 +7,7 @@ net8.0-windows $(SolutionDir)out false - 0.16.7 + 0.16.8 diff --git a/SpineViewer/SpineViewer.csproj b/SpineViewer/SpineViewer.csproj index f621c47..c839c81 100644 --- a/SpineViewer/SpineViewer.csproj +++ b/SpineViewer/SpineViewer.csproj @@ -7,7 +7,7 @@ net8.0-windows $(SolutionDir)out false - 0.16.7 + 0.16.8 WinExe true