1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-17 03:02:56 +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, () => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position,
() => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200))); () => 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", AddAssert("first object rotated 90deg around selection centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200))); () => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
AddAssert("second object rotated 90deg around selection centre", AddAssert("second object rotated 90deg around selection centre",

View File

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

View File

@ -3,6 +3,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface; using osu.Framework.Graphics.UserInterface;
@ -25,6 +26,9 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
public partial class OsuSelectionHandler : EditorSelectionHandler public partial class OsuSelectionHandler : EditorSelectionHandler
{ {
[Resolved]
private OsuGridToolboxGroup gridToolbox { get; set; } = null!;
protected override void OnSelectionChanged() protected override void OnSelectionChanged()
{ {
base.OnSelectionChanged(); base.OnSelectionChanged();
@ -123,13 +127,43 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
var hitObjects = selectedMovableObjects; 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; bool didFlip = false;
foreach (var h in hitObjects) 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)) if (!Precision.AlmostEquals(flippedPosition, h.Position))
{ {
@ -142,12 +176,7 @@ namespace osu.Game.Rulesets.Osu.Edit
didFlip = true; didFlip = true;
foreach (var cp in slider.Path.ControlPoints) foreach (var cp in slider.Path.ControlPoints)
{ cp.Position = GeometryUtils.GetFlippedPosition(flipAxis, controlPointFlipQuad, cp.Position);
cp.Position = new Vector2(
(direction == Direction.Horizontal ? -1 : 1) * cp.Position.X,
(direction == Direction.Vertical ? -1 : 1) * cp.Position.Y
);
}
} }
} }

View File

@ -69,6 +69,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private Dictionary<OsuHitObject, OriginalHitObjectState>? objectsInScale; private Dictionary<OsuHitObject, OriginalHitObjectState>? objectsInScale;
private Vector2? defaultOrigin; private Vector2? defaultOrigin;
private List<Vector2>? originalConvexHull;
public override void Begin() 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(slider.Path.ControlPoints.Select(p => slider.Position + p.Position))
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys); : GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
defaultOrigin = OriginalSurroundingQuad.Value.Centre; 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) if (!OperationInProgress.Value)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); 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); Debug.Assert(objectsInScale != null && defaultOrigin != null && OriginalSurroundingQuad != null);
Vector2 actualOrigin = origin ?? defaultOrigin.Value; Vector2 actualOrigin = origin ?? defaultOrigin.Value;
scale = clampScaleToAdjustAxis(scale, adjustAxis);
// for the time being, allow resizing of slider paths only if the slider is // 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 // 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]; var originalInfo = objectsInScale[slider];
Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null); Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null);
scaleSlider(slider, scale, originalInfo.PathControlPointPositions, originalInfo.PathControlPointTypes); scaleSlider(slider, scale, originalInfo.PathControlPointPositions, originalInfo.PathControlPointTypes, axisRotation);
} }
else else
{ {
scale = ClampScaleToPlayfieldBounds(scale, actualOrigin); scale = ClampScaleToPlayfieldBounds(scale, actualOrigin, adjustAxis, axisRotation);
foreach (var (ho, originalState) in objectsInScale) 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>() private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
.Where(h => h is not Spinner); .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)); 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 // 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++) 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]; slider.Path.ControlPoints[i].Type = originalPathTypes[i];
} }
@ -176,11 +201,13 @@ namespace osu.Game.Rulesets.Osu.Edit
/// </summary> /// </summary>
/// <param name="origin">The origin from which the scale operation is performed</param> /// <param name="origin">The origin from which the scale operation is performed</param>
/// <param name="scale">The scale to be clamped</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> /// <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. //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; return scale;
Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null); 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) if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider)
origin = slider.Position; 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; Vector2 actualOrigin = origin ?? defaultOrigin.Value;
IEnumerable<Vector2> points;
if (axisRotation == 0)
{
var selectionQuad = OriginalSurroundingQuad.Value; var selectionQuad = OriginalSurroundingQuad.Value;
points = new[]
{
selectionQuad.TopLeft,
selectionQuad.TopRight,
selectionQuad.BottomLeft,
selectionQuad.BottomRight
};
}
else
points = originalConvexHull!;
var tl1 = Vector2.Divide(-actualOrigin, selectionQuad.TopLeft - actualOrigin); foreach (var point in points)
var tl2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.TopLeft - actualOrigin); {
var br1 = Vector2.Divide(-actualOrigin, selectionQuad.BottomRight - actualOrigin); scale = clampToBound(scale, point, Vector2.Zero);
var br2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.BottomRight - actualOrigin); scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE);
}
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);
return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); 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() private void moveSelectionInBounds()

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -19,16 +20,19 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
private readonly SelectionRotationHandler rotationHandler; 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 SliderWithTextBoxInput<float> angleInput = null!;
private EditorRadioButtonCollection rotationOrigin = null!; private EditorRadioButtonCollection rotationOrigin = null!;
private RadioButton selectionCentreButton = null!; private RadioButton selectionCentreButton = null!;
public PreciseRotationPopover(SelectionRotationHandler rotationHandler) public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox)
{ {
this.rotationHandler = rotationHandler; this.rotationHandler = rotationHandler;
this.gridToolbox = gridToolbox;
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
} }
@ -58,6 +62,9 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Items = new[] Items = new[]
{ {
new RadioButton("Grid centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.GridCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
new RadioButton("Playfield centre", new RadioButton("Playfield centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre }, () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }), () => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
@ -93,10 +100,19 @@ namespace osu.Game.Rulesets.Osu.Edit
rotationInfo.BindValueChanged(rotation => 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() protected override void PopIn()
{ {
base.PopIn(); base.PopIn();
@ -114,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public enum RotationOrigin public enum RotationOrigin
{ {
GridCentre,
PlayfieldCentre, PlayfieldCentre,
SelectionCentre SelectionCentre
} }

View File

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

View File

@ -27,6 +27,8 @@ namespace osu.Game.Rulesets.Osu.Edit
public SelectionRotationHandler RotationHandler { get; init; } = null!; public SelectionRotationHandler RotationHandler { get; init; } = null!;
public OsuSelectionScaleHandler ScaleHandler { get; init; } = null!; public OsuSelectionScaleHandler ScaleHandler { get; init; } = null!;
public OsuGridToolboxGroup GridToolbox { get; init; } = null!;
public TransformToolboxGroup() public TransformToolboxGroup()
: base("transform") : base("transform")
{ {
@ -44,10 +46,10 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
rotateButton = new EditorToolButton("Rotate", rotateButton = new EditorToolButton("Rotate",
() => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, () => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
() => new PreciseRotationPopover(RotationHandler)), () => new PreciseRotationPopover(RotationHandler, GridToolbox)),
scaleButton = new EditorToolButton("Scale", scaleButton = new EditorToolButton("Scale",
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, () => 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); 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) if (targetContainer == null)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); 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; 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) if (objectsInScale == null)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); 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. /// If the default <see langword="null"/> value is supplied, a sane implementation-defined default will be used.
/// </param> /// </param>
/// <param name="adjustAxis">The axes to adjust the scale in.</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(); Begin();
Update(scale, origin, adjustAxis); Update(scale, origin, adjustAxis, axisRotation);
Commit(); 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. /// If the default <see langword="null"/> value is supplied, a sane implementation-defined default will be used.
/// </param> /// </param>
/// <param name="adjustAxis">The axes to adjust the scale in.</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, /// Given a flip direction, a surrounding quad for all selected objects, and a position,
/// will return the flipped position in screen space coordinates. /// will return the flipped position in screen space coordinates.
/// </summary> /// </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) public static Vector2 GetFlippedPosition(Direction direction, Quad quad, Vector2 position)
{ {
var centre = quad.Centre; var centre = quad.Centre;
@ -69,6 +72,20 @@ namespace osu.Game.Utils
return position; 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> /// <summary>
/// Given a scale vector, a surrounding quad for all selected objects, and a position, /// Given a scale vector, a surrounding quad for all selected objects, and a position,
/// will return the scaled position in screen space coordinates. /// 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, /// Given a scale multiplier, an origin, and a position,
/// will return the scaled position in screen space coordinates. /// will return the scaled position in screen space coordinates.
/// </summary> /// </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> /// <summary>
@ -127,7 +144,67 @@ namespace osu.Game.Utils
/// </summary> /// </summary>
/// <param name="hitObjects">The hit objects to calculate a quad for.</param> /// <param name="hitObjects">The hit objects to calculate a quad for.</param>
public static Quad GetSurroundingQuad(IEnumerable<IHasPosition> hitObjects) => 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) if (h is IHasPath path)
{ {
@ -140,6 +217,6 @@ namespace osu.Game.Utils
} }
return new[] { h.Position }; return new[] { h.Position };
})); });
} }
} }