1
0
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:
Bartłomiej Dach 2024-09-27 10:46:32 +02:00 committed by GitHub
commit 33593280d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 226 additions and 16 deletions

View 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);
}
}

View File

@ -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>()

View File

@ -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)

View File

@ -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));
}
}
}

View File

@ -84,6 +84,7 @@ namespace osu.Game.Tests.Visual.Editing
targetContainer = getTargetContainer();
initialRotation = targetContainer!.Rotation;
DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero));
base.Begin();
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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>

View File

@ -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));
}
}