From 42bd5c2830ed6647a37c23725094c1432d00d97f Mon Sep 17 00:00:00 2001 From: ww-rm Date: Wed, 1 Oct 2025 23:43:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=B2=BE=E7=A1=AE=E5=91=BD?= =?UTF-8?q?=E4=B8=AD=E6=A3=80=E6=B5=8B=E5=92=8C=E6=8F=92=E6=A7=BD=E8=BE=93?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Spine/Interfaces/SpineExtension.cs | 140 ++++++++++++++++-- SpineViewer/Models/PreferenceModel.cs | 6 + SpineViewer/Models/SpineObjectModel.cs | 14 +- SpineViewer/Resources/Strings/en.xaml | 4 + SpineViewer/Resources/Strings/ja.xaml | 4 + SpineViewer/Resources/Strings/zh.xaml | 4 + .../MainWindow/PreferenceViewModel.cs | 16 ++ .../MainWindow/SFMLRendererViewModel.cs | 109 +++++++++++--- SpineViewer/Views/PreferenceDialog.xaml | 33 +++-- 9 files changed, 277 insertions(+), 53 deletions(-) diff --git a/Spine/Interfaces/SpineExtension.cs b/Spine/Interfaces/SpineExtension.cs index 751a391..b6f3c0d 100644 --- a/Spine/Interfaces/SpineExtension.cs +++ b/Spine/Interfaces/SpineExtension.cs @@ -1,4 +1,5 @@ -using Spine.Interfaces.Attachments; +using SkiaSharp; +using Spine.Interfaces.Attachments; using System; using System.Collections.Generic; using System.Linq; @@ -56,18 +57,6 @@ namespace Spine.Interfaces } } - /// - /// 命中测试, 当插槽全透明或者处于禁用或者骨骼处于未激活则无法命中 - /// - public static bool HitTest(this ISlot self, float x, float y) - { - if (self.A <= 0 || !self.Bone.Active || self.Disabled) - return false; - - self.GetBounds(out var bx, out var by, out var bw, out var bh); - return x >= bx && x <= (bx + bw) && y >= by && y <= (by + bh); - } - /// /// 获取当前状态包围盒 /// @@ -112,11 +101,130 @@ namespace Spine.Interfaces } /// - /// 逐插槽的命中测试, 不会计算处于禁用或者骨骼未激活的插槽, 比整体包围盒稍微精确一些 + /// 命中测试, 当插槽全透明或者处于禁用或者骨骼处于未激活则无法命中 /// - public static bool HitTest(this ISkeleton self, float x, float y) + /// 是否精确命中检测, 否则仅使用包围盒进行命中检测 + /// 调用方管理的缓存表 + public static bool HitTest(this ISlot self, float x, float y, bool precise = false, Dictionary cache = null) { - return self.IterDrawOrder().Any(st => st.HitTest(x, y)); + if (self.A <= 0 || !self.Bone.Active || self.Disabled) + return false; + + if (!precise) + { + self.GetBounds(out var bx, out var by, out var bw, out var bh); + return x >= bx && x <= (bx + bw) && y >= by && y <= (by + bh); + } + else + { + float[] vertices = new float[8]; + int[] triangles; + float[] uvs; + SFML.Graphics.Texture tex; + + switch (self.Attachment) + { + case IRegionAttachment regionAttachment: + _ = regionAttachment.ComputeWorldVertices(self, ref vertices); + triangles = regionAttachment.Triangles; + uvs = regionAttachment.UVs; + tex = regionAttachment.RendererObject; + break; + case IMeshAttachment meshAttachment: + _ = meshAttachment.ComputeWorldVertices(self, ref vertices); + triangles = meshAttachment.Triangles; + uvs = meshAttachment.UVs; + tex = meshAttachment.RendererObject; + break; + default: + return false; + } + + SFML.Graphics.Image img = null; + if (cache is not null) + { + if (!cache.TryGetValue(tex, out img)) + { + img = cache[tex] = tex.CopyToImage(); + } + } + else + { + img = tex.CopyToImage(); + } + + bool hit = false; + var trianglesLength = triangles.Length; + var texSize = img.Size; + for (int i = 0; i + 2 < trianglesLength; i += 3) + { + var idx0 = triangles[i] << 1; + var idx1 = triangles[i + 1] << 1; + var idx2 = triangles[i + 2] << 1; + + float x0 = vertices[idx0] - x, y0 = vertices[idx0 + 1] - y; + float x1 = vertices[idx1] - x, y1 = vertices[idx1 + 1] - y; + float x2 = vertices[idx2] - x, y2 = vertices[idx2 + 1] - y; + + float c0 = Cross(x0, y0, x1, y1); + float c1 = Cross(x1, y1, x2, y2); + float c2 = Cross(x2, y2, x0, y0); + + // 判断是否全部同号 (或为 0, 点在边上) + if ((c0 >= 0 && c1 >= 0 && c2 >= 0) || (c0 <= 0 && c1 <= 0 && c2 <= 0)) + { + float u0 = uvs[idx0], v0 = uvs[idx0 + 1]; + float u1 = uvs[idx1], v1 = uvs[idx1 + 1]; + float u2 = uvs[idx2], v2 = uvs[idx2 + 1]; + float inv = 1 / (c0 + c1 + c2); + float w0 = c1 * inv; + float w1 = c2 * inv; + float w2 = c0 * inv; + float u = u0 * w0 + u1 * w1 + u2 * w2; + float v = v0 * w0 + v1 * w1 + v2 * w2; + + var pixel = img.GetPixel((uint)(u * texSize.X), (uint)(v * texSize.Y)); + hit = pixel.A > 0; + break; + } + } + + // 无缓存需要立即释放资源 + if (cache is null) + { + img.Dispose(); + } + + return hit; + } } + + /// + /// 逐插槽的命中测试, 命中后会提前返回结果中止计算 + /// + public static bool HitTest(this ISkeleton self, float x, float y, bool precise = false) + { + var cache = new Dictionary(); + bool hit = self.IterDrawOrder().Any(st => st.HitTest(x, y, precise, cache)); + foreach (var img in cache.Values) img.Dispose(); + return hit; + } + + /// + /// 逐插槽的命中测试, 会完整计算所有插槽的命中情况并按顶层至底层的顺序返回命中的插槽 + /// /// 是否精确命中检测, 否则仅使用每个插槽的包围盒进行命中检测 + /// + public static ISlot[] HitTestFull(this ISkeleton self, float x, float y, bool precise = false) + { + var cache = new Dictionary(); + var hitSlots = self.IterDrawOrder().Where(st => st.HitTest(x, y, precise, cache)).Reverse().ToArray(); + foreach (var img in cache.Values) img.Dispose(); + return hitSlots; + } + + /// + /// 向量叉积 + /// + private static float Cross(float x0, float y0, float x1, float y1) => x0 * y1 - y0 * x1; } } diff --git a/SpineViewer/Models/PreferenceModel.cs b/SpineViewer/Models/PreferenceModel.cs index 2b812d2..61beced 100644 --- a/SpineViewer/Models/PreferenceModel.cs +++ b/SpineViewer/Models/PreferenceModel.cs @@ -88,6 +88,12 @@ namespace SpineViewer.Models [ObservableProperty] private bool _renderSelectedOnly; + [ObservableProperty] + private bool _usePreciseHitTest; + + [ObservableProperty] + private bool _logHitSlots; + [ObservableProperty] private bool _wallpaperView; diff --git a/SpineViewer/Models/SpineObjectModel.cs b/SpineViewer/Models/SpineObjectModel.cs index cfaf4bd..ddb920f 100644 --- a/SpineViewer/Models/SpineObjectModel.cs +++ b/SpineViewer/Models/SpineObjectModel.cs @@ -429,11 +429,19 @@ namespace SpineViewer.Models } /// - /// 命中检测, 可以比整体包围盒略精确一点 + /// 命中检测, 可选是否使用精确检测, 会有性能损失 /// - public bool HitTest(float x, float y) + public bool HitTest(float x, float y, bool precise = false) { - lock (_lock) return _spineObject.Skeleton.HitTest(x, y); + lock (_lock) return _spineObject.Skeleton.HitTest(x, y, precise); + } + + /// + /// 完整的命中检测, 会检测所有插槽是否命中并返回命中的插槽名称 + /// + public string[] HitTestFull(float x, float y, bool precise = false) + { + lock (_lock) return _spineObject.Skeleton.HitTestFull(x, y, precise).Select(v => v.Name).ToArray(); } public SpineObjectConfigModel ObjectConfig diff --git a/SpineViewer/Resources/Strings/en.xaml b/SpineViewer/Resources/Strings/en.xaml index a63f3a4..ffa0d04 100644 --- a/SpineViewer/Resources/Strings/en.xaml +++ b/SpineViewer/Resources/Strings/en.xaml @@ -120,6 +120,10 @@ Playback Speed Wallpaper View Render Selected Only + Use Precise Hit Testing + When enabled, click detection will be performed based on pixel transparency of the model. + Log Hit Slot Names + When enabled, the log box will output the model and slot information hit by each click operation (will not output when using Ctrl for multi-selection). Show Axis Background Color Background Image Path diff --git a/SpineViewer/Resources/Strings/ja.xaml b/SpineViewer/Resources/Strings/ja.xaml index 168cc59..4759e47 100644 --- a/SpineViewer/Resources/Strings/ja.xaml +++ b/SpineViewer/Resources/Strings/ja.xaml @@ -120,6 +120,10 @@ 再生速度 壁紙表示 選択のみレンダリング + 精密ヒットテストを使用 + 有効にすると、モデルのピクセル透過度に基づいてクリック判定を行います。 + ヒットしたスロット名を出力 + 有効にすると、ログボックスに各クリック操作でヒットしたモデルとスロットの情報を出力します(Ctrlを押しながら複数選択する場合は出力されません)。 座標軸を表示 背景色 背景画像のパス diff --git a/SpineViewer/Resources/Strings/zh.xaml b/SpineViewer/Resources/Strings/zh.xaml index 5884c6e..13421bd 100644 --- a/SpineViewer/Resources/Strings/zh.xaml +++ b/SpineViewer/Resources/Strings/zh.xaml @@ -120,6 +120,10 @@ 播放速度 桌面投影 仅渲染选中 + 使用精确命中检测 + 启用后将会按像素透明度来检测点击操作是否命中了模型 + 输出命中的插槽名称 + 启用后将会在日志框内输出每一次点击操作命中的模型和插槽情况(按下 Ctrl 进行多选时不会输出) 显示坐标轴 背景颜色 背景图片路径 diff --git a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs index 9818ff5..02cfa54 100644 --- a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs @@ -109,6 +109,8 @@ namespace SpineViewer.ViewModels.MainWindow AppLanguage = AppLanguage, RenderSelectedOnly = RenderSelectedOnly, + UsePreciseHitTest = UsePreciseHitTest, + LogHitSlots = LogHitSlots, WallpaperView = WallpaperView, CloseToTray = CloseToTray, AutoRun = AutoRun, @@ -138,6 +140,8 @@ namespace SpineViewer.ViewModels.MainWindow AppLanguage = value.AppLanguage; RenderSelectedOnly = value.RenderSelectedOnly; + UsePreciseHitTest = value.UsePreciseHitTest; + LogHitSlots = value.LogHitSlots; WallpaperView = value.WallpaperView; CloseToTray = value.CloseToTray; AutoRun = value.AutoRun; @@ -260,6 +264,18 @@ namespace SpineViewer.ViewModels.MainWindow set => SetProperty(_vmMain.SFMLRendererViewModel.RenderSelectedOnly, value, v => _vmMain.SFMLRendererViewModel.RenderSelectedOnly = v); } + public bool UsePreciseHitTest + { + get => _vmMain.SFMLRendererViewModel.UsePreciseHitTest; + set => SetProperty(_vmMain.SFMLRendererViewModel.UsePreciseHitTest, value, v => _vmMain.SFMLRendererViewModel.UsePreciseHitTest = v); + } + + public bool LogHitSlots + { + get => _vmMain.SFMLRendererViewModel.LogHitSlots; + set => SetProperty(_vmMain.SFMLRendererViewModel.LogHitSlots, value, v => _vmMain.SFMLRendererViewModel.LogHitSlots = v); + } + public bool WallpaperView { get => _vmMain.SFMLRendererViewModel.WallpaperView; diff --git a/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs b/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs index dbf51f5..b574eb5 100644 --- a/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SFMLRendererViewModel.cs @@ -240,6 +240,39 @@ namespace SpineViewer.ViewModels.MainWindow } private Stretch _backgroundImageMode = Stretch.Uniform; + /// + /// 仅渲染选中对象 + /// + public bool RenderSelectedOnly + { + get => _renderSelectedOnly; + set => SetProperty(ref _renderSelectedOnly, value); + } + private bool _renderSelectedOnly; + + /// + /// 启用精确命中测试 + /// + public bool UsePreciseHitTest + { + get => _usePreciseHitTest; + set => SetProperty(ref _usePreciseHitTest, value); + } + private bool _usePreciseHitTest; + + /// + /// 启用完整的命中测试并在日志中输出命中测试的插槽结果 + /// + public bool LogHitSlots + { + get => _logHitSlots; + set => SetProperty(ref _logHitSlots, value); + } + private bool _logHitSlots; + + /// + /// 启用桌面投影 + /// public bool WallpaperView { get => _wallpaperView; @@ -247,13 +280,6 @@ namespace SpineViewer.ViewModels.MainWindow } private bool _wallpaperView; - public bool RenderSelectedOnly - { - get => _renderSelectedOnly; - set => SetProperty(ref _renderSelectedOnly, value); - } - private bool _renderSelectedOnly = false; - public bool IsUpdating { get => _isUpdating; @@ -333,17 +359,31 @@ namespace SpineViewer.ViewModels.MainWindow } 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; + var src = _renderer.MapPixelToCoords(new(e.X, e.Y)); + _draggingSrc = src; lock (_models.Lock) { // 仅渲染选中模式禁止在画面里选择对象 if (_renderSelectedOnly) { - // 只在被选中的对象里判断是否有效命中 - bool hit = _models.Any(m => m.IsSelected && m.GetCurrentBounds().Contains(src)); + bool hit = false; + if (!_logHitSlots) + { + // 只在被选中的对象里判断是否有效命中 + hit = _models.Any(m => m.IsSelected && m.HitTest(src.X, src.Y, _usePreciseHitTest)); + } + else + { + foreach (var sp in _models.Where(m => m.IsSelected)) + { + var slotNames = sp.HitTestFull(src.X, src.Y, _usePreciseHitTest); + if (slotNames.Length <= 0) continue; + + hit = true; + _logger.Debug("Model Hit ({0}): [{1}]", sp.Name, string.Join(", ", slotNames)); + } + } // 如果没点到被选中的模型, 则不允许拖动 if (!hit) _draggingSrc = null; @@ -354,20 +394,41 @@ namespace SpineViewer.ViewModels.MainWindow { // 没按 Ctrl 的情况下, 如果命中了已选中对象, 则就算普通命中 bool hit = false; - foreach (var sp in _models) + + if (!_logHitSlots) { - if (!sp.IsShown) continue; - if (!sp.GetCurrentBounds().Contains(src)) continue; - - hit = true; - - // 如果点到了没被选中的东西, 则清空原先选中的, 改为只选中这一次点的 - if (!sp.IsSelected) + foreach (var sp in _models.Where(m => m.IsShown)) { - RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); - RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp)); + if (!sp.HitTest(src.X, src.Y, _usePreciseHitTest)) continue; + + hit = true; + + // 如果点到了没被选中的东西, 则清空原先选中的, 改为只选中这一次点的 + if (!sp.IsSelected) + { + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp)); + } + break; + } + } + else + { + foreach (var sp in _models.Where(m => m.IsShown)) + { + var slotNames = sp.HitTestFull(src.X, src.Y, _usePreciseHitTest); + if (slotNames.Length <= 0) continue; + + // 如果点到了没被选中的东西, 则清空原先选中的, 改为只选中这一次点的 + // 仅判断顶层对象 (首次命中) + if (!hit && !sp.IsSelected) + { + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset)); + RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp)); + } + hit = true; + _logger.Debug("Model Hit ({0}): [{1}]", sp.Name, string.Join(", ", slotNames)); } - break; } // 如果点了空白的地方, 就清空选中列表 @@ -376,7 +437,7 @@ namespace SpineViewer.ViewModels.MainWindow else { // 按下 Ctrl 的情况就执行多选, 并且点空白处也不会清空选中, 如果点击了本来就是选中的则取消选中 - if (_models.FirstOrDefault(m => m.IsShown && m.GetCurrentBounds().Contains(src), null) is SpineObjectModel sp) + if (_models.FirstOrDefault(m => m.IsShown && m.HitTest(src.X, src.Y, _usePreciseHitTest), null) is SpineObjectModel sp) { if (sp.IsSelected) RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Remove, sp)); diff --git a/SpineViewer/Views/PreferenceDialog.xaml b/SpineViewer/Views/PreferenceDialog.xaml index 8c4c8e2..8610285 100644 --- a/SpineViewer/Views/PreferenceDialog.xaml +++ b/SpineViewer/Views/PreferenceDialog.xaml @@ -215,8 +215,25 @@