1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-16 10:23:04 +08:00

Merge pull request #26311 from OliBomby/grids-3

Make editor flip, rotate, and scale tools revolve around the grid center
This commit is contained in:
Dean Herbert 2024-09-19 18:45:39 +09:00 committed by GitHub
commit 9376ba3262
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 299 additions and 51 deletions

View File

@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position,
() => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200)));
AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(1).TriggerClick());
AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(2).TriggerClick());
AddAssert("first object rotated 90deg around selection centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
AddAssert("second object rotated 90deg around selection centre",

View File

@ -106,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler,
ScaleHandler = (OsuSelectionScaleHandler)BlueprintContainer.SelectionHandler.ScaleHandler,
GridToolbox = OsuGridToolboxGroup,
},
new GenerateToolboxGroup(),
FreehandSliderToolboxGroup

View File

@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
@ -25,6 +26,9 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSelectionHandler : EditorSelectionHandler
{
[Resolved]
private OsuGridToolboxGroup gridToolbox { get; set; } = null!;
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();
@ -123,13 +127,43 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : GeometryUtils.GetSurroundingQuad(hitObjects);
// If we're flipping over the origin, we take the grid origin position from the grid toolbox.
var flipQuad = flipOverOrigin ? new Quad(gridToolbox.StartPositionX.Value, gridToolbox.StartPositionY.Value, 0, 0) : GeometryUtils.GetSurroundingQuad(hitObjects);
Vector2 flipAxis = direction == Direction.Vertical ? Vector2.UnitY : Vector2.UnitX;
if (flipOverOrigin)
{
// If we're flipping over the origin, we take one of the axes of the grid.
// Take the axis closest to the direction we want to flip over.
switch (gridToolbox.GridType.Value)
{
case PositionSnapGridType.Square:
flipAxis = GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 45) % 90 - 45));
flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis;
break;
case PositionSnapGridType.Triangle:
// Hex grid has 3 axes, so you can not directly flip over one of the axes,
// however it's still possible to achieve that flip by combining multiple flips over the other axes.
// Angle degree range for vertical = (-120, -60]
// Angle degree range for horizontal = [-30, 30)
flipAxis = direction == Direction.Vertical
? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 30) % 60 + 60))
: GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360) % 60 - 30));
break;
}
}
var controlPointFlipQuad = new Quad();
bool didFlip = false;
foreach (var h in hitObjects)
{
var flippedPosition = GeometryUtils.GetFlippedPosition(direction, flipQuad, h.Position);
var flippedPosition = GeometryUtils.GetFlippedPosition(flipAxis, flipQuad, h.Position);
// Clamp the flipped position inside the playfield bounds, because the flipped position might be outside the playfield bounds if the origin is not centered.
flippedPosition = Vector2.Clamp(flippedPosition, Vector2.Zero, OsuPlayfield.BASE_SIZE);
if (!Precision.AlmostEquals(flippedPosition, h.Position))
{
@ -142,12 +176,7 @@ namespace osu.Game.Rulesets.Osu.Edit
didFlip = true;
foreach (var cp in slider.Path.ControlPoints)
{
cp.Position = new Vector2(
(direction == Direction.Horizontal ? -1 : 1) * cp.Position.X,
(direction == Direction.Vertical ? -1 : 1) * cp.Position.Y
);
}
cp.Position = GeometryUtils.GetFlippedPosition(flipAxis, controlPointFlipQuad, cp.Position);
}
}

View File

@ -69,6 +69,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private Dictionary<OsuHitObject, OriginalHitObjectState>? objectsInScale;
private Vector2? defaultOrigin;
private List<Vector2>? originalConvexHull;
public override void Begin()
{
@ -84,9 +85,12 @@ namespace osu.Game.Rulesets.Osu.Edit
? 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);
}
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both)
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
if (!OperationInProgress.Value)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!");
@ -94,6 +98,7 @@ namespace osu.Game.Rulesets.Osu.Edit
Debug.Assert(objectsInScale != null && defaultOrigin != null && OriginalSurroundingQuad != null);
Vector2 actualOrigin = origin ?? defaultOrigin.Value;
scale = clampScaleToAdjustAxis(scale, adjustAxis);
// for the time being, allow resizing of slider paths only if the slider is
// the only hit object selected. with a group selection, it's likely the user
@ -102,15 +107,15 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var originalInfo = objectsInScale[slider];
Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null);
scaleSlider(slider, scale, originalInfo.PathControlPointPositions, originalInfo.PathControlPointTypes);
scaleSlider(slider, scale, originalInfo.PathControlPointPositions, originalInfo.PathControlPointTypes, axisRotation);
}
else
{
scale = ClampScaleToPlayfieldBounds(scale, actualOrigin);
scale = ClampScaleToPlayfieldBounds(scale, actualOrigin, adjustAxis, axisRotation);
foreach (var (ho, originalState) in objectsInScale)
{
ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position);
ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position, axisRotation);
}
}
@ -134,14 +139,34 @@ namespace osu.Game.Rulesets.Osu.Edit
private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
.Where(h => h is not Spinner);
private void scaleSlider(Slider slider, Vector2 scale, Vector2[] originalPathPositions, PathType?[] originalPathTypes)
private Vector2 clampScaleToAdjustAxis(Vector2 scale, Axes adjustAxis)
{
switch (adjustAxis)
{
case Axes.Y:
scale.X = 1;
break;
case Axes.X:
scale.Y = 1;
break;
case Axes.None:
scale = Vector2.One;
break;
}
return scale;
}
private void scaleSlider(Slider slider, Vector2 scale, Vector2[] originalPathPositions, PathType?[] originalPathTypes, float axisRotation = 0)
{
scale = Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
// Maintain the path types in case they were defaulted to bezier at some point during scaling
for (int i = 0; i < slider.Path.ControlPoints.Count; i++)
{
slider.Path.ControlPoints[i].Position = originalPathPositions[i] * scale;
slider.Path.ControlPoints[i].Position = GeometryUtils.GetScaledPosition(scale, Vector2.Zero, originalPathPositions[i], axisRotation);
slider.Path.ControlPoints[i].Type = originalPathTypes[i];
}
@ -176,11 +201,13 @@ namespace osu.Game.Rulesets.Osu.Edit
/// </summary>
/// <param name="origin">The origin from which the scale operation is performed</param>
/// <param name="scale">The scale to be clamped</param>
/// <param name="adjustAxis">The axes to adjust the scale in.</param>
/// <param name="axisRotation">The rotation of the axes in degrees</param>
/// <returns>The clamped scale vector</returns>
public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null)
public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
//todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
if (objectsInScale == null)
if (objectsInScale == null || adjustAxis == Axes.None)
return scale;
Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null);
@ -188,24 +215,60 @@ namespace osu.Game.Rulesets.Osu.Edit
if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider)
origin = slider.Position;
float cos = MathF.Cos(float.DegreesToRadians(-axisRotation));
float sin = MathF.Sin(float.DegreesToRadians(-axisRotation));
scale = clampScaleToAdjustAxis(scale, adjustAxis);
Vector2 actualOrigin = origin ?? defaultOrigin.Value;
var selectionQuad = OriginalSurroundingQuad.Value;
IEnumerable<Vector2> points;
var tl1 = Vector2.Divide(-actualOrigin, selectionQuad.TopLeft - actualOrigin);
var tl2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.TopLeft - actualOrigin);
var br1 = Vector2.Divide(-actualOrigin, selectionQuad.BottomRight - actualOrigin);
var br2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.BottomRight - actualOrigin);
if (axisRotation == 0)
{
var selectionQuad = OriginalSurroundingQuad.Value;
points = new[]
{
selectionQuad.TopLeft,
selectionQuad.TopRight,
selectionQuad.BottomLeft,
selectionQuad.BottomRight
};
}
else
points = originalConvexHull!;
if (!Precision.AlmostEquals(selectionQuad.TopLeft.X - actualOrigin.X, 0))
scale.X = selectionQuad.TopLeft.X - actualOrigin.X < 0 ? MathHelper.Clamp(scale.X, tl2.X, tl1.X) : MathHelper.Clamp(scale.X, tl1.X, tl2.X);
if (!Precision.AlmostEquals(selectionQuad.TopLeft.Y - actualOrigin.Y, 0))
scale.Y = selectionQuad.TopLeft.Y - actualOrigin.Y < 0 ? MathHelper.Clamp(scale.Y, tl2.Y, tl1.Y) : MathHelper.Clamp(scale.Y, tl1.Y, tl2.Y);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.X - actualOrigin.X, 0))
scale.X = selectionQuad.BottomRight.X - actualOrigin.X < 0 ? MathHelper.Clamp(scale.X, br2.X, br1.X) : MathHelper.Clamp(scale.X, br1.X, br2.X);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.Y - actualOrigin.Y, 0))
scale.Y = selectionQuad.BottomRight.Y - actualOrigin.Y < 0 ? MathHelper.Clamp(scale.Y, br2.Y, br1.Y) : MathHelper.Clamp(scale.Y, br1.Y, br2.Y);
foreach (var point in points)
{
scale = clampToBound(scale, point, Vector2.Zero);
scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE);
}
return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
float minPositiveComponent(Vector2 v) => MathF.Min(v.X < 0 ? float.PositiveInfinity : v.X, v.Y < 0 ? float.PositiveInfinity : v.Y);
Vector2 clampToBound(Vector2 s, Vector2 p, Vector2 bound)
{
p -= actualOrigin;
bound -= actualOrigin;
var a = new Vector2(cos * cos * p.X - sin * cos * p.Y, -sin * cos * p.X + sin * sin * p.Y);
var b = new Vector2(sin * sin * p.X + sin * cos * p.Y, sin * cos * p.X + cos * cos * p.Y);
switch (adjustAxis)
{
case Axes.X:
s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a)));
break;
case Axes.Y:
s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b)));
break;
case Axes.Both:
s = Vector2.ComponentMin(s, s * minPositiveComponent(Vector2.Divide(bound, a * s.X + b * s.Y)));
break;
}
return s;
}
}
private void moveSelectionInBounds()

View File

@ -1,6 +1,7 @@
// 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 System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -19,16 +20,19 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly SelectionRotationHandler rotationHandler;
private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre));
private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.GridCentre));
private SliderWithTextBoxInput<float> angleInput = null!;
private EditorRadioButtonCollection rotationOrigin = null!;
private RadioButton selectionCentreButton = null!;
public PreciseRotationPopover(SelectionRotationHandler rotationHandler)
public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox)
{
this.rotationHandler = rotationHandler;
this.gridToolbox = gridToolbox;
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
}
@ -58,6 +62,9 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X,
Items = new[]
{
new RadioButton("Grid centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.GridCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
new RadioButton("Playfield centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
@ -93,10 +100,19 @@ namespace osu.Game.Rulesets.Osu.Edit
rotationInfo.BindValueChanged(rotation =>
{
rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null);
rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue));
});
}
private Vector2? getOriginPosition(PreciseRotationInfo rotation) =>
rotation.Origin switch
{
RotationOrigin.GridCentre => gridToolbox.StartPosition.Value,
RotationOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2,
RotationOrigin.SelectionCentre => null,
_ => throw new ArgumentOutOfRangeException(nameof(rotation))
};
protected override void PopIn()
{
base.PopIn();
@ -114,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public enum RotationOrigin
{
GridCentre,
PlayfieldCentre,
SelectionCentre
}

View File

@ -20,21 +20,25 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly OsuSelectionScaleHandler scaleHandler;
private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.PlayfieldCentre, true, true));
private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.GridCentre, true, true));
private SliderWithTextBoxInput<float> scaleInput = null!;
private BindableNumber<float> scaleInputBindable = null!;
private EditorRadioButtonCollection scaleOrigin = null!;
private RadioButton gridCentreButton = null!;
private RadioButton playfieldCentreButton = null!;
private RadioButton selectionCentreButton = null!;
private OsuCheckbox xCheckBox = null!;
private OsuCheckbox yCheckBox = null!;
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler)
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler, OsuGridToolboxGroup gridToolbox)
{
this.scaleHandler = scaleHandler;
this.gridToolbox = gridToolbox;
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
}
@ -66,6 +70,9 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X,
Items = new[]
{
gridCentreButton = new RadioButton("Grid centre",
() => setOrigin(ScaleOrigin.GridCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
playfieldCentreButton = new RadioButton("Playfield centre",
() => setOrigin(ScaleOrigin.PlayfieldCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
@ -97,6 +104,10 @@ namespace osu.Game.Rulesets.Osu.Edit
},
}
};
gridCentreButton.Selected.DisabledChanged += isDisabled =>
{
gridCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to grid centre." : string.Empty;
};
playfieldCentreButton.Selected.DisabledChanged += isDisabled =>
{
playfieldCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to playfield centre." : string.Empty;
@ -123,19 +134,20 @@ namespace osu.Game.Rulesets.Osu.Edit
selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value);
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
gridCentreButton.Selected.Disabled = playfieldCentreButton.Selected.Disabled;
scaleOrigin.Items.First(b => !b.Selected.Disabled).Select();
scaleInfo.BindValueChanged(scale =>
{
var newScale = new Vector2(scale.NewValue.XAxis ? scale.NewValue.Scale : 1, scale.NewValue.YAxis ? scale.NewValue.Scale : 1);
scaleHandler.Update(newScale, getOriginPosition(scale.NewValue));
var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale);
scaleHandler.Update(newScale, getOriginPosition(scale.NewValue), getAdjustAxis(scale.NewValue), getRotation(scale.NewValue));
});
}
private void updateAxisCheckBoxesEnabled()
{
if (scaleInfo.Value.Origin == ScaleOrigin.PlayfieldCentre)
if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre)
{
toggleAxisAvailable(xCheckBox.Current, true);
toggleAxisAvailable(yCheckBox.Current, true);
@ -162,7 +174,7 @@ namespace osu.Game.Rulesets.Osu.Edit
return;
const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value));
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis)
scale.X = max_scale;
@ -179,7 +191,18 @@ namespace osu.Game.Rulesets.Osu.Edit
updateAxisCheckBoxesEnabled();
}
private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null;
private Vector2? getOriginPosition(PreciseScaleInfo scale) =>
scale.Origin switch
{
ScaleOrigin.GridCentre => gridToolbox.StartPosition.Value,
ScaleOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2,
ScaleOrigin.SelectionCentre => null,
_ => throw new ArgumentOutOfRangeException(nameof(scale))
};
private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y;
private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
private void setAxis(bool x, bool y)
{
@ -204,6 +227,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public enum ScaleOrigin
{
GridCentre,
PlayfieldCentre,
SelectionCentre
}

View File

@ -27,6 +27,8 @@ namespace osu.Game.Rulesets.Osu.Edit
public SelectionRotationHandler RotationHandler { get; init; } = null!;
public OsuSelectionScaleHandler ScaleHandler { get; init; } = null!;
public OsuGridToolboxGroup GridToolbox { get; init; } = null!;
public TransformToolboxGroup()
: base("transform")
{
@ -44,10 +46,10 @@ namespace osu.Game.Rulesets.Osu.Edit
{
rotateButton = new EditorToolButton("Rotate",
() => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
() => new PreciseRotationPopover(RotationHandler)),
() => new PreciseRotationPopover(RotationHandler, GridToolbox)),
scaleButton = new EditorToolButton("Scale",
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt },
() => new PreciseScalePopover(ScaleHandler))
() => new PreciseScalePopover(ScaleHandler, GridToolbox))
}
};
}

View File

@ -0,0 +1,33 @@
// 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 NUnit.Framework;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Tests.Utils
{
[TestFixture]
public class GeometryUtilsTest
{
[TestCase(new int[] { }, new int[] { })]
[TestCase(new[] { 0, 0 }, new[] { 0, 0 })]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })]
[TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, new[] { 0, 0, 4, 10, 2, -1 })]
public void TestConvexHull(int[] values, int[] expected)
{
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 expectedPoints = new Vector2[expected.Length / 2];
for (int i = 0; i < expected.Length; i += 2)
expectedPoints[i / 2] = new Vector2(expected[i], expected[i + 1]);
var hull = GeometryUtils.GetConvexHull(points);
Assert.That(hull, Is.EquivalentTo(expectedPoints));
}
}
}

View File

@ -134,7 +134,7 @@ namespace osu.Game.Tests.Visual.Editing
OriginalSurroundingQuad = new Quad(targetContainer!.X, targetContainer.Y, targetContainer.Width, targetContainer.Height);
}
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both)
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
if (targetContainer == null)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!");

View File

@ -73,7 +73,7 @@ namespace osu.Game.Overlays.SkinEditor
isFlippedY = false;
}
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both)
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
if (objectsInScale == null)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!");

View File

@ -52,10 +52,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// If the default <see langword="null"/> value is supplied, a sane implementation-defined default will be used.
/// </param>
/// <param name="adjustAxis">The axes to adjust the scale in.</param>
public void ScaleSelection(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both)
/// <param name="axisRotation">The rotation of the axes in degrees.</param>
public void ScaleSelection(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
Begin();
Update(scale, origin, adjustAxis);
Update(scale, origin, adjustAxis, axisRotation);
Commit();
}
@ -91,7 +92,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// If the default <see langword="null"/> value is supplied, a sane implementation-defined default will be used.
/// </param>
/// <param name="adjustAxis">The axes to adjust the scale in.</param>
public virtual void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both)
/// <param name="axisRotation">The rotation of the axes in degrees.</param>
public virtual void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
}

View File

@ -51,6 +51,9 @@ namespace osu.Game.Utils
/// Given a flip direction, a surrounding quad for all selected objects, and a position,
/// will return the flipped position in screen space coordinates.
/// </summary>
/// <param name="direction">The direction to flip towards.</param>
/// <param name="quad">The quad surrounding all selected objects. The center of this determines the position of the axis.</param>
/// <param name="position">The position to flip.</param>
public static Vector2 GetFlippedPosition(Direction direction, Quad quad, Vector2 position)
{
var centre = quad.Centre;
@ -69,6 +72,20 @@ namespace osu.Game.Utils
return position;
}
/// <summary>
/// Given a flip axis vector, a surrounding quad for all selected objects, and a position,
/// will return the flipped position in screen space coordinates.
/// </summary>
/// <param name="axis">The vector indicating the direction to flip towards. This is perpendicular to the mirroring axis.</param>
/// <param name="quad">The quad surrounding all selected objects. The center of this determines the position of the axis.</param>
/// <param name="position">The position to flip.</param>
public static Vector2 GetFlippedPosition(Vector2 axis, Quad quad, Vector2 position)
{
var centre = quad.Centre;
return position - 2 * Vector2.Dot(position - centre, axis) * axis;
}
/// <summary>
/// Given a scale vector, a surrounding quad for all selected objects, and a position,
/// will return the scaled position in screen space coordinates.
@ -93,9 +110,9 @@ namespace osu.Game.Utils
/// Given a scale multiplier, an origin, and a position,
/// will return the scaled position in screen space coordinates.
/// </summary>
public static Vector2 GetScaledPosition(Vector2 scale, Vector2 origin, Vector2 position)
public static Vector2 GetScaledPosition(Vector2 scale, Vector2 origin, Vector2 position, float axisRotation = 0)
{
return origin + (position - origin) * scale;
return origin + RotateVector(RotateVector(position - origin, axisRotation) * scale, -axisRotation);
}
/// <summary>
@ -127,7 +144,67 @@ namespace osu.Game.Utils
/// </summary>
/// <param name="hitObjects">The hit objects to calculate a quad for.</param>
public static Quad GetSurroundingQuad(IEnumerable<IHasPosition> hitObjects) =>
GetSurroundingQuad(hitObjects.SelectMany(h =>
GetSurroundingQuad(enumerateStartAndEndPositions(hitObjects));
/// <summary>
/// Returns the points that make up the convex hull of the provided points.
/// </summary>
/// <param name="points">The points to calculate a convex hull.</param>
public static List<Vector2> GetConvexHull(IEnumerable<Vector2> points)
{
var pointsList = points.OrderBy(p => p.X).ThenBy(p => p.Y).ToList();
if (pointsList.Count < 3)
return pointsList;
var convexHullLower = new List<Vector2>
{
pointsList[0],
pointsList[1]
};
var convexHullUpper = new List<Vector2>
{
pointsList[^1],
pointsList[^2]
};
// Build the lower hull.
for (int i = 2; i < pointsList.Count; i++)
{
Vector2 c = pointsList[i];
while (convexHullLower.Count > 1 && isClockwise(convexHullLower[^2], convexHullLower[^1], c))
convexHullLower.RemoveAt(convexHullLower.Count - 1);
convexHullLower.Add(c);
}
// Build the upper hull.
for (int i = pointsList.Count - 3; i >= 0; i--)
{
Vector2 c = pointsList[i];
while (convexHullUpper.Count > 1 && isClockwise(convexHullUpper[^2], convexHullUpper[^1], c))
convexHullUpper.RemoveAt(convexHullUpper.Count - 1);
convexHullUpper.Add(c);
}
convexHullLower.RemoveAt(convexHullLower.Count - 1);
convexHullUpper.RemoveAt(convexHullUpper.Count - 1);
convexHullLower.AddRange(convexHullUpper);
return convexHullLower;
float crossProduct(Vector2 v1, Vector2 v2) => v1.X * v2.Y - v1.Y * v2.X;
bool isClockwise(Vector2 a, Vector2 b, Vector2 c) => crossProduct(b - a, c - a) >= 0;
}
public static List<Vector2> GetConvexHull(IEnumerable<IHasPosition> hitObjects) =>
GetConvexHull(enumerateStartAndEndPositions(hitObjects));
private static IEnumerable<Vector2> enumerateStartAndEndPositions(IEnumerable<IHasPosition> hitObjects) =>
hitObjects.SelectMany(h =>
{
if (h is IHasPath path)
{
@ -140,6 +217,6 @@ namespace osu.Game.Utils
}
return new[] { h.Position };
}));
});
}
}