Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use minimum enclosing circle as selection centre for scale and rotate #29938

Merged
merged 20 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions osu.Game.Benchmarks/BenchmarkGeometryUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. 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);
}
}
9 changes: 4 additions & 5 deletions osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ private void updateState()

private OsuHitObject[]? objectsInRotation;

private Vector2? defaultOrigin;
private Dictionary<OsuHitObject, Vector2>? originalPositions;
private Dictionary<IHasPath, Vector2[]>? originalPathControlPointPositions;

Expand All @@ -61,7 +60,7 @@ public override void Begin()
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,
Expand All @@ -73,9 +72,9 @@ public override void Update(float rotation, Vector2? origin = null)
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)
{
Expand Down Expand Up @@ -103,7 +102,7 @@ public override void Commit()
objectsInRotation = null;
originalPositions = null;
originalPathControlPointPositions = null;
defaultOrigin = null;
DefaultOrigin = null;
}

private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
Expand Down
2 changes: 1 addition & 1 deletion osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ public override void Begin()
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)
Expand Down
18 changes: 18 additions & 0 deletions osu.Game.Tests/Utils/GeometryUtilsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,23 @@ public void TestConvexHull(int[] values, int[] expected)

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));
}
}
}
1 change: 1 addition & 0 deletions osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public override void Begin()

targetContainer = getTargetContainer();
initialRotation = targetContainer!.Rotation;
DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero));

base.Begin();
}
Expand Down
9 changes: 4 additions & 5 deletions osu.Game/Overlays/SkinEditor/SkinSelectionRotationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ private void updateState()

private Drawable[]? objectsInRotation;

private Vector2? defaultOrigin;
private Dictionary<Drawable, float>? originalRotations;
private Dictionary<Drawable, Vector2>? originalPositions;

Expand All @@ -60,7 +59,7 @@ public override void Begin()
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();
}
Expand All @@ -70,7 +69,7 @@ public override void Update(float rotation, Vector2? origin = null)
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)
{
Expand All @@ -79,7 +78,7 @@ public override void Update(float rotation, Vector2? origin = null)
return;
}

var actualOrigin = origin ?? defaultOrigin.Value;
var actualOrigin = origin ?? DefaultOrigin.Value;

foreach (var drawableItem in objectsInRotation)
{
Expand All @@ -100,7 +99,7 @@ public override void Commit()
objectsInRotation = null;
originalPositions = null;
originalRotations = null;
defaultOrigin = null;
DefaultOrigin = null;

base.Commit();
}
Expand Down
2 changes: 1 addition & 1 deletion osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public override void Begin()

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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ protected override void OnDrag(DragEvent e)
{
base.OnDrag(e);

if (rotationHandler == null || !rotationHandler.OperationInProgress.Value) return;

rawCumulativeRotation += convertDragEventToAngleOfRotation(e);

applyRotation(shouldSnap: e.ShiftPressed);
Expand Down Expand Up @@ -113,9 +115,11 @@ protected override void OnDragEnd(DragEndEvent e)

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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -27,6 +27,12 @@ public partial class SelectionRotationHandler : Component
/// </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; }
bdach marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Performs a single, instant, atomic rotation operation.
/// </summary>
Expand Down
154 changes: 154 additions & 0 deletions osu.Game/Utils/GeometryUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils;
using osu.Game.Rulesets.Objects.Types;
using osuTK;

Expand Down Expand Up @@ -218,5 +219,158 @@ private static IEnumerable<Vector2> enumerateStartAndEndPositions(IEnumerable<IH

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