mirror of
https://github.com/ppy/osu.git
synced 2024-12-16 07:42:54 +08:00
Merge pull request #29938 from OliBomby/selection-center
Use minimum enclosing circle as selection centre for scale and rotate
This commit is contained in:
commit
33593280d8
29
osu.Game.Benchmarks/BenchmarkGeometryUtils.cs
Normal file
29
osu.Game.Benchmarks/BenchmarkGeometryUtils.cs
Normal file
@ -0,0 +1,29 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||
// See the LICENCE file in the repository root for full licence text.
|
||||
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Utils;
|
||||
using osuTK;
|
||||
|
||||
namespace osu.Game.Benchmarks
|
||||
{
|
||||
public class BenchmarkGeometryUtils : BenchmarkTest
|
||||
{
|
||||
[Params(100, 1000, 2000, 4000, 8000, 10000)]
|
||||
public int N;
|
||||
|
||||
private Vector2[] points = null!;
|
||||
|
||||
public override void SetUp()
|
||||
{
|
||||
points = new Vector2[N];
|
||||
|
||||
for (int i = 0; i < points.Length; ++i)
|
||||
points[i] = new Vector2(RNG.Next(512), RNG.Next(384));
|
||||
}
|
||||
|
||||
[Benchmark]
|
||||
public void MinimumEnclosingCircle() => GeometryUtils.MinimumEnclosingCircle(points);
|
||||
}
|
||||
}
|
@ -47,7 +47,6 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
|
||||
private OsuHitObject[]? objectsInRotation;
|
||||
|
||||
private Vector2? defaultOrigin;
|
||||
private Dictionary<OsuHitObject, Vector2>? originalPositions;
|
||||
private Dictionary<IHasPath, Vector2[]>? originalPathControlPointPositions;
|
||||
|
||||
@ -61,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
changeHandler?.BeginChange();
|
||||
|
||||
objectsInRotation = selectedMovableObjects.ToArray();
|
||||
defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation).Centre;
|
||||
DefaultOrigin = GeometryUtils.MinimumEnclosingCircle(objectsInRotation).Item1;
|
||||
originalPositions = objectsInRotation.ToDictionary(obj => obj, obj => obj.Position);
|
||||
originalPathControlPointPositions = objectsInRotation.OfType<IHasPath>().ToDictionary(
|
||||
obj => obj,
|
||||
@ -73,9 +72,9 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
if (!OperationInProgress.Value)
|
||||
throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!");
|
||||
|
||||
Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && defaultOrigin != null);
|
||||
Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && DefaultOrigin != null);
|
||||
|
||||
Vector2 actualOrigin = origin ?? defaultOrigin.Value;
|
||||
Vector2 actualOrigin = origin ?? DefaultOrigin.Value;
|
||||
|
||||
foreach (var ho in objectsInRotation)
|
||||
{
|
||||
@ -103,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
objectsInRotation = null;
|
||||
originalPositions = null;
|
||||
originalPathControlPointPositions = null;
|
||||
defaultOrigin = null;
|
||||
DefaultOrigin = null;
|
||||
}
|
||||
|
||||
private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
|
||||
|
@ -84,10 +84,10 @@ namespace osu.Game.Rulesets.Osu.Edit
|
||||
OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider
|
||||
? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position))
|
||||
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
|
||||
defaultOrigin = OriginalSurroundingQuad.Value.Centre;
|
||||
originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2
|
||||
? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position))
|
||||
: GeometryUtils.GetConvexHull(objectsInScale.Keys);
|
||||
defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1;
|
||||
}
|
||||
|
||||
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
|
||||
|
@ -29,5 +29,23 @@ namespace osu.Game.Tests.Utils
|
||||
|
||||
Assert.That(hull, Is.EquivalentTo(expectedPoints));
|
||||
}
|
||||
|
||||
[TestCase(new int[] { }, 0, 0, 0)]
|
||||
[TestCase(new[] { 0, 0 }, 0, 0, 0)]
|
||||
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, 1, 0, 1)]
|
||||
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, 1, 0, 1)]
|
||||
[TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, 3, 4.5f, 5.5901699f)]
|
||||
public void TestMinimumEnclosingCircle(int[] values, float x, float y, float r)
|
||||
{
|
||||
var points = new Vector2[values.Length / 2];
|
||||
for (int i = 0; i < values.Length; i += 2)
|
||||
points[i / 2] = new Vector2(values[i], values[i + 1]);
|
||||
|
||||
(var centre, float radius) = GeometryUtils.MinimumEnclosingCircle(points);
|
||||
|
||||
Assert.That(centre.X, Is.EqualTo(x).Within(0.0001));
|
||||
Assert.That(centre.Y, Is.EqualTo(y).Within(0.0001));
|
||||
Assert.That(radius, Is.EqualTo(r).Within(0.0001));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -84,6 +84,7 @@ namespace osu.Game.Tests.Visual.Editing
|
||||
|
||||
targetContainer = getTargetContainer();
|
||||
initialRotation = targetContainer!.Rotation;
|
||||
DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero));
|
||||
|
||||
base.Begin();
|
||||
}
|
||||
|
@ -46,7 +46,6 @@ namespace osu.Game.Overlays.SkinEditor
|
||||
|
||||
private Drawable[]? objectsInRotation;
|
||||
|
||||
private Vector2? defaultOrigin;
|
||||
private Dictionary<Drawable, float>? originalRotations;
|
||||
private Dictionary<Drawable, Vector2>? originalPositions;
|
||||
|
||||
@ -60,7 +59,7 @@ namespace osu.Game.Overlays.SkinEditor
|
||||
objectsInRotation = selectedItems.Cast<Drawable>().ToArray();
|
||||
originalRotations = objectsInRotation.ToDictionary(d => d, d => d.Rotation);
|
||||
originalPositions = objectsInRotation.ToDictionary(d => d, d => d.ToScreenSpace(d.OriginPosition));
|
||||
defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre;
|
||||
DefaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation.SelectMany(d => d.ScreenSpaceDrawQuad.GetVertices().ToArray())).Centre;
|
||||
|
||||
base.Begin();
|
||||
}
|
||||
@ -70,7 +69,7 @@ namespace osu.Game.Overlays.SkinEditor
|
||||
if (objectsInRotation == null)
|
||||
throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!");
|
||||
|
||||
Debug.Assert(originalRotations != null && originalPositions != null && defaultOrigin != null);
|
||||
Debug.Assert(originalRotations != null && originalPositions != null && DefaultOrigin != null);
|
||||
|
||||
if (objectsInRotation.Length == 1 && origin == null)
|
||||
{
|
||||
@ -79,7 +78,7 @@ namespace osu.Game.Overlays.SkinEditor
|
||||
return;
|
||||
}
|
||||
|
||||
var actualOrigin = origin ?? defaultOrigin.Value;
|
||||
var actualOrigin = origin ?? DefaultOrigin.Value;
|
||||
|
||||
foreach (var drawableItem in objectsInRotation)
|
||||
{
|
||||
@ -100,7 +99,7 @@ namespace osu.Game.Overlays.SkinEditor
|
||||
objectsInRotation = null;
|
||||
originalPositions = null;
|
||||
originalRotations = null;
|
||||
defaultOrigin = null;
|
||||
DefaultOrigin = null;
|
||||
|
||||
base.Commit();
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ namespace osu.Game.Overlays.SkinEditor
|
||||
|
||||
objectsInScale = selectedItems.Cast<Drawable>().ToDictionary(d => d, d => new OriginalDrawableState(d));
|
||||
OriginalSurroundingQuad = ToLocalSpace(GeometryUtils.GetSurroundingQuad(objectsInScale.SelectMany(d => d.Key.ScreenSpaceDrawQuad.GetVertices().ToArray())));
|
||||
defaultOrigin = OriginalSurroundingQuad.Value.Centre;
|
||||
defaultOrigin = ToLocalSpace(GeometryUtils.MinimumEnclosingCircle(objectsInScale.SelectMany(d => d.Key.ScreenSpaceDrawQuad.GetVertices().ToArray())).Item1);
|
||||
|
||||
isFlippedX = false;
|
||||
isFlippedY = false;
|
||||
|
@ -77,6 +77,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
{
|
||||
base.OnDrag(e);
|
||||
|
||||
if (rotationHandler == null || !rotationHandler.OperationInProgress.Value) return;
|
||||
|
||||
rawCumulativeRotation += convertDragEventToAngleOfRotation(e);
|
||||
|
||||
applyRotation(shouldSnap: e.ShiftPressed);
|
||||
@ -113,9 +115,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
|
||||
private float convertDragEventToAngleOfRotation(DragEvent e)
|
||||
{
|
||||
// Adjust coordinate system to the center of SelectionBox
|
||||
float startAngle = MathF.Atan2(e.LastMousePosition.Y - selectionBox.DrawHeight / 2, e.LastMousePosition.X - selectionBox.DrawWidth / 2);
|
||||
float endAngle = MathF.Atan2(e.MousePosition.Y - selectionBox.DrawHeight / 2, e.MousePosition.X - selectionBox.DrawWidth / 2);
|
||||
// Adjust coordinate system to the center of the selection
|
||||
Vector2 center = selectionBox.ToLocalSpace(rotationHandler!.ToScreenSpace(rotationHandler!.DefaultOrigin!.Value));
|
||||
|
||||
float startAngle = MathF.Atan2(e.LastMousePosition.Y - center.Y, e.LastMousePosition.X - center.X);
|
||||
float endAngle = MathF.Atan2(e.MousePosition.Y - center.Y, e.MousePosition.X - center.X);
|
||||
|
||||
return (endAngle - startAngle) * 180 / MathF.PI;
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
public partial class SelectionRotationHandler : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether there is any ongoing scale operation right now.
|
||||
/// Whether there is any ongoing rotation operation right now.
|
||||
/// </summary>
|
||||
public Bindable<bool> OperationInProgress { get; private set; } = new BindableBool();
|
||||
|
||||
@ -27,6 +27,12 @@ namespace osu.Game.Screens.Edit.Compose.Components
|
||||
/// </summary>
|
||||
public Bindable<bool> CanRotateAroundPlayfieldOrigin { get; private set; } = new BindableBool();
|
||||
|
||||
/// <summary>
|
||||
/// Implementation-defined origin point to rotate around when no explicit origin is provided.
|
||||
/// This field is only assigned during a rotation operation.
|
||||
/// </summary>
|
||||
public Vector2? DefaultOrigin { get; protected set; }
|
||||
|
||||
/// <summary>
|
||||
/// Performs a single, instant, atomic rotation operation.
|
||||
/// </summary>
|
||||
|
@ -6,6 +6,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using osu.Framework.Graphics;
|
||||
using osu.Framework.Graphics.Primitives;
|
||||
using osu.Framework.Utils;
|
||||
using osu.Game.Rulesets.Objects.Types;
|
||||
using osuTK;
|
||||
|
||||
@ -218,5 +219,158 @@ namespace osu.Game.Utils
|
||||
|
||||
return new[] { h.Position };
|
||||
});
|
||||
|
||||
#region Welzl helpers
|
||||
|
||||
// Function to check whether a point lies inside or on the boundaries of the circle
|
||||
private static bool isInside((Vector2 Centre, float Radius) c, Vector2 p)
|
||||
{
|
||||
return Precision.AlmostBigger(c.Radius, Vector2.Distance(c.Centre, p));
|
||||
}
|
||||
|
||||
// Function to return a unique circle that intersects three points
|
||||
private static (Vector2, float) circleFrom(Vector2 a, Vector2 b, Vector2 c)
|
||||
{
|
||||
if (Precision.AlmostEquals(0, (b.Y - a.Y) * (c.X - a.X) - (b.X - a.X) * (c.Y - a.Y)))
|
||||
return circleFrom(a, b);
|
||||
|
||||
// See: https://en.wikipedia.org/wiki/Circumcircle#Cartesian_coordinates
|
||||
float d = 2 * (a.X * (b - c).Y + b.X * (c - a).Y + c.X * (a - b).Y);
|
||||
float aSq = a.LengthSquared;
|
||||
float bSq = b.LengthSquared;
|
||||
float cSq = c.LengthSquared;
|
||||
|
||||
var centre = new Vector2(
|
||||
aSq * (b - c).Y + bSq * (c - a).Y + cSq * (a - b).Y,
|
||||
aSq * (c - b).X + bSq * (a - c).X + cSq * (b - a).X) / d;
|
||||
|
||||
return (centre, Vector2.Distance(a, centre));
|
||||
}
|
||||
|
||||
// Function to return the smallest circle that intersects 2 points
|
||||
private static (Vector2, float) circleFrom(Vector2 a, Vector2 b)
|
||||
{
|
||||
var centre = (a + b) / 2.0f;
|
||||
return (centre, Vector2.Distance(a, b) / 2.0f);
|
||||
}
|
||||
|
||||
// Function to check whether a circle encloses the given points
|
||||
private static bool isValidCircle((Vector2, float) c, List<Vector2> points)
|
||||
{
|
||||
// Iterating through all the points to check whether the points lie inside the circle or not
|
||||
foreach (Vector2 p in points)
|
||||
{
|
||||
if (!isInside(c, p)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function to return the minimum enclosing circle for N <= 3
|
||||
private static (Vector2, float) minCircleTrivial(List<Vector2> points)
|
||||
{
|
||||
if (points.Count > 3)
|
||||
throw new ArgumentException("Number of points must be at most 3", nameof(points));
|
||||
|
||||
switch (points.Count)
|
||||
{
|
||||
case 0:
|
||||
return (new Vector2(0, 0), 0);
|
||||
|
||||
case 1:
|
||||
return (points[0], 0);
|
||||
|
||||
case 2:
|
||||
return circleFrom(points[0], points[1]);
|
||||
}
|
||||
|
||||
// To check if MEC can be determined by 2 points only
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
for (int j = i + 1; j < 3; j++)
|
||||
{
|
||||
var c = circleFrom(points[i], points[j]);
|
||||
|
||||
if (isValidCircle(c, points))
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
return circleFrom(points[0], points[1], points[2]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Function to find the minimum enclosing circle for a collection of points.
|
||||
/// </summary>
|
||||
/// <returns>A tuple containing the circle centre and radius.</returns>
|
||||
public static (Vector2, float) MinimumEnclosingCircle(IEnumerable<Vector2> points)
|
||||
{
|
||||
// Using Welzl's algorithm to find the minimum enclosing circle
|
||||
// https://www.geeksforgeeks.org/minimum-enclosing-circle-using-welzls-algorithm/
|
||||
List<Vector2> p = points.ToList();
|
||||
|
||||
var stack = new Stack<(Vector2?, int)>();
|
||||
var r = new List<Vector2>(3);
|
||||
(Vector2, float) d = (Vector2.Zero, 0);
|
||||
|
||||
stack.Push((null, p.Count));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
// `n` represents the number of points in P that are not yet processed.
|
||||
// `point` represents the point that was randomly picked to process.
|
||||
(Vector2? point, int n) = stack.Pop();
|
||||
|
||||
if (!point.HasValue)
|
||||
{
|
||||
// Base case when all points processed or |R| = 3
|
||||
if (n == 0 || r.Count == 3)
|
||||
{
|
||||
d = minCircleTrivial(r);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pick a random point randomly
|
||||
int idx = RNG.Next(n);
|
||||
point = p[idx];
|
||||
|
||||
// Put the picked point at the end of P since it's more efficient than
|
||||
// deleting from the middle of the list
|
||||
(p[idx], p[n - 1]) = (p[n - 1], p[idx]);
|
||||
|
||||
// Schedule processing of p after we get the MEC circle d from the set of points P - {p}
|
||||
stack.Push((point, n));
|
||||
// Get the MEC circle d from the set of points P - {p}
|
||||
stack.Push((null, n - 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
// If d contains p, return d
|
||||
if (isInside(d, point.Value))
|
||||
continue;
|
||||
|
||||
// Remove points from R that were added in a deeper recursion
|
||||
// |R| = |P| - |stack| - n
|
||||
int removeCount = r.Count - (p.Count - stack.Count - n);
|
||||
r.RemoveRange(r.Count - removeCount, removeCount);
|
||||
|
||||
// Otherwise, must be on the boundary of the MEC
|
||||
r.Add(point.Value);
|
||||
// Return the MEC for P - {p} and R U {p}
|
||||
stack.Push((null, n - 1));
|
||||
}
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function to find the minimum enclosing circle for a collection of hit objects.
|
||||
/// </summary>
|
||||
/// <returns>A tuple containing the circle centre and radius.</returns>
|
||||
public static (Vector2, float) MinimumEnclosingCircle(IEnumerable<IHasPosition> hitObjects) =>
|
||||
MinimumEnclosingCircle(enumerateStartAndEndPositions(hitObjects));
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user