修改结构

This commit is contained in:
ww-rm
2025-05-29 19:49:06 +08:00
parent 707bdf7d33
commit fffe69c49f
13 changed files with 15 additions and 33 deletions

View File

@@ -0,0 +1,410 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NLog;
using SFML.Audio;
using Spine;
using Spine.Exporters;
using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shell;
namespace SpineViewer.ViewModels.MainWindow
{
public class ExplorerListViewModel : ObservableObject
{
/// <summary>
/// 预览图的保存质量
/// </summary>
public static int PreviewQuality { get; set; } = 80;
/// <summary>
/// 缩略图文件名格式字符串, 需要一个参数
/// </summary>
public static string PreviewFileNameFormat => ".{0}.preview.webp";
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private readonly MainWindowViewModel _vmMain;
/// <summary>
/// 当前目录路径
/// </summary>
private string? _currentDirectory;
/// <summary>
/// 当前目录下文件项缓存
/// </summary>
private readonly List<ExplorerItemViewModel> _items = [];
public ExplorerListViewModel(MainWindowViewModel vmMain)
{
_vmMain = vmMain;
}
/// <summary>
/// 筛选字符串
/// </summary>
public string? FilterString
{
get => _filterString;
set
{
if (!SetProperty(ref _filterString, value)) return;
if (string.IsNullOrWhiteSpace(_filterString))
{
_shownItems = _items.ToList();
}
else
{
_shownItems = [];
_shownItems.AddRange(_items.Where(it => it.FileName.Contains(_filterString)));
}
OnPropertyChanged(nameof(ShownItems));
}
}
private string? _filterString;
/// <summary>
/// 当前目录下的所有子项文件, 含递归目录
/// </summary>
public List<ExplorerItemViewModel> ShownItems => _shownItems;
private List<ExplorerItemViewModel> _shownItems = [];
/// <summary>
/// 选择项, 显示某一项的具体信息和预览图
/// </summary>
public ExplorerItemViewModel? SelectedItem => _selectedItem;
private ExplorerItemViewModel? _selectedItem;
/// <summary>
/// 选择文件夹命令
/// </summary>
public RelayCommand Cmd_ChangeCurrentDirectory => _cmd_ChangeCurrentDirectory ??= new(() =>
{
if (OpenFolderService.OpenFolder(out var selectedPath))
{
_currentDirectory = selectedPath;
RefreshItems();
}
});
private RelayCommand? _cmd_ChangeCurrentDirectory;
public RelayCommand Cmd_RefreshItems => _cmd_RefreshItems ??= new(RefreshItems);
private RelayCommand? _cmd_RefreshItems;
/// <summary>
/// 选中项发生变化命令
/// </summary>
public RelayCommand<IList?> Cmd_SelectionChanged => _cmd_SelectionChanged ??= new(args =>
{
if (args is null || args.Count != 1)
{
SetProperty(ref _selectedItem, null, nameof(SelectedItem));
}
else
{
SetProperty(ref _selectedItem, args[0] as ExplorerItemViewModel, nameof(SelectedItem));
}
});
private RelayCommand<IList?>? _cmd_SelectionChanged;
/// <summary>
/// 右键菜单, 添加到模型列表
/// </summary>
public RelayCommand<IList?> Cmd_AddSelectedItems => _cmd_AddSelectedItems ??= new(AddSelectedItems_Execute, args => args is not null && args.Count > 0);
private RelayCommand<IList?>? _cmd_AddSelectedItems;
private void AddSelectedItems_Execute(IList? args)
{
if (args is null || args.Count <= 0) return;
_vmMain.SpineObjectListViewModel.AddSpineObjectFromFileList(args.Cast<ExplorerItemViewModel>().Select(m => m.FullPath).ToArray());
}
/// <summary>
/// 对参数项生成预览图
/// </summary>
public RelayCommand<IList?> Cmd_GeneratePreviews => _cmd_GeneratePreviews ??= new(GeneratePreview_Execute, args => args is not null && args.Count > 0);
private RelayCommand<IList?>? _cmd_GeneratePreviews;
private void GeneratePreview_Execute(IList? args)
{
if (args is null || args.Count <= 0) return;
if (args.Count <= 1)
{
var m = (ExplorerItemViewModel)args[0];
try
{
using var sp = new SpineObject(m.FullPath);
sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
var bounds = new SFML.Graphics.FloatRect(x, y, w, h).GetCanvasBounds(new(510, 510), 2);
using var exporter = new FrameExporter(512, 512)
{
Center = bounds.Position + bounds.Size / 2,
Size = new(bounds.Width, -bounds.Height),
Format = SkiaSharp.SKEncodedImageFormat.Webp,
Quality = PreviewQuality,
};
exporter.Export(m.PreviewFilePath, sp);
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to generate preview: {0}, {1}", m.PreviewFilePath, ex.Message);
}
_logger.LogCurrentProcessMemoryUsage();
}
else
{
ProgressService.RunAsync((pr, ct) => GeneratePreviewTask(
args.Cast<ExplorerItemViewModel>().ToArray(), pr, ct),
AppResource.Str_GeneratePreviewsTitle
);
}
}
private void GeneratePreviewTask(ExplorerItemViewModel[] models, IProgressReporter reporter, CancellationToken ct)
{
int totalCount = models.Length;
int success = 0;
int error = 0;
_vmMain.ProgressState = TaskbarItemProgressState.Normal;
_vmMain.ProgressValue = 0;
reporter.Total = totalCount;
reporter.Done = 0;
reporter.ProgressText = $"[0/{totalCount}]";
using var exporter = new FrameExporter(512, 512)
{
Format = SkiaSharp.SKEncodedImageFormat.Webp,
Quality = PreviewQuality,
};
for (int i = 0; i < totalCount; i++)
{
if (ct.IsCancellationRequested) break;
var m = models[i];
reporter.ProgressText = $"[{i}/{totalCount}] {m.FullPath}";
try
{
using var sp = new SpineObject(m.FullPath);
sp.Skeleton.GetBounds(out var x, out var y, out var w, out var h);
var bounds = new SFML.Graphics.FloatRect(x, y, w, h).GetCanvasBounds(new(510, 510), 2);
exporter.Center = bounds.Position + bounds.Size / 2;
exporter.Size = new(bounds.Width, -bounds.Height);
exporter.Export(m.PreviewFilePath, sp);
success++;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to generate preview: {0}, {1}", m.PreviewFilePath, ex.Message);
error++;
}
reporter.Done = i + 1;
reporter.ProgressText = $"[{i + 1}/{totalCount}] {m}";
_vmMain.ProgressValue = (i + 1f) / totalCount;
}
_vmMain.ProgressState = TaskbarItemProgressState.None;
if (error > 0)
_logger.Warn("Preview generation {0} successfully, {1} failed", success, error);
else
_logger.Info("{0} previews generated successfully", success);
_logger.LogCurrentProcessMemoryUsage();
}
/// <summary>
/// 删除参数项的预览图
/// </summary>
public RelayCommand<IList?> Cmd_DeletePreviews => _cmd_DeletePreviews ??= new(DeletePreview_Execute, args => args is not null && args.Count > 0);
private RelayCommand<IList?>? _cmd_DeletePreviews;
private void DeletePreview_Execute(IList? args)
{
if (args is null || args.Count <= 0) return;
if (!MessagePopupService.Quest(string.Format(AppResource.Str_DeleteItemsQuest, args.Count))) return;
if (args.Count <= 10)
{
foreach (var m in args.Cast<ExplorerItemViewModel>())
{
try
{
File.Delete(m.PreviewFilePath);
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to delete preview: {0}, {1}", m.PreviewFilePath, ex.Message);
}
}
_logger.LogCurrentProcessMemoryUsage();
}
else
{
ProgressService.RunAsync((pr, ct) => DeletePreviewTask(
args.Cast<ExplorerItemViewModel>().ToArray(), pr, ct),
AppResource.Str_DeletePreviewsTitle
);
}
}
private void DeletePreviewTask(ExplorerItemViewModel[] models, IProgressReporter reporter, CancellationToken ct)
{
int totalCount = models.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 m = models[i];
reporter.ProgressText = $"[{i}/{totalCount}] {m.FullPath}";
try
{
File.Delete(m.PreviewFilePath);
success++;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to delete preview: {0}, {1}", m.PreviewFilePath, ex.Message);
error++;
}
reporter.Done = i + 1;
reporter.ProgressText = $"[{i + 1}/{totalCount}] {m}";
_vmMain.ProgressValue = (i + 1f) / totalCount;
}
_vmMain.ProgressState = TaskbarItemProgressState.None;
if (error > 0)
_logger.Warn("Preview deletion {0} successfully, {1} failed", success, error);
else
_logger.Info("{0} previews deleted successfully", success);
_logger.LogCurrentProcessMemoryUsage();
}
/// <summary>
/// 刷新显示, 可以更新文件夹项缓存
/// </summary>
public void RefreshItems()
{
_items.Clear();
if (Directory.Exists(_currentDirectory))
{
try
{
foreach (var file in Directory.EnumerateFiles(_currentDirectory, "*.*", SearchOption.AllDirectories))
{
var lowerPath = file.ToLower();
if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith))
_items.Add(new(file));
}
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to enumerate files in dir: {0}, {1}", _currentDirectory, ex.Message);
}
}
_shownItems = [];
if (string.IsNullOrWhiteSpace(_filterString))
{
_shownItems = _items.ToList();
}
else
{
_shownItems = [];
_shownItems.AddRange(_items.Where(it => it.FileName.Contains(_filterString)));
}
OnPropertyChanged(nameof(ShownItems));
}
}
public class ExplorerItemViewModel : ObservableObject
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
public ExplorerItemViewModel(string path)
{
FullPath = Path.GetFullPath(path);
FileDirectory = Path.GetDirectoryName(FullPath) ?? "";
FileName = Path.GetFileName(FullPath);
PreviewFilePath = Path.Combine(FileDirectory, string.Format(ExplorerListViewModel.PreviewFileNameFormat, FileName));
}
/// <summary>
/// 完整路径
/// </summary
public string FullPath { get; }
/// <summary>
/// 文件所处目录
/// </summary>
public string FileDirectory { get; }
/// <summary>
/// 文件名
/// </summary>
public string FileName { get; }
/// <summary>
/// 预览图路径
/// </summary>
public string PreviewFilePath { get; }
/// <summary>
/// 预览图
/// </summary>
public ImageSource? PreviewImage
{
get
{
try
{
return WpfExtension.LoadWebpWithAlpha(PreviewFilePath);
}
catch (FileNotFoundException)
{
return null;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Warn("Failed to load preview image for {0}, {1}", FullPath, ex.Message);
return null;
}
}
}
}
}

View File

@@ -0,0 +1,109 @@
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 System.Windows.Shell;
namespace SpineViewer.ViewModels.MainWindow
{
/// <summary>
/// MainWindow 上下文对象
/// </summary>
public class MainWindowViewModel : ObservableObject
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
public MainWindowViewModel(ISFMLRenderer sfmlRenderer)
{
_sfmlRenderer = sfmlRenderer;
_explorerListViewModel = new(this);
_spineObjectListViewModel = new(this);
_sfmlRendererViewModel = new(this);
}
public string Title => $"SpineViewer - v{App.Version}";
/// <summary>
/// SFML 渲染对象
/// </summary>
public ISFMLRenderer SFMLRenderer => _sfmlRenderer;
private readonly ISFMLRenderer _sfmlRenderer;
public TaskbarItemProgressState ProgressState { get => _progressState; set => SetProperty(ref _progressState, value); }
private TaskbarItemProgressState _progressState = TaskbarItemProgressState.None;
public float ProgressValue { get => _progressValue; set => SetProperty(ref _progressValue, value); }
private float _progressValue = 0;
/// <summary>
/// 已加载的 Spine 对象
/// </summary>
public ObservableCollectionWithLock<SpineObjectModel> SpineObjects => _spineObjectModels;
private readonly ObservableCollectionWithLock<SpineObjectModel> _spineObjectModels = [];
/// <summary>
/// 浏览页列表 ViewModel
/// </summary>
public ExplorerListViewModel ExplorerListViewModel => _explorerListViewModel;
private readonly ExplorerListViewModel _explorerListViewModel;
/// <summary>
/// 模型列表 ViewModel
/// </summary>
public SpineObjectListViewModel SpineObjectListViewModel => _spineObjectListViewModel;
private readonly SpineObjectListViewModel _spineObjectListViewModel;
/// <summary>
/// 模型属性页 ViewModel
/// </summary>
public SpineObjectTabViewModel SpineObjectTabViewModel => _spineObjectTabViewModel;
private readonly SpineObjectTabViewModel _spineObjectTabViewModel = new();
/// <summary>
/// SFML 渲染 ViewModel
/// </summary>
public SFMLRendererViewModel SFMLRendererViewModel => _sfmlRendererViewModel;
private readonly SFMLRendererViewModel _sfmlRendererViewModel;
/// <summary>
/// 显示诊断信息对话框
/// </summary>
public RelayCommand Cmd_ShowDiagnosticsDialog => _cmd_ShowDiagnosticsDialog ??= new(() => { DiagnosticsDialogService.ShowDiagnosticsDialog(); });
private RelayCommand? _cmd_ShowDiagnosticsDialog;
/// <summary>
/// 显示关于对话框
/// </summary>
public RelayCommand Cmd_ShowAboutDialog => _cmd_ShowAboutDialog ??= new(() => { AboutDialogService.ShowAboutDialog(); });
private RelayCommand? _cmd_ShowAboutDialog;
/// <summary>
/// 调试命令
/// </summary>
public RelayCommand Cmd_Debug => _cmd_Debug ??= new(Debug_Execute);
private RelayCommand? _cmd_Debug;
private void Debug_Execute()
{
#if DEBUG
MessagePopupService.Quest("测试一下");
#endif
}
}
}

View File

@@ -0,0 +1,427 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using HandyControl.Expression.Shapes;
using NLog;
using SFMLRenderer;
using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace SpineViewer.ViewModels.MainWindow
{
public class SFMLRendererViewModel : ObservableObject
{
/// <summary>
/// 日志器
/// </summary>
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
private readonly MainWindowViewModel _vmMain;
private readonly ObservableCollectionWithLock<SpineObjectModel> _models;
private readonly ISFMLRenderer _renderer;
/// <summary>
/// 被选中对象的背景颜色
/// </summary>
private static readonly SFML.Graphics.Color _selectedBackgroundColor = new(255, 255, 255, 50);
/// <summary>
/// 被选中对象背景顶点缓冲区
/// </summary>
private readonly SFML.Graphics.VertexArray _selectedBackgroundVertices = new(SFML.Graphics.PrimitiveType.Quads, 4); // XXX: 暂时未使用 Dispose 释放
/// <summary>
/// 预览画面坐标轴颜色
/// </summary>
private static readonly SFML.Graphics.Color _axisColor = new(220, 220, 220);
/// <summary>
/// 坐标轴顶点缓冲区
/// </summary>
private readonly SFML.Graphics.VertexArray _axisVertices = new(SFML.Graphics.PrimitiveType.Lines, 2); // XXX: 暂时未使用 Dispose 释放
/// <summary>
/// 帧间隔计时器
/// </summary>
private readonly SFML.System.Clock _clock = new();
/// <summary>
/// 渲染任务
/// </summary>
private Task? _renderTask = null;
private CancellationTokenSource? _cancelToken = null;
/// <summary>
/// 快进时间量
/// </summary>
private float _forwardDelta = 0;
private readonly object _forwardDeltaLock = new();
/// <summary>
/// 临时变量, 记录拖放世界源点
/// </summary>
private SFML.System.Vector2f? _draggingSrc = null;
public SFMLRendererViewModel(MainWindowViewModel vmMain)
{
_vmMain = vmMain;
_models = _vmMain.SpineObjects;
_renderer = _vmMain.SFMLRenderer;
}
/// <summary>
/// 请求选中项发生变化
/// </summary>
public event NotifyCollectionChangedEventHandler? RequestSelectionChanging;
public uint ResolutionX
{
get => _renderer.Resolution.X;
set => SetProperty(_renderer.Resolution.X, value, _renderer, (r, v) => r.Resolution = new(v, r.Resolution.Y));
}
public uint ResolutionY
{
get => _renderer.Resolution.Y;
set => SetProperty(_renderer.Resolution.Y, value, _renderer, (r, v) => r.Resolution = new(r.Resolution.X, v));
}
public float CenterX
{
get => _renderer.Center.X;
set => SetProperty(_renderer.Center.X, value, _renderer, (r, v) => r.Center = new(v, r.Center.Y));
}
public float CenterY
{
get => _renderer.Center.Y;
set => SetProperty(_renderer.Center.Y, value, _renderer, (r, v) => r.Center = new(r.Center.X, v));
}
public float Zoom
{
get => _renderer.Zoom;
set => SetProperty(_renderer.Zoom, value, _renderer, (r, v) => r.Zoom = value);
}
public float Rotation
{
get => _renderer.Rotation;
set => SetProperty(_renderer.Rotation, value, _renderer, (r, v) => r.Rotation = value);
}
public bool FlipX
{
get => _renderer.FlipX;
set => SetProperty(_renderer.FlipX, value, _renderer, (r, v) => r.FlipX = value);
}
public bool FlipY
{
get => _renderer.FlipY;
set => SetProperty(_renderer.FlipY, value, _renderer, (r, v) => r.FlipY = value);
}
public uint MaxFps
{
get => _renderer.MaxFps;
set => SetProperty(_renderer.MaxFps, value, _renderer, (r, v) => r.MaxFps = value);
}
public bool ShowAxis
{
get => _showAxis;
set => SetProperty(ref _showAxis, value);
}
private bool _showAxis = true;
public Color BackgroundColor
{
get => Color.FromRgb(_backgroundColor.R, _backgroundColor.G, _backgroundColor.B);
set => SetProperty(BackgroundColor, value, this, (m, v) => m._backgroundColor = new(value.R, value.G, value.B));
}
private SFML.Graphics.Color _backgroundColor = new(105, 105, 105);
public bool RenderSelectedOnly
{
get => _renderSelectedOnly;
set => SetProperty(ref _renderSelectedOnly, value);
}
private bool _renderSelectedOnly = false;
public bool IsUpdating
{
get => _isUpdating;
private set
{
if (value == _isUpdating) return;
_isUpdating = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Geo_PlayPause));
}
}
private bool _isUpdating = true;
public RelayCommand Cmd_Stop => _cmd_Stop ??= new(() =>
{
IsUpdating = false;
lock (_models) foreach (var sp in _models) sp.ResetAnimationsTime();
});
private RelayCommand? _cmd_Stop;
public RelayCommand Cmd_PlayPause => _cmd_PlayPause ??= new(() => IsUpdating = !IsUpdating);
private RelayCommand? _cmd_PlayPause;
public Geometry Geo_PlayPause => _isUpdating ? AppResource.Geo_Pause : AppResource.Geo_Play;
public RelayCommand Cmd_Restart => _cmd_Restart ??= new(() =>
{
lock (_models) foreach (var sp in _models) sp.ResetAnimationsTime();
IsUpdating = true;
});
private RelayCommand? _cmd_Restart;
public RelayCommand Cmd_ForwardStep => _cmd_ForwardStep ??= new(() =>
{
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 1f / _renderer.MaxFps : 0.001f;
});
private RelayCommand? _cmd_ForwardStep;
public RelayCommand Cmd_ForwardFast => _cmd_ForwardFast ??= new(() =>
{
lock (_forwardDeltaLock) _forwardDelta += _renderer.MaxFps > 0 ? 10f / _renderer.MaxFps : 0.01f;
});
private RelayCommand? _cmd_ForwardFast;
public void CanvasMouseWheelScrolled(object? s, SFML.Window.MouseWheelScrollEventArgs e)
{
var factor = e.Delta > 0 ? 1.1f : 0.9f;
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0)
{
Zoom = Math.Clamp(Zoom * factor, 0.001f, 1000f); // 滚轮缩放限制一下缩放范围
}
else
{
lock (_models.Lock)
{
foreach (var sp in _models.Where(it => it.IsShown && it.IsSelected))
{
sp.Scale = Math.Clamp(sp.Scale * factor, 0.001f, 1000f); // 滚轮缩放限制一下缩放范围
}
}
}
}
public void CanvasMouseButtonPressed(object? s, SFML.Window.MouseButtonEventArgs e)
{
if (e.Button == SFML.Window.Mouse.Button.Right)
{
_draggingSrc = _renderer.MapPixelToCoords(new(e.X, e.Y));
}
else if (e.Button == SFML.Window.Mouse.Button.Left && !SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Right))
{
var _src = _renderer.MapPixelToCoords(new(e.X, e.Y));
var src = new Point(_src.X, _src.Y);
_draggingSrc = _src;
lock (_models.Lock)
{
// 仅渲染选中模式禁止在画面里选择对象
if (_renderSelectedOnly)
{
// 只在被选中的对象里判断是否有效命中
bool hit = _models.Any(m => m.IsSelected && m.GetCurrentBounds().Contains(src));
// 如果没点到被选中的模型, 则不允许拖动
if (!hit) _draggingSrc = null;
}
else
{
if ((Keyboard.Modifiers & ModifierKeys.Control) == 0)
{
// 没按 Ctrl 的情况下, 如果命中了已选中对象, 则就算普通命中
bool hit = false;
foreach (var sp in _models)
{
if (!sp.IsShown) continue;
if (!sp.GetCurrentBounds().Contains(src)) continue;
hit = true;
// 如果点到了没被选中的东西, 则清空原先选中的, 改为只选中这一次点的
if (!sp.IsSelected)
{
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
}
break;
}
// 如果点了空白的地方, 就清空选中列表
if (!hit) RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
}
else
{
// 按下 Ctrl 的情况就执行多选, 并且点空白处也不会清空选中, 如果点击了本来就是选中的则取消选中
if (_models.FirstOrDefault(m => m.IsShown && m.GetCurrentBounds().Contains(src), null) is SpineObjectModel sp)
{
if (sp.IsSelected)
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Remove, sp));
else
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
}
}
}
}
}
}
public void CanvasMouseMove(object? s, SFML.Window.MouseMoveEventArgs e)
{
if (_draggingSrc is null) return;
var src = (SFML.System.Vector2f)_draggingSrc;
var dst = _renderer.MapPixelToCoords(new(e.X, e.Y));
var delta = dst - src;
if (SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Right))
{
_renderer.Center -= delta;
OnPropertyChanged(nameof(CenterX));
OnPropertyChanged(nameof(CenterY));
}
else if (SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Left))
{
lock (_models.Lock)
{
foreach (var sp in _models.Where(it => it.IsShown && it.IsSelected))
{
sp.X += delta.X;
sp.Y += delta.Y;
}
}
_draggingSrc = dst;
}
}
public void CanvasMouseButtonReleased(object? s, SFML.Window.MouseButtonEventArgs e)
{
if (e.Button == SFML.Window.Mouse.Button.Right)
{
_draggingSrc = null;
}
else if (e.Button == SFML.Window.Mouse.Button.Left && !SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Right))
{
_draggingSrc = null;
}
}
public void StartRender()
{
if (_renderTask is not null) return;
_cancelToken = new();
_renderTask = new Task(RenderTask, _cancelToken.Token, TaskCreationOptions.LongRunning);
_renderTask.Start();
IsUpdating = true;
}
public void StopRender()
{
IsUpdating = false;
if (_renderTask is null || _cancelToken is null) return;
_cancelToken.Cancel();
_renderTask.Wait();
_cancelToken = null;
_renderTask = null;
}
private void RenderTask()
{
try
{
_renderer.SetActive(true);
float delta;
while (!_cancelToken?.IsCancellationRequested ?? false)
{
delta = _clock.ElapsedTime.AsSeconds();
_clock.Restart();
// 停止更新的时候只是时间不前进, 但是坐标变换还是要更新, 否则无法移动对象
if (!_isUpdating) delta = 0;
// 加上要快进的量
lock (_forwardDeltaLock)
{
delta += _forwardDelta;
_forwardDelta = 0;
}
_renderer.Clear(_backgroundColor);
if (_showAxis)
{
// 画一个很长的坐标轴, 用 1e9 比较合适
_axisVertices[0] = new(new(-1e9f, 0), _axisColor);
_axisVertices[1] = new(new(1e9f, 0), _axisColor);
_renderer.Draw(_axisVertices);
_axisVertices[0] = new(new(0, -1e9f), _axisColor);
_axisVertices[1] = new(new(0, 1e9f), _axisColor);
_renderer.Draw(_axisVertices);
}
// 渲染 Spine
lock (_models.Lock)
{
foreach (var sp in _models.Where(sp => sp.IsShown && (!_renderSelectedOnly || sp.IsSelected)).Reverse())
{
if (_cancelToken?.IsCancellationRequested ?? true) break; // 提前中止
sp.Update(0); // 避免物理效果出现问题
sp.Update(delta);
// 为选中对象绘制一个半透明背景
if (sp.IsSelected)
{
var rc = sp.GetCurrentBounds().ToFloatRect();
_selectedBackgroundVertices[0] = new(new(rc.Left, rc.Top), _selectedBackgroundColor);
_selectedBackgroundVertices[1] = new(new(rc.Left + rc.Width, rc.Top), _selectedBackgroundColor);
_selectedBackgroundVertices[2] = new(new(rc.Left + rc.Width, rc.Top + rc.Height), _selectedBackgroundColor);
_selectedBackgroundVertices[3] = new(new(rc.Left, rc.Top + rc.Height), _selectedBackgroundColor);
_renderer.Draw(_selectedBackgroundVertices);
}
// 仅在预览画面临时启用调试模式
sp.EnableDebug = true;
_renderer.Draw(sp);
sp.EnableDebug = false;
}
}
_renderer.Display();
}
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Fatal("Render task stopped, {0}", ex.Message);
MessagePopupService.Error(ex.ToString());
}
finally
{
_renderer.SetActive(false);
}
}
}
}

View File

@@ -0,0 +1,356 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NLog;
using Spine;
using SpineViewer.Extensions;
using SpineViewer.Models;
using SpineViewer.Resources;
using SpineViewer.Services;
using SpineViewer.ViewModels.Exporters;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Shell;
namespace SpineViewer.ViewModels.MainWindow
{
public class SpineObjectListViewModel : ObservableObject
{
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// 主窗口视图模型引用
/// </summary>
private readonly MainWindowViewModel _vmMain;
/// <summary>
/// 临时对象, 存储复制的模型参数
/// </summary>
private SpineObjectConfigModel? _copiedSpineObjectConfigModel = null;
public SpineObjectListViewModel(MainWindowViewModel mainViewModel)
{
_vmMain = mainViewModel;
_spineObjectModels = _vmMain.SpineObjects; // 缓存对象
_frameExporterViewModel = new(_vmMain);
_frameSequenceExporterViewModel = new(_vmMain);
_ffmpegVideoExporterViewModel = new(_vmMain);
_customFFmpegExporterViewModel = new(_vmMain);
}
/// <summary>
/// 单帧导出 ViewModel
/// </summary>
public FrameExporterViewModel FrameExporterViewModel => _frameExporterViewModel;
private readonly FrameExporterViewModel _frameExporterViewModel;
/// <summary>
/// 帧序列 ViewModel
/// </summary>
public FrameSequenceExporterViewModel FrameSequenceExporterViewModel => _frameSequenceExporterViewModel;
private readonly FrameSequenceExporterViewModel _frameSequenceExporterViewModel;
/// <summary>
/// 动图/视频 ViewModel
/// </summary>
public FFmpegVideoExporterViewModel FFmpegVideoExporterViewModel => _ffmpegVideoExporterViewModel;
private readonly FFmpegVideoExporterViewModel _ffmpegVideoExporterViewModel;
/// <summary>
/// 动图/视频 ViewModel
/// </summary>
public CustomFFmpegExporterViewModel CustomFFmpegExporterViewModel => _customFFmpegExporterViewModel;
private readonly CustomFFmpegExporterViewModel _customFFmpegExporterViewModel;
/// <summary>
/// 已加载的 Spine 对象
/// </summary>
public ObservableCollectionWithLock<SpineObjectModel> SpineObjects => _spineObjectModels;
private readonly ObservableCollectionWithLock<SpineObjectModel> _spineObjectModels;
/// <summary>
/// 列表视图选中项发生改变时同步内部模型列表状态
/// </summary>
public RelayCommand<IList?> Cmd_ListViewSelectionChanged => _cmd_ListViewSelectionChanged ??= new(ListViewSelectionChanged_Execute);
private RelayCommand<IList?>? _cmd_ListViewSelectionChanged;
private void ListViewSelectionChanged_Execute(IList? args)
{
if (args is null) return;
lock (_spineObjectModels.Lock)
{
var selectedItems = args.Cast<SpineObjectModel>().ToArray();
foreach (var it in _spineObjectModels.Except(selectedItems)) it.IsSelected = false;
foreach (var it in selectedItems) it.IsSelected = true;
_vmMain.SpineObjectTabViewModel.SelectedObjects = selectedItems;
}
}
/// <summary>
/// 弹窗添加单模型命令
/// </summary>
public RelayCommand Cmd_AddSpineObject => _cmd_AddSpineObject ??= new(AddSpineObject_Execute);
private RelayCommand? _cmd_AddSpineObject;
private void AddSpineObject_Execute()
{
throw new NotImplementedException();
}
/// <summary>
/// 移除给定模型
/// </summary>
public RelayCommand<IList?> Cmd_RemoveSpineObject => _cmd_RemoveSpineObject ??= new(RemoveSpineObject_Execute, RemoveSpineObject_CanExecute);
private RelayCommand<IList?>? _cmd_RemoveSpineObject;
private void RemoveSpineObject_Execute(IList? args)
{
if (args is null) return;
if (args.Count > 1)
{
if (!MessagePopupService.Quest(string.Format(AppResource.Str_RemoveItemsQuest, args.Count)))
return;
}
lock (_spineObjectModels.Lock)
{
// XXX: 这里必须要浅拷贝一次, 不能直接对会被修改的绑定数据 args 进行 foreach 遍历
foreach (var sp in args.Cast<SpineObjectModel>().ToArray())
{
_spineObjectModels.Remove(sp);
sp.Dispose();
}
}
}
private bool RemoveSpineObject_CanExecute(IList? args)
{
if (args is null) return false;
if (args.Count <= 0) return false;
return true;
}
/// <summary>
/// 模型上移一位
/// </summary>
public RelayCommand<IList?> Cmd_MoveUpSpineObject => _cmd_MoveUpSpineObject ??= new(MoveUpSpineObject_Execute, MoveUpSpineObject_CanExecute);
private RelayCommand<IList?>? _cmd_MoveUpSpineObject;
private void MoveUpSpineObject_Execute(IList? args)
{
if (args is null) return;
if (args.Count != 1) return;
var sp = (SpineObjectModel)args[0];
lock (_spineObjectModels.Lock)
{
var idx = _spineObjectModels.IndexOf(sp);
if (idx <= 0) return;
_spineObjectModels.Move(idx, idx - 1);
}
}
private bool MoveUpSpineObject_CanExecute(IList? args)
{
if (args is null) return false;
if (args.Count != 1) return false;
return true;
}
/// <summary>
/// 模型下移一位
/// </summary>
public RelayCommand<IList?> Cmd_MoveDownSpineObject => _cmd_MoveDownSpineObject ??= new(MoveDownSpineObject_Execute, MoveDownSpineObject_CanExecute);
private RelayCommand<IList?>? _cmd_MoveDownSpineObject;
private void MoveDownSpineObject_Execute(IList? args)
{
if (args is null) return;
if (args.Count != 1) return;
var sp = (SpineObjectModel)args[0];
lock (_spineObjectModels.Lock)
{
var idx = _spineObjectModels.IndexOf(sp);
if (idx < 0 || idx >= _spineObjectModels.Count - 1) return;
_spineObjectModels.Move(idx, idx + 1);
}
}
private bool MoveDownSpineObject_CanExecute(IList? args)
{
if (args is null) return false;
if (args.Count != 1) return false;
return true;
}
/// <summary>
/// 从剪贴板文件列表添加模型
/// </summary>
public RelayCommand Cmd_AddSpineObjectFromClipboard => _cmd_AddSpineObjectFromClipboard ??= new(AddSpineObjectFromClipboard_Execute);
private RelayCommand? _cmd_AddSpineObjectFromClipboard;
private void AddSpineObjectFromClipboard_Execute()
{
if (!Clipboard.ContainsFileDropList()) return;
AddSpineObjectFromFileList(Clipboard.GetFileDropList().Cast<string>().ToArray());
}
/// <summary>
/// 复制模型参数
/// </summary>
public RelayCommand<IList?> Cmd_CopySpineObjectConfig => _cmd_CopySpineObjectConfig ??= new(CopySpineObjectConfig_Execute, CopySpineObjectConfig_CanExecute);
private RelayCommand<IList?>? _cmd_CopySpineObjectConfig;
private void CopySpineObjectConfig_Execute(IList? args)
{
if (args is null) return;
if (args.Count != 1) return;
var sp = (SpineObjectModel)args[0];
_copiedSpineObjectConfigModel = sp.Dump();
_logger.Info("Copy config from model: {0}", sp.Name);
}
private bool CopySpineObjectConfig_CanExecute(IList? args)
{
if (args is null) return false;
if (args.Count != 1) return false;
return true;
}
/// <summary>
/// 应用复制的模型参数
/// </summary>
public RelayCommand<IList?> Cmd_ApplySpineObjectConfig => _cmd_ApplySpineObjectConfig ??= new(ApplySpineObjectConfig_Execute, ApplySpineObjectConfig_CanExecute);
private RelayCommand<IList?>? _cmd_ApplySpineObjectConfig;
private void ApplySpineObjectConfig_Execute(IList? args)
{
if (_copiedSpineObjectConfigModel is null) return;
if (args is null) return;
if (args.Count <= 0) return;
foreach (SpineObjectModel sp in args)
{
sp.Load(_copiedSpineObjectConfigModel);
_logger.Info("Apply config to model: {0}", sp.Name);
}
}
private bool ApplySpineObjectConfig_CanExecute(IList? args)
{
if (_copiedSpineObjectConfigModel is null) return false;
if (args is null) return false;
if (args.Count <= 0) return false;
return true;
}
/// <summary>
/// 从路径列表添加对象
/// </summary>
/// <param name="paths">可以是文件和文件夹</param>
public void AddSpineObjectFromFileList(IEnumerable<string> paths)
{
List<string> validPaths = [];
foreach (var path in paths)
{
if (File.Exists(path))
{
var lowerPath = path.ToLower();
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.ToLower();
if (SpineObject.PossibleSuffixMapping.Keys.Any(lowerPath.EndsWith))
validPaths.Add(file);
}
}
}
if (validPaths.Count > 1)
{
if (validPaths.Count > 100)
{
if (!MessagePopupService.Quest(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)
{
var skelPath = validPaths[0];
try
{
var sp = new SpineObjectModel(skelPath);
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
}
_logger.LogCurrentProcessMemoryUsage();
}
}
/// <summary>
/// 用于后台添加模型的任务方法
/// </summary>
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}";
try
{
var sp = new SpineObjectModel(skelPath);
lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp);
success++;
}
catch (Exception ex)
{
_logger.Trace(ex.ToString());
_logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message);
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();
}
}
}

View File

@@ -0,0 +1,830 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Spine;
using Spine.SpineWrappers;
using SpineViewer.Models;
using System.Collections;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows;
namespace SpineViewer.ViewModels.MainWindow
{
public class SpineObjectTabViewModel : ObservableObject
{
private SpineObjectModel[] _selectedObjects = [];
private readonly ObservableCollection<SkinViewModel> _skins = [];
private readonly ObservableCollection<SlotAttachmentViewModel> _slots = [];
private readonly ObservableCollection<AnimationTrackViewModel> _animationTracks = [];
public ImmutableArray<ISkeleton.Physics> PhysicsOptions { get; } = Enum.GetValues<ISkeleton.Physics>().ToImmutableArray();
public SpineObjectModel[] SelectedObjects
{
get => _selectedObjects;
set
{
if (ReferenceEquals(_selectedObjects, value)) return;
// 清空之前的所有内容
foreach (var obj in _selectedObjects)
{
obj.PropertyChanged -= SingleModel_PropertyChanged;
obj.AnimationChanged -= SingleModel_AnimationChanged;
}
_skins.Clear();
_slots.Clear();
_animationTracks.Clear();
// 生成新的内容
_selectedObjects = value ?? [];
if (_selectedObjects.Length > 0)
{
foreach (var obj in _selectedObjects)
{
obj.PropertyChanged += SingleModel_PropertyChanged;
obj.AnimationChanged += SingleModel_AnimationChanged;
}
IEnumerable<string> commonSkinNames = _selectedObjects[0].Skins;
foreach (var obj in _selectedObjects.Skip(1)) commonSkinNames = commonSkinNames.Intersect(obj.Skins);
foreach (var name in commonSkinNames) _skins.Add(new(name, _selectedObjects));
IEnumerable<string> commonSlotNames = _selectedObjects[0].SlotAttachments.Keys;
foreach (var obj in _selectedObjects.Skip(1)) commonSlotNames = commonSlotNames.Intersect(obj.SlotAttachments.Keys);
foreach (var name in commonSlotNames) _slots.Add(new(name, _selectedObjects));
IEnumerable<int> commonTrackIndices = _selectedObjects[0].GetTrackIndices();
foreach (var obj in _selectedObjects.Skip(1)) commonTrackIndices = commonTrackIndices.Intersect(obj.GetTrackIndices());
foreach (var idx in commonTrackIndices) _animationTracks.Add(new(idx, _selectedObjects));
}
OnPropertyChanged();
Cmd_AppendTrack.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(Version));
OnPropertyChanged(nameof(AssetsDir));
OnPropertyChanged(nameof(SkelPath));
OnPropertyChanged(nameof(AtlasPath));
OnPropertyChanged(nameof(Name));
OnPropertyChanged(nameof(FileVersion));
OnPropertyChanged(nameof(IsShown));
OnPropertyChanged(nameof(UsePma));
OnPropertyChanged(nameof(Physics));
OnPropertyChanged(nameof(Scale));
OnPropertyChanged(nameof(FlipX));
OnPropertyChanged(nameof(FlipY));
OnPropertyChanged(nameof(X));
OnPropertyChanged(nameof(Y));
OnPropertyChanged(nameof(DebugTexture));
OnPropertyChanged(nameof(DebugBounds));
OnPropertyChanged(nameof(DebugBones));
OnPropertyChanged(nameof(DebugRegions));
OnPropertyChanged(nameof(DebugMeshHulls));
OnPropertyChanged(nameof(DebugMeshes));
OnPropertyChanged(nameof(DebugBoundingBoxes));
OnPropertyChanged(nameof(DebugPaths));
OnPropertyChanged(nameof(DebugPoints));
OnPropertyChanged(nameof(DebugClippings));
}
}
public SpineVersion? Version
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].Version;
if (_selectedObjects.Skip(1).Any(it => it.Version != val)) return null;
return val;
}
}
public string? AssetsDir
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].AssetsDir;
if (_selectedObjects.Skip(1).Any(it => it.AssetsDir != val)) return null;
return val;
}
}
public string? SkelPath
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].SkelPath;
if (_selectedObjects.Skip(1).Any(it => it.SkelPath != val)) return null;
return val;
}
}
public string? AtlasPath
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].AtlasPath;
if (_selectedObjects.Skip(1).Any(it => it.AtlasPath != val)) return null;
return val;
}
}
public string? Name
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].Name;
if (_selectedObjects.Skip(1).Any(it => it.Name != val)) return null;
return val;
}
}
public string? FileVersion
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].FileVersion;
if (_selectedObjects.Skip(1).Any(it => it.FileVersion != val)) return null;
return val;
}
}
public bool? IsShown
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].IsShown;
if (_selectedObjects.Skip(1).Any(it => it.IsShown != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.IsShown = (bool)value;
OnPropertyChanged();
}
}
public bool? UsePma
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].UsePma;
if (_selectedObjects.Skip(1).Any(it => it.UsePma != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.UsePma = (bool)value;
OnPropertyChanged();
}
}
public ISkeleton.Physics? Physics
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].Physics;
if (_selectedObjects.Skip(1).Any(it => it.Physics != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.Physics = (ISkeleton.Physics)value;
OnPropertyChanged();
}
}
public float? Scale
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].Scale;
if (_selectedObjects.Skip(1).Any(it => it.Scale != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.Scale = (float)value;
OnPropertyChanged();
}
}
public bool? FlipX
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].FlipX;
if (_selectedObjects.Skip(1).Any(it => it.FlipX != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.FlipX = (bool)value;
OnPropertyChanged();
}
}
public bool? FlipY
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].FlipY;
if (_selectedObjects.Skip(1).Any(it => it.FlipY != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.FlipY = (bool)value;
OnPropertyChanged();
}
}
public float? X
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].X;
if (_selectedObjects.Skip(1).Any(it => it.X != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.X = (float)value;
OnPropertyChanged();
}
}
public float? Y
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].Y;
if (_selectedObjects.Skip(1).Any(it => it.Y != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.Y = (float)value;
OnPropertyChanged();
}
}
public ObservableCollection<SkinViewModel> Skins => _skins;
public RelayCommand<IList?> Cmd_EnableSkins { get; } = new(
args => { if (args is null) return; foreach (var s in args.OfType<SkinViewModel>()) s.Status = true; },
args => { return args is not null && args.OfType<SkinViewModel>().Any(); }
);
public RelayCommand<IList?> Cmd_DisableSkins { get; } = new(
args => { if (args is null) return; foreach (var s in args.OfType<SkinViewModel>()) s.Status = false; },
args => { return args is not null && args.OfType<SkinViewModel>().Any(); }
);
public ObservableCollection<SlotAttachmentViewModel> Slots => _slots;
public RelayCommand<IList?> Cmd_ClearSlotsAttachment { get; } = new(
args => { if (args is null) return; foreach (var s in args.OfType<SlotAttachmentViewModel>()) s.AttachmentName = null; },
args => { return args is not null && args.OfType<SlotAttachmentViewModel>().Any(); }
);
public ObservableCollection<AnimationTrackViewModel> AnimationTracks => _animationTracks;
public RelayCommand Cmd_AppendTrack => _cmd_AppendTrack ??= new(
() =>
{
if (_selectedObjects.Length != 1) return;
var sp = _selectedObjects[0];
if (sp.Animations.Length <= 0) return;
sp.SetAnimation(sp.GetTrackIndices().LastOrDefault(-1) + 1, sp.Animations[0]);
},
() => { return _selectedObjects.Length == 1; }
);
private RelayCommand? _cmd_AppendTrack;
public RelayCommand<IList?> Cmd_InsertTrack => _cmd_InsertTrack ??= new(
args =>
{
if (_selectedObjects.Length != 1) return;
var sp = _selectedObjects[0];
if (sp.Animations.Length <= 0) return;
if (args is null) return;
if (args.Count != 1) return;
if (args[0] is not AnimationTrackViewModel vm) return;
var idx = vm.TrackIndex;
if (idx <= 0) return;
if (sp.GetTrackIndices().Contains(idx - 1)) return;
sp.SetAnimation(idx - 1, sp.Animations[0]);
},
args =>
{
if (_selectedObjects.Length != 1) return false;
var sp = _selectedObjects[0];
if (sp.Animations.Length <= 0) return false;
if (args is null) return false;
if (args.Count != 1) return false;
if (args[0] is not AnimationTrackViewModel vm) return false;
var idx = vm.TrackIndex;
if (idx <= 0) return false;
if (sp.GetTrackIndices().Contains(idx - 1)) return false;
return true;
}
);
private RelayCommand<IList?>? _cmd_InsertTrack;
public bool? DebugTexture
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugTexture;
if (_selectedObjects.Skip(1).Any(it => it.DebugTexture != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugTexture = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugBounds
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugBounds;
if (_selectedObjects.Skip(1).Any(it => it.DebugBounds != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugBounds = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugBones
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugBones;
if (_selectedObjects.Skip(1).Any(it => it.DebugBones != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugBones = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugRegions
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugRegions;
if (_selectedObjects.Skip(1).Any(it => it.DebugRegions != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugRegions = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugMeshHulls
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugMeshHulls;
if (_selectedObjects.Skip(1).Any(it => it.DebugMeshHulls != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugMeshHulls = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugMeshes
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugMeshes;
if (_selectedObjects.Skip(1).Any(it => it.DebugMeshes != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugMeshes = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugBoundingBoxes
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugBoundingBoxes;
if (_selectedObjects.Skip(1).Any(it => it.DebugBoundingBoxes != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugBoundingBoxes = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugPaths
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugPaths;
if (_selectedObjects.Skip(1).Any(it => it.DebugPaths != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugPaths = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugPoints
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugPoints;
if (_selectedObjects.Skip(1).Any(it => it.DebugPoints != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugPoints = (bool)value;
OnPropertyChanged();
}
}
public bool? DebugClippings
{
get
{
if (_selectedObjects.Length <= 0) return null;
var val = _selectedObjects[0].DebugClippings;
if (_selectedObjects.Skip(1).Any(it => it.DebugClippings != val)) return null;
return val;
}
set
{
if (_selectedObjects.Length <= 0) return;
if (value is null) return;
foreach (var sp in _selectedObjects) sp.DebugClippings = (bool)value;
OnPropertyChanged();
}
}
/// <summary>
/// 监听单个模型属性发生变化, 则更新聚合属性值
/// </summary>
private void SingleModel_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SpineObjectModel.IsShown)) OnPropertyChanged(nameof(IsShown));
else if (e.PropertyName == nameof(SpineObjectModel.UsePma)) OnPropertyChanged(nameof(UsePma));
else if (e.PropertyName == nameof(SpineObjectModel.Physics)) OnPropertyChanged(nameof(Physics));
else if (e.PropertyName == nameof(SpineObjectModel.Scale)) OnPropertyChanged(nameof(Scale));
else if (e.PropertyName == nameof(SpineObjectModel.FlipX)) OnPropertyChanged(nameof(FlipX));
else if (e.PropertyName == nameof(SpineObjectModel.FlipY)) OnPropertyChanged(nameof(FlipY));
else if (e.PropertyName == nameof(SpineObjectModel.X)) OnPropertyChanged(nameof(X));
else if (e.PropertyName == nameof(SpineObjectModel.Y)) OnPropertyChanged(nameof(Y));
// Skins 变化在 SkinViewModel 中监听
// Slots 变化在 SlotAttachmentViewModel 中监听
// AnimationTracks 变化在 AnimationTrackViewModel 中监听
else if (e.PropertyName == nameof(SpineObjectModel.DebugTexture)) OnPropertyChanged(nameof(DebugTexture));
else if (e.PropertyName == nameof(SpineObjectModel.DebugBounds)) OnPropertyChanged(nameof(DebugBounds));
else if (e.PropertyName == nameof(SpineObjectModel.DebugBones)) OnPropertyChanged(nameof(DebugBones));
else if (e.PropertyName == nameof(SpineObjectModel.DebugRegions)) OnPropertyChanged(nameof(DebugRegions));
else if (e.PropertyName == nameof(SpineObjectModel.DebugMeshHulls)) OnPropertyChanged(nameof(DebugMeshHulls));
else if (e.PropertyName == nameof(SpineObjectModel.DebugMeshes)) OnPropertyChanged(nameof(DebugMeshes));
else if (e.PropertyName == nameof(SpineObjectModel.DebugBoundingBoxes)) OnPropertyChanged(nameof(DebugBoundingBoxes));
else if (e.PropertyName == nameof(SpineObjectModel.DebugPaths)) OnPropertyChanged(nameof(DebugPaths));
else if (e.PropertyName == nameof(SpineObjectModel.DebugPoints)) OnPropertyChanged(nameof(DebugPoints));
else if (e.PropertyName == nameof(SpineObjectModel.DebugClippings)) OnPropertyChanged(nameof(DebugClippings));
}
/// <summary>
/// 监听单个模型动画轨道发生变化, 则重建聚合后的动画列表
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void SingleModel_AnimationChanged(object? sender, AnimationChangedEventArgs e)
{
// XXX: 这里应该有更好的实现, 当 e.AnimationName == null 的时候代表删除轨道需要重新构建列表
// 但是目前无法识别是否增加了轨道, 因此总是重建列表
// 由于某些原因, 直接使用 Clear 会和 UI 逻辑冲突产生报错, 因此需要放到 Dispatcher 里延迟执行
Application.Current.Dispatcher.BeginInvoke(
() =>
{
_animationTracks.Clear();
IEnumerable<int> commonTrackIndices = _selectedObjects[0].GetTrackIndices();
foreach (var obj in _selectedObjects.Skip(1)) commonTrackIndices = commonTrackIndices.Intersect(obj.GetTrackIndices());
foreach (var idx in commonTrackIndices) _animationTracks.Add(new(idx, _selectedObjects));
}
);
}
public class SkinViewModel : ObservableObject
{
private readonly SpineObjectModel[] _spines;
private readonly string _name;
public SkinViewModel(string name, SpineObjectModel[] spines)
{
_spines = spines;
_name = name;
// 使用弱引用, 则此 ViewModel 被释放时无需显式退订事件
foreach (var sp in _spines)
{
WeakEventManager<SpineObjectModel, SkinStatusChangedEventArgs>.AddHandler(
sp,
nameof(sp.SkinStatusChanged),
SingleModel_SkinStatusChanged
);
}
}
public string Name => _name;
public bool? Status
{
get
{
if (_spines.Length <= 0) return null;
var val = _spines[0].GetSkinStatus(_name);
if (_spines.Skip(1).Any(it => it.GetSkinStatus(_name) != val)) return null;
return val;
}
set
{
if (_spines.Length <= 0) return;
if (value is null) return;
bool changed = false;
foreach (var sp in _spines) if (sp.SetSkinStatus(_name, (bool)value)) changed = true;
if (changed) OnPropertyChanged();
}
}
private void SingleModel_SkinStatusChanged(object? sender, SkinStatusChangedEventArgs e)
{
if (e.Name == _name) OnPropertyChanged(nameof(Status));
}
}
public class SlotAttachmentViewModel : ObservableObject
{
private readonly SpineObjectModel[] _spines;
private readonly string[] _attachmentNames = [];
private readonly string _slotName;
public SlotAttachmentViewModel(string slotName, SpineObjectModel[] spines)
{
_spines = spines;
_slotName = slotName;
if (_spines.Length > 0)
{
IEnumerable<string> attachmentNames = _spines[0].SlotAttachments[_slotName];
foreach (var sp in _spines.Skip(1))
attachmentNames = attachmentNames.Union(sp.SlotAttachments[_slotName]);
_attachmentNames = attachmentNames.ToArray();
}
// 使用弱引用, 则此 ViewModel 被释放时无需显式退订事件
foreach (var sp in _spines)
{
WeakEventManager<SpineObjectModel, SlotAttachmentChangedEventArgs>.AddHandler(
sp,
nameof(sp.SlotAttachmentChanged),
SingleModel_SlotAttachmentChanged
);
WeakEventManager<SpineObjectModel, SkinStatusChangedEventArgs>.AddHandler(
sp,
nameof(sp.SkinStatusChanged),
SingleModel_SkinStatusChanged
);
}
}
public RelayCommand Cmd_ClearAttachment => _cmd_ClearAttachment ??= new(() => AttachmentName = null);
private RelayCommand? _cmd_ClearAttachment;
public ReadOnlyCollection<string> AttachmentNames => _attachmentNames.AsReadOnly();
public string SlotName => _slotName;
public string? AttachmentName
{
get
{
if (_spines.Length <= 0) return null;
var val = _spines[0].GetAttachment(_slotName);
if (_spines.Skip(1).Any(it => it.GetAttachment(_slotName) != val)) return null;
return val;
}
set
{
if (_spines.Length <= 0) return;
bool changed = false;
foreach (var sp in _spines) if (sp.SetAttachment(_slotName, value)) changed = true;
if (changed) OnPropertyChanged();
}
}
private void SingleModel_SlotAttachmentChanged(object? sender, SlotAttachmentChangedEventArgs e)
{
if (e.SlotName == _slotName) OnPropertyChanged(nameof(AttachmentName));
}
private void SingleModel_SkinStatusChanged(object? sender, SkinStatusChangedEventArgs e)
{
// 如果皮肤发生改变, 则直接触发附件属性变化事件
OnPropertyChanged(nameof(AttachmentName));
}
}
public class AnimationTrackViewModel : ObservableObject
{
private readonly SpineObjectModel[] _spines;
private readonly string[] _animationNames = [];
private readonly int _trackIndex;
public AnimationTrackViewModel(int trackIndex, SpineObjectModel[] spines)
{
_spines = spines;
_trackIndex = trackIndex;
if (_spines.Length > 0)
{
IEnumerable<string> animationNames = _spines[0].Animations;
foreach (var sp in _spines.Skip(1))
animationNames = animationNames.Union(sp.Animations);
_animationNames = animationNames.ToArray();
}
// 使用弱引用, 则此 ViewModel 被释放时无需显式退订事件
foreach (var sp in _spines)
{
WeakEventManager<SpineObjectModel, AnimationChangedEventArgs>.AddHandler(
sp,
nameof(sp.AnimationChanged),
SingleModel_AnimationChanged
);
}
}
public RelayCommand Cmd_ClearTrack => _cmd_ClearTrack ??= new(() => { foreach (var sp in _spines) sp.ClearTrack(_trackIndex); });
private RelayCommand? _cmd_ClearTrack;
public ReadOnlyCollection<string> AnimationNames => _animationNames.AsReadOnly();
public int TrackIndex => _trackIndex;
public string? AnimationName
{
get
{
/// 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;
return val;
}
set
{
if (_spines.Length <= 0) return;
if (value is null) return;
foreach (var sp in _spines) sp.SetAnimation(_trackIndex, value);
OnPropertyChanged();
OnPropertyChanged(nameof(AnimationDuration));
}
}
public float? AnimationDuration
{
get
{
if (_spines.Length <= 0) return null;
var ani = _spines[0].GetAnimation(_trackIndex);
if (ani is null) return null;
var val = _spines[0].GetAnimationDuration(ani);
foreach (var sp in _spines.Skip(1))
{
var a = sp.GetAnimation(_trackIndex);
if (a is null) return null;
if (sp.GetAnimationDuration(a) != val) return null;
}
return val;
}
}
private void SingleModel_AnimationChanged(object? sender, AnimationChangedEventArgs e)
{
if (e.TrackIndex == _trackIndex) OnPropertyChanged(nameof(AnimationName));
}
}
}
}