From e803b0215f146e449f12a77fae1f09db4fdae30f Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sat, 30 Dec 2023 01:38:08 +0100 Subject: [PATCH 01/26] flip along grid axis --- .../Edit/OsuSelectionHandler.cs | 19 ++++++++++--------- osu.Game/Utils/GeometryUtils.cs | 11 +++++++++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index cea2adc6e2..021c735ebd 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -15,7 +16,6 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Utils; using osuTK; @@ -28,6 +28,9 @@ namespace osu.Game.Rulesets.Osu.Edit [Resolved(CanBeNull = true)] private IDistanceSnapProvider? snapProvider { get; set; } + [Resolved] + private OsuGridToolboxGroup gridToolbox { get; set; } = null!; + /// /// During a transform, the initial path types of a single selected slider are stored so they /// can be maintained throughout the operation. @@ -104,13 +107,16 @@ 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); + var flipQuad = flipOverOrigin ? new Quad(gridToolbox.StartPositionX.Value, gridToolbox.StartPositionY.Value, 0, 0) : GeometryUtils.GetSurroundingQuad(hitObjects); + var flipAxis = flipOverOrigin ? new Vector2(MathF.Cos(MathHelper.DegreesToRadians(gridToolbox.GridLinesRotation.Value)), MathF.Sin(MathHelper.DegreesToRadians(gridToolbox.GridLinesRotation.Value))) : Vector2.UnitX; + flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis; + 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); if (!Precision.AlmostEquals(flippedPosition, h.Position)) { @@ -123,12 +129,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); } } diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index e0d217dd48..aacf9b91f9 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -70,6 +70,17 @@ namespace osu.Game.Utils return position; } + /// + /// Given a flip axis vector, a surrounding quad for all selected objects, and a position, + /// will return the flipped position in screen space coordinates. + /// + public static Vector2 GetFlippedPosition(Vector2 axis, Quad quad, Vector2 position) + { + var centre = quad.Centre; + + return position - 2 * Vector2.Dot(position - centre, axis) * axis; + } + /// /// Given a scale vector, a surrounding quad for all selected objects, and a position, /// will return the scaled position in screen space coordinates. From 078fe5a78ca5b0744d12c6f4fa8f242a0730986c Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sat, 30 Dec 2023 01:53:19 +0100 Subject: [PATCH 02/26] Rotate popover rotates around grid center --- osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs | 2 +- osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs | 8 +++++--- osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 84d5adbc52..02e98d75a7 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -104,7 +104,7 @@ namespace osu.Game.Rulesets.Osu.Edit RightToolbox.AddRange(new EditorToolboxGroup[] { OsuGridToolboxGroup, - new TransformToolboxGroup { RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler, }, + new TransformToolboxGroup { RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler, GridToolbox = OsuGridToolboxGroup }, FreehandlSliderToolboxGroup } ); diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index f09d6b78e6..02a8ff5872 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -19,14 +18,17 @@ namespace osu.Game.Rulesets.Osu.Edit { private readonly SelectionRotationHandler rotationHandler; + private readonly OsuGridToolboxGroup gridToolbox; + private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre)); private SliderWithTextBoxInput angleInput = null!; private EditorRadioButtonCollection rotationOrigin = null!; - public PreciseRotationPopover(SelectionRotationHandler rotationHandler) + public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox) { this.rotationHandler = rotationHandler; + this.gridToolbox = gridToolbox; AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; } @@ -78,7 +80,7 @@ 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, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? gridToolbox.StartPosition.Value : null); }); } diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs index 3da9f5b69b..f8df45f545 100644 --- a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs @@ -24,6 +24,8 @@ namespace osu.Game.Rulesets.Osu.Edit public SelectionRotationHandler RotationHandler { get; init; } = null!; + public OsuGridToolboxGroup GridToolbox { get; init; } = null!; + public TransformToolboxGroup() : base("transform") { @@ -41,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Edit { rotateButton = new EditorToolButton("Rotate", () => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, - () => new PreciseRotationPopover(RotationHandler)), + () => new PreciseRotationPopover(RotationHandler, GridToolbox)), // TODO: scale } }; From 09852bc46b6b0b7a78914e19ba3414efab60afdd Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sun, 31 Dec 2023 21:23:13 +0100 Subject: [PATCH 03/26] fix horizontal vs vertical flips being mixed up when rotation angle is too big --- osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 021c735ebd..1cb206c2f8 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -107,9 +106,13 @@ namespace osu.Game.Rulesets.Osu.Edit { var hitObjects = selectedMovableObjects; + // 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); - var flipAxis = flipOverOrigin ? new Vector2(MathF.Cos(MathHelper.DegreesToRadians(gridToolbox.GridLinesRotation.Value)), MathF.Sin(MathHelper.DegreesToRadians(gridToolbox.GridLinesRotation.Value))) : Vector2.UnitX; + // If we're flipping over the origin, we take the grid rotation from the grid toolbox. + // We want to normalize the rotation angle to -45 to 45 degrees, so horizontal vs vertical flip is not mixed up by the rotation and it stays intuitive to use. + var flipAxis = flipOverOrigin ? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 405) % 90 - 45)) : Vector2.UnitX; flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis; + var controlPointFlipQuad = new Quad(); bool didFlip = false; From 236f029dad22722ebf73c02d40dc19b312b16642 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Thu, 1 Feb 2024 16:56:57 +0100 Subject: [PATCH 04/26] Remove Masking from PositionSnapGrid This caused issues in rendering the outline of the grid because the outline was getting masked at some resolutions. --- .../Components/LinedPositionSnapGrid.cs | 128 +++++++++++++++--- .../Compose/Components/PositionSnapGrid.cs | 2 - 2 files changed, 106 insertions(+), 24 deletions(-) diff --git a/osu.Game/Screens/Edit/Compose/Components/LinedPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/LinedPositionSnapGrid.cs index ebdd76a4e2..8a7f6b5344 100644 --- a/osu.Game/Screens/Edit/Compose/Components/LinedPositionSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/LinedPositionSnapGrid.cs @@ -15,18 +15,29 @@ namespace osu.Game.Screens.Edit.Compose.Components { protected void GenerateGridLines(Vector2 step, Vector2 drawSize) { + if (Precision.AlmostEquals(step, Vector2.Zero)) + return; + int index = 0; - var currentPosition = StartPosition.Value; // Make lines the same width independent of display resolution. float lineWidth = DrawWidth / ScreenSpaceDrawQuad.Width; - float lineLength = drawSize.Length * 2; + float rotation = MathHelper.RadiansToDegrees(MathF.Atan2(step.Y, step.X)); List generatedLines = new List(); - while (lineDefinitelyIntersectsBox(currentPosition, step.PerpendicularLeft, drawSize) || - isMovingTowardsBox(currentPosition, step, drawSize)) + while (true) { + Vector2 currentPosition = StartPosition.Value + index++ * step; + + if (!lineDefinitelyIntersectsBox(currentPosition, step.PerpendicularLeft, drawSize, out var p1, out var p2)) + { + if (!isMovingTowardsBox(currentPosition, step, drawSize)) + break; + + continue; + } + var gridLine = new Box { Colour = Colour4.White, @@ -34,15 +45,12 @@ namespace osu.Game.Screens.Edit.Compose.Components Origin = Anchor.Centre, RelativeSizeAxes = Axes.None, Width = lineWidth, - Height = lineLength, - Position = currentPosition, - Rotation = MathHelper.RadiansToDegrees(MathF.Atan2(step.Y, step.X)), + Height = Vector2.Distance(p1, p2), + Position = (p1 + p2) / 2, + Rotation = rotation, }; generatedLines.Add(gridLine); - - index += 1; - currentPosition = StartPosition.Value + index * step; } if (generatedLines.Count == 0) @@ -59,23 +67,99 @@ namespace osu.Game.Screens.Edit.Compose.Components (currentPosition + step - box).LengthSquared < (currentPosition - box).LengthSquared; } - private bool lineDefinitelyIntersectsBox(Vector2 lineStart, Vector2 lineDir, Vector2 box) + /// + /// Determines if the line starting at and going in the direction of + /// definitely intersects the box on (0, 0) with the given width and height and returns the intersection points if it does. + /// + /// The start point of the line. + /// The direction of the line. + /// The width and height of the box. + /// The first intersection point. + /// The second intersection point. + /// Whether the line definitely intersects the box. + private bool lineDefinitelyIntersectsBox(Vector2 lineStart, Vector2 lineDir, Vector2 box, out Vector2 p1, out Vector2 p2) { - var p2 = lineStart + lineDir; + p1 = Vector2.Zero; + p2 = Vector2.Zero; - double d1 = det(Vector2.Zero); - double d2 = det(new Vector2(box.X, 0)); - double d3 = det(new Vector2(0, box.Y)); - double d4 = det(box); + if (Precision.AlmostEquals(lineDir.X, 0)) + { + // If the line is vertical, we only need to check if the X coordinate of the line is within the box. + if (!Precision.DefinitelyBigger(lineStart.X, 0) || !Precision.DefinitelyBigger(box.X, lineStart.X)) + return false; - return definitelyDifferentSign(d1, d2) || definitelyDifferentSign(d3, d4) || - definitelyDifferentSign(d1, d3) || definitelyDifferentSign(d2, d4); + p1 = new Vector2(lineStart.X, 0); + p2 = new Vector2(lineStart.X, box.Y); + return true; + } - double det(Vector2 p) => (p.X - lineStart.X) * (p2.Y - lineStart.Y) - (p.Y - lineStart.Y) * (p2.X - lineStart.X); + if (Precision.AlmostEquals(lineDir.Y, 0)) + { + // If the line is horizontal, we only need to check if the Y coordinate of the line is within the box. + if (!Precision.DefinitelyBigger(lineStart.Y, 0) || !Precision.DefinitelyBigger(box.Y, lineStart.Y)) + return false; - bool definitelyDifferentSign(double a, double b) => !Precision.AlmostEquals(a, 0) && - !Precision.AlmostEquals(b, 0) && - Math.Sign(a) != Math.Sign(b); + p1 = new Vector2(0, lineStart.Y); + p2 = new Vector2(box.X, lineStart.Y); + return true; + } + + float m = lineDir.Y / lineDir.X; + float mInv = lineDir.X / lineDir.Y; // Use this to improve numerical stability if X is close to zero. + float b = lineStart.Y - m * lineStart.X; + + // Calculate intersection points with the sides of the box. + var p = new List(4); + + if (0 <= b && b <= box.Y) + p.Add(new Vector2(0, b)); + if (0 <= (box.Y - b) * mInv && (box.Y - b) * mInv <= box.X) + p.Add(new Vector2((box.Y - b) * mInv, box.Y)); + if (0 <= m * box.X + b && m * box.X + b <= box.Y) + p.Add(new Vector2(box.X, m * box.X + b)); + if (0 <= -b * mInv && -b * mInv <= box.X) + p.Add(new Vector2(-b * mInv, 0)); + + switch (p.Count) + { + case 4: + // If there are 4 intersection points, the line is a diagonal of the box. + if (m > 0) + { + p1 = Vector2.Zero; + p2 = box; + } + else + { + p1 = new Vector2(0, box.Y); + p2 = new Vector2(box.X, 0); + } + + break; + + case 3: + // If there are 3 intersection points, the line goes through a corner of the box. + if (p[0] == p[1]) + { + p1 = p[0]; + p2 = p[2]; + } + else + { + p1 = p[0]; + p2 = p[1]; + } + + break; + + case 2: + p1 = p[0]; + p2 = p[1]; + + break; + } + + return !Precision.AlmostEquals(p1, p2); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/PositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/PositionSnapGrid.cs index 36687ef73a..e576ac1e49 100644 --- a/osu.Game/Screens/Edit/Compose/Components/PositionSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/PositionSnapGrid.cs @@ -21,8 +21,6 @@ namespace osu.Game.Screens.Edit.Compose.Components protected PositionSnapGrid() { - Masking = true; - StartPosition.BindValueChanged(_ => GridCache.Invalidate()); AddLayout(GridCache); From b4b5cdfcf2d84527ec4d5cb9e504152be59b7e19 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Thu, 1 Feb 2024 17:07:03 +0100 Subject: [PATCH 05/26] Fix masking in circular snap grid --- .../Edit/Compose/Components/CircularPositionSnapGrid.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularPositionSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularPositionSnapGrid.cs index 403a270359..791cb33439 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularPositionSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularPositionSnapGrid.cs @@ -82,7 +82,12 @@ namespace osu.Game.Screens.Edit.Compose.Components generatedCircles.First().Alpha = 0.8f; - AddRangeInternal(generatedCircles); + AddInternal(new Container + { + Masking = true, + RelativeSizeAxes = Axes.Both, + Children = generatedCircles, + }); } public override Vector2 GetSnappedPosition(Vector2 original) From b15028a918ececff597d694c3315d732cf784cdc Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 3 Jul 2024 12:36:12 +0200 Subject: [PATCH 06/26] fixes --- osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 8d80ed651e..7720afe60a 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -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; @@ -23,18 +24,9 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class OsuSelectionHandler : EditorSelectionHandler { - [Resolved(CanBeNull = true)] - private IDistanceSnapProvider? snapProvider { get; set; } - [Resolved] private OsuGridToolboxGroup gridToolbox { get; set; } = null!; - /// - /// During a transform, the initial path types of a single selected slider are stored so they - /// can be maintained throughout the operation. - /// - private List? referencePathTypes; - protected override void OnSelectionChanged() { base.OnSelectionChanged(); From 5f8512896e0eee0eda8ec0c9410704422d475182 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 3 Jul 2024 12:40:22 +0200 Subject: [PATCH 07/26] use grid origin in scale tool --- osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs | 8 +++++--- osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index a299eebbce..65a07e2e2f 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Components.RadioButtons; using osuTK; @@ -20,6 +19,8 @@ namespace osu.Game.Rulesets.Osu.Edit { private readonly OsuSelectionScaleHandler scaleHandler; + private readonly OsuGridToolboxGroup gridToolbox; + private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, ScaleOrigin.PlayfieldCentre, true, true)); private SliderWithTextBoxInput scaleInput = null!; @@ -32,9 +33,10 @@ namespace osu.Game.Rulesets.Osu.Edit 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 }; } @@ -179,7 +181,7 @@ 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 == ScaleOrigin.PlayfieldCentre ? gridToolbox.StartPosition.Value : null; private void setAxis(bool x, bool y) { diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs index 2e4d7e8b91..a41412cbe3 100644 --- a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs @@ -49,7 +49,7 @@ namespace osu.Game.Rulesets.Osu.Edit () => new PreciseRotationPopover(RotationHandler, GridToolbox)), scaleButton = new EditorToolButton("Scale", () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, - () => new PreciseScalePopover(ScaleHandler)) + () => new PreciseScalePopover(ScaleHandler, GridToolbox)) } }; } From d0715c5f12a98e9a31465600d1cc69bb0efe1df2 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 3 Jul 2024 16:23:19 +0200 Subject: [PATCH 08/26] scale along rotated axis --- .../Edit/OsuSelectionScaleHandler.cs | 104 ++++++++++++++---- .../Edit/PreciseScalePopover.cs | 8 +- .../Editing/TestSceneComposeSelectBox.cs | 2 +- .../SkinEditor/SkinSelectionScaleHandler.cs | 2 +- .../Components/SelectionScaleHandler.cs | 8 +- osu.Game/Utils/GeometryUtils.cs | 4 +- 6 files changed, 97 insertions(+), 31 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index f4fd48f183..cfcf90e5f5 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.Osu.Edit defaultOrigin = OriginalSurroundingQuad.Value.Centre; } - 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 +94,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 +103,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 +135,34 @@ namespace osu.Game.Rulesets.Osu.Edit private IEnumerable selectedMovableObjects => selectedItems.Cast() .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 +197,13 @@ namespace osu.Game.Rulesets.Osu.Edit /// /// The origin from which the scale operation is performed /// The scale to be clamped + /// The axes to adjust the scale in. + /// The rotation of the axes in degrees /// The clamped scale vector - 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 +211,63 @@ namespace osu.Game.Rulesets.Osu.Edit if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider) origin = slider.Position; + scale = clampScaleToAdjustAxis(scale, adjustAxis); Vector2 actualOrigin = origin ?? defaultOrigin.Value; var selectionQuad = OriginalSurroundingQuad.Value; - 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 (!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); + scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE.X, Axes.X); + scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE.Y, Axes.Y); + scale = clampToBound(scale, selectionQuad.TopLeft, 0, Axes.X); + scale = clampToBound(scale, selectionQuad.TopLeft, 0, Axes.Y); return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); + + Vector2 clampToBound(Vector2 s, Vector2 p, float bound, Axes axis) + { + float px = p.X - actualOrigin.X; + float py = p.Y - actualOrigin.Y; + float c = axis == Axes.X ? bound - actualOrigin.X : bound - actualOrigin.Y; + float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); + float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); + float a, b; + + if (axis == Axes.X) + { + a = cos * cos * px - sin * cos * py; + b = sin * sin * px + sin * cos * py; + } + else + { + a = -sin * cos * px + sin * sin * py; + b = sin * cos * px + cos * cos * py; + } + + switch (adjustAxis) + { + case Axes.X: + if (Precision.AlmostEquals(a, 0) || (c - b) / a < 0) + break; + + s.X = MathF.Min(scale.X, (c - b) / a); + break; + + case Axes.Y: + if (Precision.AlmostEquals(b, 0) || (c - a) / b < 0) + break; + + s.Y = MathF.Min(scale.Y, (c - a) / b); + break; + + case Axes.Both: + if (Precision.AlmostEquals(a + b, 0) || c / (a * s.X + b * s.Y) < 0) + break; + + s = Vector2.ComponentMin(s, s * c / (a * s.X + b * s.Y)); + break; + } + + return s; + } } private void moveSelectionInBounds() diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index 65a07e2e2f..a1907a2fd5 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -130,8 +130,8 @@ namespace osu.Game.Rulesets.Osu.Edit 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), gridToolbox.GridLinesRotation.Value); }); } @@ -164,7 +164,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), gridToolbox.GridLinesRotation.Value); if (!scaleInfo.Value.XAxis) scale.X = max_scale; @@ -183,6 +183,8 @@ namespace osu.Game.Rulesets.Osu.Edit private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? gridToolbox.StartPosition.Value : null; + private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y; + private void setAxis(bool x, bool y) { scaleInfo.Value = scaleInfo.Value with { XAxis = x, YAxis = y }; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index 28763051e3..30f397f518 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -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!"); diff --git a/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs b/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs index 4bfa7fba81..977aaade99 100644 --- a/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinSelectionScaleHandler.cs @@ -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!"); diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs index c91362219c..177de9df33 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs @@ -52,10 +52,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// If the default value is supplied, a sane implementation-defined default will be used. /// /// The axes to adjust the scale in. - public void ScaleSelection(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both) + /// The rotation of the axes in degrees. + 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 value is supplied, a sane implementation-defined default will be used. /// /// The axes to adjust the scale in. - public virtual void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both) + /// The rotation of the axes in degrees. + public virtual void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) { } diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index bf1addf6c8..f6e7e81007 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -104,9 +104,9 @@ namespace osu.Game.Utils /// Given a scale multiplier, an origin, and a position, /// will return the scaled position in screen space coordinates. /// - 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); } /// From 979a5e9f3e5de341bcfe785ca7978f5449e0fd78 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 3 Jul 2024 16:41:41 +0200 Subject: [PATCH 09/26] simplify code --- .../Edit/OsuSelectionScaleHandler.cs | 46 ++++++------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index cfcf90e5f5..2cf5a604ed 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -194,7 +194,7 @@ namespace osu.Game.Rulesets.Osu.Edit /// /// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip. - /// + /// The origin from which the scale operation is performed /// The scale to be clamped /// The axes to adjust the scale in. @@ -215,54 +215,34 @@ namespace osu.Game.Rulesets.Osu.Edit Vector2 actualOrigin = origin ?? defaultOrigin.Value; var selectionQuad = OriginalSurroundingQuad.Value; - scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE.X, Axes.X); - scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE.Y, Axes.Y); - scale = clampToBound(scale, selectionQuad.TopLeft, 0, Axes.X); - scale = clampToBound(scale, selectionQuad.TopLeft, 0, Axes.Y); + scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE); + scale = clampToBound(scale, selectionQuad.TopLeft, Vector2.Zero); return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); - Vector2 clampToBound(Vector2 s, Vector2 p, float bound, Axes axis) + 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) { - float px = p.X - actualOrigin.X; - float py = p.Y - actualOrigin.Y; - float c = axis == Axes.X ? bound - actualOrigin.X : bound - actualOrigin.Y; + p -= actualOrigin; + bound -= actualOrigin; float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); - float a, b; - - if (axis == Axes.X) - { - a = cos * cos * px - sin * cos * py; - b = sin * sin * px + sin * cos * py; - } - else - { - a = -sin * cos * px + sin * sin * py; - b = sin * cos * px + cos * cos * py; - } + 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: - if (Precision.AlmostEquals(a, 0) || (c - b) / a < 0) - break; - - s.X = MathF.Min(scale.X, (c - b) / a); + s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a))); break; case Axes.Y: - if (Precision.AlmostEquals(b, 0) || (c - a) / b < 0) - break; - - s.Y = MathF.Min(scale.Y, (c - a) / b); + s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b))); break; case Axes.Both: - if (Precision.AlmostEquals(a + b, 0) || c / (a * s.X + b * s.Y) < 0) - break; - - s = Vector2.ComponentMin(s, s * c / (a * s.X + b * s.Y)); + s = Vector2.ComponentMin(s, s * minPositiveComponent(Vector2.Divide(bound, a * s.X + b * s.Y))); break; } From 0797d942aecde4e267fca11fbec5bf73611fc9b8 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 3 Jul 2024 16:41:57 +0200 Subject: [PATCH 10/26] fix warning --- osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index 2cf5a604ed..8b87246456 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -194,7 +194,7 @@ namespace osu.Game.Rulesets.Osu.Edit /// /// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip. - /// /// The origin from which the scale operation is performed /// The scale to be clamped /// The axes to adjust the scale in. From 4165ded8134d05f4d6b934255a5678a6a7d74bca Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 3 Jul 2024 19:03:15 +0200 Subject: [PATCH 11/26] fix incorrect rotated bound checking --- .../Edit/OsuSelectionScaleHandler.cs | 53 +++++++++++++++---- osu.Game/Utils/GeometryUtils.cs | 20 ++++--- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index 8b87246456..d336261499 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -80,12 +80,32 @@ namespace osu.Game.Rulesets.Osu.Edit changeHandler?.BeginChange(); objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho)); - 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); + OriginalSurroundingQuad = getOriginalSurroundingQuad()!; defaultOrigin = OriginalSurroundingQuad.Value.Centre; } + private Quad? getOriginalSurroundingQuad(float axisRotation = 0) + { + if (objectsInScale == null) + return null; + + return objectsInScale.Count == 1 && objectsInScale.First().Value.PathControlPointPositions != null + ? GeometryUtils.GetSurroundingQuad(objectsInScale.First().Value.PathControlPointPositions!.Select(p => objectsInScale.First().Value.Position + p), axisRotation) + : GeometryUtils.GetSurroundingQuad(objectsInScale.Values.SelectMany(s => + { + if (s.EndPosition.HasValue) + { + return new[] + { + s.Position, + s.Position + s.EndPosition.Value + }; + } + + return new[] { s.Position }; + }), axisRotation); + } + public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) { if (!OperationInProgress.Value) @@ -213,10 +233,23 @@ namespace osu.Game.Rulesets.Osu.Edit scale = clampScaleToAdjustAxis(scale, adjustAxis); Vector2 actualOrigin = origin ?? defaultOrigin.Value; - var selectionQuad = OriginalSurroundingQuad.Value; + var selectionQuad = axisRotation == 0 ? OriginalSurroundingQuad.Value : getOriginalSurroundingQuad(axisRotation)!.Value; + var points = new[] + { + selectionQuad.TopLeft, + selectionQuad.TopRight, + selectionQuad.BottomLeft, + selectionQuad.BottomRight + }; - scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE); - scale = clampToBound(scale, selectionQuad.TopLeft, Vector2.Zero); + float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); + float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); + + 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)); @@ -226,19 +259,17 @@ namespace osu.Game.Rulesets.Osu.Edit { p -= actualOrigin; bound -= actualOrigin; - float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); - float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); 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))); + s.X = MathF.Min(s.X, minPositiveComponent(Vector2.Divide(bound - b, a))); break; case Axes.Y: - s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b))); + s.Y = MathF.Min(s.Y, minPositiveComponent(Vector2.Divide(bound - a, b))); break; case Axes.Both: @@ -275,12 +306,14 @@ namespace osu.Game.Rulesets.Osu.Edit public Vector2 Position { get; } public Vector2[]? PathControlPointPositions { get; } public PathType?[]? PathControlPointTypes { get; } + public Vector2? EndPosition { get; } public OriginalHitObjectState(OsuHitObject hitObject) { Position = hitObject.Position; PathControlPointPositions = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Position).ToArray(); PathControlPointTypes = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Type).ToArray(); + EndPosition = (hitObject as IHasPath)?.Path.PositionAt(1); } } } diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index f6e7e81007..23c25cfffa 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -113,7 +113,8 @@ namespace osu.Game.Utils /// Returns a quad surrounding the provided points. /// /// The points to calculate a quad for. - public static Quad GetSurroundingQuad(IEnumerable points) + /// The rotation in degrees of the axis to align the quad to. + public static Quad GetSurroundingQuad(IEnumerable points, float axisRotation = 0) { if (!points.Any()) return new Quad(); @@ -124,20 +125,25 @@ namespace osu.Game.Utils // Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted foreach (var p in points) { - minPosition = Vector2.ComponentMin(minPosition, p); - maxPosition = Vector2.ComponentMax(maxPosition, p); + var pr = RotateVector(p, axisRotation); + minPosition = Vector2.ComponentMin(minPosition, pr); + maxPosition = Vector2.ComponentMax(maxPosition, pr); } - Vector2 size = maxPosition - minPosition; + var p1 = RotateVector(minPosition, -axisRotation); + var p2 = RotateVector(new Vector2(minPosition.X, maxPosition.Y), -axisRotation); + var p3 = RotateVector(maxPosition, -axisRotation); + var p4 = RotateVector(new Vector2(maxPosition.X, minPosition.Y), -axisRotation); - return new Quad(minPosition.X, minPosition.Y, size.X, size.Y); + return new Quad(p1, p2, p3, p4); } /// /// Returns a gamefield-space quad surrounding the provided hit objects. /// /// The hit objects to calculate a quad for. - public static Quad GetSurroundingQuad(IEnumerable hitObjects) => + /// The rotation in degrees of the axis to align the quad to. + public static Quad GetSurroundingQuad(IEnumerable hitObjects, float axisRotation = 0) => GetSurroundingQuad(hitObjects.SelectMany(h => { if (h is IHasPath path) @@ -151,6 +157,6 @@ namespace osu.Game.Utils } return new[] { h.Position }; - })); + }), axisRotation); } } From dfe6c70996b06fa0e597fffbf1aec75e5b1d508c Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 3 Jul 2024 19:08:31 +0200 Subject: [PATCH 12/26] prevent flipping objects far offscreen --- osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 7720afe60a..7d6ef66909 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -15,6 +15,7 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Utils; using osuTK; @@ -119,6 +120,9 @@ namespace osu.Game.Rulesets.Osu.Edit { 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)) { h.Position = flippedPosition; From ae380027772867b45baf80acc910c98cb39fac46 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sun, 14 Jul 2024 15:46:40 +0200 Subject: [PATCH 13/26] Revert "fix incorrect rotated bound checking" This reverts commit 4165ded8134d05f4d6b934255a5678a6a7d74bca. --- .../Edit/OsuSelectionScaleHandler.cs | 53 ++++--------------- osu.Game/Utils/GeometryUtils.cs | 20 +++---- 2 files changed, 17 insertions(+), 56 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index d336261499..8b87246456 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -80,32 +80,12 @@ namespace osu.Game.Rulesets.Osu.Edit changeHandler?.BeginChange(); objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho)); - OriginalSurroundingQuad = getOriginalSurroundingQuad()!; + 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; } - private Quad? getOriginalSurroundingQuad(float axisRotation = 0) - { - if (objectsInScale == null) - return null; - - return objectsInScale.Count == 1 && objectsInScale.First().Value.PathControlPointPositions != null - ? GeometryUtils.GetSurroundingQuad(objectsInScale.First().Value.PathControlPointPositions!.Select(p => objectsInScale.First().Value.Position + p), axisRotation) - : GeometryUtils.GetSurroundingQuad(objectsInScale.Values.SelectMany(s => - { - if (s.EndPosition.HasValue) - { - return new[] - { - s.Position, - s.Position + s.EndPosition.Value - }; - } - - return new[] { s.Position }; - }), axisRotation); - } - public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) { if (!OperationInProgress.Value) @@ -233,23 +213,10 @@ namespace osu.Game.Rulesets.Osu.Edit scale = clampScaleToAdjustAxis(scale, adjustAxis); Vector2 actualOrigin = origin ?? defaultOrigin.Value; - var selectionQuad = axisRotation == 0 ? OriginalSurroundingQuad.Value : getOriginalSurroundingQuad(axisRotation)!.Value; - var points = new[] - { - selectionQuad.TopLeft, - selectionQuad.TopRight, - selectionQuad.BottomLeft, - selectionQuad.BottomRight - }; + var selectionQuad = OriginalSurroundingQuad.Value; - float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); - float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); - - foreach (var point in points) - { - scale = clampToBound(scale, point, Vector2.Zero); - scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE); - } + scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE); + scale = clampToBound(scale, selectionQuad.TopLeft, Vector2.Zero); return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON)); @@ -259,17 +226,19 @@ namespace osu.Game.Rulesets.Osu.Edit { p -= actualOrigin; bound -= actualOrigin; + float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); + float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); 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(s.X, minPositiveComponent(Vector2.Divide(bound - b, a))); + s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a))); break; case Axes.Y: - s.Y = MathF.Min(s.Y, minPositiveComponent(Vector2.Divide(bound - a, b))); + s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b))); break; case Axes.Both: @@ -306,14 +275,12 @@ namespace osu.Game.Rulesets.Osu.Edit public Vector2 Position { get; } public Vector2[]? PathControlPointPositions { get; } public PathType?[]? PathControlPointTypes { get; } - public Vector2? EndPosition { get; } public OriginalHitObjectState(OsuHitObject hitObject) { Position = hitObject.Position; PathControlPointPositions = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Position).ToArray(); PathControlPointTypes = (hitObject as IHasPath)?.Path.ControlPoints.Select(p => p.Type).ToArray(); - EndPosition = (hitObject as IHasPath)?.Path.PositionAt(1); } } } diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index 23c25cfffa..f6e7e81007 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -113,8 +113,7 @@ namespace osu.Game.Utils /// Returns a quad surrounding the provided points. /// /// The points to calculate a quad for. - /// The rotation in degrees of the axis to align the quad to. - public static Quad GetSurroundingQuad(IEnumerable points, float axisRotation = 0) + public static Quad GetSurroundingQuad(IEnumerable points) { if (!points.Any()) return new Quad(); @@ -125,25 +124,20 @@ namespace osu.Game.Utils // Go through all hitobjects to make sure they would remain in the bounds of the editor after movement, before any movement is attempted foreach (var p in points) { - var pr = RotateVector(p, axisRotation); - minPosition = Vector2.ComponentMin(minPosition, pr); - maxPosition = Vector2.ComponentMax(maxPosition, pr); + minPosition = Vector2.ComponentMin(minPosition, p); + maxPosition = Vector2.ComponentMax(maxPosition, p); } - var p1 = RotateVector(minPosition, -axisRotation); - var p2 = RotateVector(new Vector2(minPosition.X, maxPosition.Y), -axisRotation); - var p3 = RotateVector(maxPosition, -axisRotation); - var p4 = RotateVector(new Vector2(maxPosition.X, minPosition.Y), -axisRotation); + Vector2 size = maxPosition - minPosition; - return new Quad(p1, p2, p3, p4); + return new Quad(minPosition.X, minPosition.Y, size.X, size.Y); } /// /// Returns a gamefield-space quad surrounding the provided hit objects. /// /// The hit objects to calculate a quad for. - /// The rotation in degrees of the axis to align the quad to. - public static Quad GetSurroundingQuad(IEnumerable hitObjects, float axisRotation = 0) => + public static Quad GetSurroundingQuad(IEnumerable hitObjects) => GetSurroundingQuad(hitObjects.SelectMany(h => { if (h is IHasPath path) @@ -157,6 +151,6 @@ namespace osu.Game.Utils } return new[] { h.Position }; - }), axisRotation); + })); } } From 58eb7f6fe174ba72f06304d0334b0668dde14c74 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sun, 14 Jul 2024 16:58:05 +0200 Subject: [PATCH 14/26] fix rotated scale bounds again --- .../Edit/OsuSelectionScaleHandler.cs | 31 ++++++++++-- osu.Game/Utils/GeometryUtils.cs | 49 ++++++++++++++++++- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs index 8b87246456..56c3ba9315 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs @@ -69,6 +69,7 @@ namespace osu.Game.Rulesets.Osu.Edit private Dictionary? objectsInScale; private Vector2? defaultOrigin; + private List? originalConvexHull; public override void Begin() { @@ -84,6 +85,9 @@ 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, float axisRotation = 0) @@ -211,12 +215,31 @@ 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 points; - scale = clampToBound(scale, selectionQuad.BottomRight, OsuPlayfield.BASE_SIZE); - scale = clampToBound(scale, selectionQuad.TopLeft, Vector2.Zero); + if (axisRotation == 0) + { + var selectionQuad = OriginalSurroundingQuad.Value; + points = new[] + { + selectionQuad.TopLeft, + selectionQuad.TopRight, + selectionQuad.BottomLeft, + selectionQuad.BottomRight + }; + } + else + points = originalConvexHull!; + + 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)); @@ -226,8 +249,6 @@ namespace osu.Game.Rulesets.Osu.Edit { p -= actualOrigin; bound -= actualOrigin; - float cos = MathF.Cos(float.DegreesToRadians(-axisRotation)); - float sin = MathF.Sin(float.DegreesToRadians(-axisRotation)); 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); diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index f6e7e81007..5a8ca9722e 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -138,7 +138,52 @@ namespace osu.Game.Utils /// /// The hit objects to calculate a quad for. public static Quad GetSurroundingQuad(IEnumerable hitObjects) => - GetSurroundingQuad(hitObjects.SelectMany(h => + GetSurroundingQuad(enumerateStartAndEndPositions(hitObjects)); + + /// + /// Returns the points that make up the convex hull of the provided points. + /// + /// The points to calculate a convex hull. + public static List GetConvexHull(IEnumerable points) + { + List p = points.ToList(); + + if (p.Count <= 1) + return p; + + int n = p.Count, k = 0; + List hull = new List(new Vector2[2 * n]); + + p.Sort((a, b) => a.X == b.X ? a.Y.CompareTo(b.Y) : a.X.CompareTo(b.X)); + + // Build lower hull + for (int i = 0; i < n; ++i) + { + while (k >= 2 && cross(hull[k - 2], hull[k - 1], p[i]) <= 0) + k--; + hull[k] = p[i]; + k++; + } + + // Build upper hull + for (int i = n - 2, t = k + 1; i >= 0; i--) + { + while (k >= t && cross(hull[k - 2], hull[k - 1], p[i]) <= 0) + k--; + hull[k] = p[i]; + k++; + } + + return hull.Take(k - 1).ToList(); + + float cross(Vector2 o, Vector2 a, Vector2 b) => (a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X); + } + + public static List GetConvexHull(IEnumerable hitObjects) => + GetConvexHull(enumerateStartAndEndPositions(hitObjects)); + + private static IEnumerable enumerateStartAndEndPositions(IEnumerable hitObjects) => + hitObjects.SelectMany(h => { if (h is IHasPath path) { @@ -151,6 +196,6 @@ namespace osu.Game.Utils } return new[] { h.Position }; - })); + }); } } From 7a319a6d74ee29fbf3e7b5dbc2b7c6c9ca8e4990 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sun, 14 Jul 2024 17:03:17 +0200 Subject: [PATCH 15/26] dont rotate scale when in selection origin mode --- osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index a1907a2fd5..0f04efcfa5 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -131,7 +131,7 @@ namespace osu.Game.Rulesets.Osu.Edit scaleInfo.BindValueChanged(scale => { var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale); - scaleHandler.Update(newScale, getOriginPosition(scale.NewValue), getAdjustAxis(scale.NewValue), gridToolbox.GridLinesRotation.Value); + scaleHandler.Update(newScale, getOriginPosition(scale.NewValue), getAdjustAxis(scale.NewValue), getRotation(scale.NewValue)); }); } @@ -164,7 +164,7 @@ namespace osu.Game.Rulesets.Osu.Edit return; const float max_scale = 10; - var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), gridToolbox.GridLinesRotation.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; @@ -185,6 +185,8 @@ namespace osu.Game.Rulesets.Osu.Edit private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y; + private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? gridToolbox.GridLinesRotation.Value : 0; + private void setAxis(bool x, bool y) { scaleInfo.Value = scaleInfo.Value with { XAxis = x, YAxis = y }; From 9e5d099b1b1113304294c17155f42ab4a8ea76cd Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sun, 14 Jul 2024 17:13:22 +0200 Subject: [PATCH 16/26] rename playfield centre origin to grid centre --- .../Edit/PreciseRotationPopover.cs | 12 ++++++------ .../Edit/PreciseScalePopover.cs | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index 6a3e326c2b..4a1ccc4b61 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly OsuGridToolboxGroup gridToolbox; - private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre)); + private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.GridCentre)); private SliderWithTextBoxInput angleInput = null!; private EditorRadioButtonCollection rotationOrigin = null!; @@ -60,9 +60,9 @@ namespace osu.Game.Rulesets.Osu.Edit RelativeSizeAxes = Axes.X, Items = new[] { - new RadioButton("Playfield centre", - () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre }, - () => new SpriteIcon { Icon = FontAwesome.Regular.Square }), + new RadioButton("Grid centre", + () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.GridCentre }, + () => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }), selectionCentreButton = new RadioButton("Selection centre", () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre }, () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) @@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.Edit rotationInfo.BindValueChanged(rotation => { - rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? gridToolbox.StartPosition.Value : null); + rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.GridCentre ? gridToolbox.StartPosition.Value : null); }); } @@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Edit public enum RotationOrigin { - PlayfieldCentre, + GridCentre, SelectionCentre } diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index 0f04efcfa5..15ed4c59c3 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly OsuGridToolboxGroup gridToolbox; - private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, ScaleOrigin.PlayfieldCentre, true, true)); + private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, ScaleOrigin.GridCentre, true, true)); private SliderWithTextBoxInput scaleInput = null!; private BindableNumber scaleInputBindable = null!; @@ -68,9 +68,9 @@ namespace osu.Game.Rulesets.Osu.Edit RelativeSizeAxes = Axes.X, Items = new[] { - playfieldCentreButton = new RadioButton("Playfield centre", - () => setOrigin(ScaleOrigin.PlayfieldCentre), - () => new SpriteIcon { Icon = FontAwesome.Regular.Square }), + playfieldCentreButton = new RadioButton("Grid centre", + () => setOrigin(ScaleOrigin.GridCentre), + () => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }), selectionCentreButton = new RadioButton("Selection centre", () => setOrigin(ScaleOrigin.SelectionCentre), () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) @@ -137,7 +137,7 @@ namespace osu.Game.Rulesets.Osu.Edit private void updateAxisCheckBoxesEnabled() { - if (scaleInfo.Value.Origin == ScaleOrigin.PlayfieldCentre) + if (scaleInfo.Value.Origin == ScaleOrigin.GridCentre) { toggleAxisAvailable(xCheckBox.Current, true); toggleAxisAvailable(yCheckBox.Current, true); @@ -181,11 +181,11 @@ namespace osu.Game.Rulesets.Osu.Edit updateAxisCheckBoxesEnabled(); } - private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? gridToolbox.StartPosition.Value : null; + private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.StartPosition.Value : null; private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y; - private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? gridToolbox.GridLinesRotation.Value : 0; + private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0; private void setAxis(bool x, bool y) { @@ -210,7 +210,7 @@ namespace osu.Game.Rulesets.Osu.Edit public enum ScaleOrigin { - PlayfieldCentre, + GridCentre, SelectionCentre } From a80e3337860773790c88010a7592219984a31570 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sun, 14 Jul 2024 17:27:04 +0200 Subject: [PATCH 17/26] add playfield origin as third origin option --- .../Edit/PreciseRotationPopover.cs | 17 ++++++++++++- .../Edit/PreciseScalePopover.cs | 24 ++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index 4a1ccc4b61..352debf500 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . 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; @@ -8,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Components.RadioButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -63,6 +65,9 @@ namespace osu.Game.Rulesets.Osu.Edit 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 }), selectionCentreButton = new RadioButton("Selection centre", () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre }, () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) @@ -95,10 +100,19 @@ namespace osu.Game.Rulesets.Osu.Edit rotationInfo.BindValueChanged(rotation => { - rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.GridCentre ? gridToolbox.StartPosition.Value : 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(); @@ -117,6 +131,7 @@ namespace osu.Game.Rulesets.Osu.Edit public enum RotationOrigin { GridCentre, + PlayfieldCentre, SelectionCentre } diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index 15ed4c59c3..dff370d259 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Osu.UI; using osu.Game.Screens.Edit.Components.RadioButtons; using osuTK; @@ -27,6 +28,7 @@ namespace osu.Game.Rulesets.Osu.Edit private BindableNumber scaleInputBindable = null!; private EditorRadioButtonCollection scaleOrigin = null!; + private RadioButton gridCentreButton = null!; private RadioButton playfieldCentreButton = null!; private RadioButton selectionCentreButton = null!; @@ -68,9 +70,12 @@ namespace osu.Game.Rulesets.Osu.Edit RelativeSizeAxes = Axes.X, Items = new[] { - playfieldCentreButton = new RadioButton("Grid centre", + 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 }), selectionCentreButton = new RadioButton("Selection centre", () => setOrigin(ScaleOrigin.SelectionCentre), () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) @@ -99,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; @@ -125,6 +134,7 @@ 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(); @@ -137,7 +147,7 @@ namespace osu.Game.Rulesets.Osu.Edit private void updateAxisCheckBoxesEnabled() { - if (scaleInfo.Value.Origin == ScaleOrigin.GridCentre) + if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre) { toggleAxisAvailable(xCheckBox.Current, true); toggleAxisAvailable(yCheckBox.Current, true); @@ -181,7 +191,14 @@ namespace osu.Game.Rulesets.Osu.Edit updateAxisCheckBoxesEnabled(); } - private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.StartPosition.Value : 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; @@ -211,6 +228,7 @@ namespace osu.Game.Rulesets.Osu.Edit public enum ScaleOrigin { GridCentre, + PlayfieldCentre, SelectionCentre } From 2bbaa8e43ccce0a1bf42f2c5790ad469a81a195e Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sun, 14 Jul 2024 18:12:55 +0200 Subject: [PATCH 18/26] make flips grid-type aware --- .../Edit/OsuSelectionHandler.cs | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 7d6ef66909..1334dbdbec 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -107,10 +107,28 @@ namespace osu.Game.Rulesets.Osu.Edit // 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); - // If we're flipping over the origin, we take the grid rotation from the grid toolbox. - // We want to normalize the rotation angle to -45 to 45 degrees, so horizontal vs vertical flip is not mixed up by the rotation and it stays intuitive to use. - var flipAxis = flipOverOrigin ? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 405) % 90 - 45)) : Vector2.UnitX; - flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis; + 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 + 405) % 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. + flipAxis = direction == Direction.Vertical + ? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 390) % 60 + 60)) + : GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 390) % 60 - 60)); + break; + } + } var controlPointFlipQuad = new Quad(); From c18814817b7d5b1f907574b6cca89f4fdc0efa53 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Tue, 16 Jul 2024 11:17:54 +0200 Subject: [PATCH 19/26] fix test --- osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs index 30e0dbbf2e..d14bd1fc87 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs @@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor () => EditorBeatmap.HitObjects.OfType().ElementAt(1).Position, () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200))); - AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(1).TriggerClick()); + AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(2).TriggerClick()); AddAssert("first object rotated 90deg around selection centre", () => EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200))); AddAssert("second object rotated 90deg around selection centre", From 7dc006f9bab4f22e326c5692a40a2afc5bfdc566 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Tue, 16 Jul 2024 13:19:01 +0200 Subject: [PATCH 20/26] fix horizontal flip rotation --- osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 1334dbdbec..2dc43deee1 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -125,7 +125,7 @@ namespace osu.Game.Rulesets.Osu.Edit // however it's still possible to achieve that flip by combining multiple flips over the other axes. flipAxis = direction == Direction.Vertical ? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 390) % 60 + 60)) - : GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 390) % 60 - 60)); + : GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360) % 60 - 30)); break; } } From b18706274784430b26526fc4b3eecb75c8ef058e Mon Sep 17 00:00:00 2001 From: OliBomby Date: Tue, 13 Aug 2024 12:58:52 +0200 Subject: [PATCH 21/26] clarify meaning of flip axis vector --- osu.Game/Utils/GeometryUtils.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index 5a8ca9722e..810eda9950 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -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. /// + /// The direction to flip towards. + /// The quad surrounding all selected objects. The center of this determines the position of the axis. + /// The position to flip. public static Vector2 GetFlippedPosition(Direction direction, Quad quad, Vector2 position) { var centre = quad.Centre; @@ -73,6 +76,9 @@ namespace osu.Game.Utils /// Given a flip axis vector, a surrounding quad for all selected objects, and a position, /// will return the flipped position in screen space coordinates. /// + /// The vector indicating the direction to flip towards. This is perpendicular to the mirroring axis. + /// The quad surrounding all selected objects. The center of this determines the position of the axis. + /// The position to flip. public static Vector2 GetFlippedPosition(Vector2 axis, Quad quad, Vector2 position) { var centre = quad.Centre; From ae47671d17e6322a688f24a090020f6188539c1e Mon Sep 17 00:00:00 2001 From: OliBomby Date: Tue, 13 Aug 2024 14:21:42 +0200 Subject: [PATCH 22/26] clarify angle ranges in HandleFlip --- osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index da98da5238..bac0a5e273 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -138,15 +138,17 @@ namespace osu.Game.Rulesets.Osu.Edit switch (gridToolbox.GridType.Value) { case PositionSnapGridType.Square: - flipAxis = GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 405) % 90 - 45)); + 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 + 390) % 60 + 60)) + ? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 30) % 60 + 60)) : GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360) % 60 - 30)); break; } From 09ca190b8ddb44d27b16763f6a6c19d71332e001 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 21 Aug 2024 00:10:15 +0200 Subject: [PATCH 23/26] re-implement ConvexHull 100% original --- osu.Game/Utils/GeometryUtils.cs | 39 +++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index 810eda9950..d4c1dc2db7 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -154,33 +154,38 @@ namespace osu.Game.Utils { List p = points.ToList(); - if (p.Count <= 1) + if (p.Count < 3) return p; - int n = p.Count, k = 0; - List hull = new List(new Vector2[2 * n]); - p.Sort((a, b) => a.X == b.X ? a.Y.CompareTo(b.Y) : a.X.CompareTo(b.X)); - // Build lower hull - for (int i = 0; i < n; ++i) + List upper = new List(); + List lower = new List(); + + // Build the lower hull + for (int i = 0; i < p.Count; i++) { - while (k >= 2 && cross(hull[k - 2], hull[k - 1], p[i]) <= 0) - k--; - hull[k] = p[i]; - k++; + while (lower.Count >= 2 && cross(lower[^2], lower[^1], p[i]) <= 0) + lower.RemoveAt(lower.Count - 1); + + lower.Add(p[i]); } - // Build upper hull - for (int i = n - 2, t = k + 1; i >= 0; i--) + // Build the upper hull + for (int i = p.Count - 1; i >= 0; i--) { - while (k >= t && cross(hull[k - 2], hull[k - 1], p[i]) <= 0) - k--; - hull[k] = p[i]; - k++; + while (upper.Count >= 2 && cross(upper[^2], upper[^1], p[i]) <= 0) + upper.RemoveAt(upper.Count - 1); + + upper.Add(p[i]); } - return hull.Take(k - 1).ToList(); + // Remove the last point of each half because it's a duplicate of the first point of the other half + lower.RemoveAt(lower.Count - 1); + upper.RemoveAt(upper.Count - 1); + + lower.AddRange(upper); + return lower; float cross(Vector2 o, Vector2 a, Vector2 b) => (a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X); } From ff3bffc7d9e0e955afe3ad3ad498c2d3259e6877 Mon Sep 17 00:00:00 2001 From: OliBomby Date: Wed, 21 Aug 2024 00:30:57 +0200 Subject: [PATCH 24/26] add test --- osu.Game.Tests/Utils/GeometryUtilsTest.cs | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 osu.Game.Tests/Utils/GeometryUtilsTest.cs diff --git a/osu.Game.Tests/Utils/GeometryUtilsTest.cs b/osu.Game.Tests/Utils/GeometryUtilsTest.cs new file mode 100644 index 0000000000..ded4656ac1 --- /dev/null +++ b/osu.Game.Tests/Utils/GeometryUtilsTest.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . 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)); + } + } +} From 385eb5eed5a4842e0eeb4e4b4dc450d77ec7c407 Mon Sep 17 00:00:00 2001 From: kongehund <63306696+kongehund@users.noreply.github.com> Date: Sat, 14 Sep 2024 16:32:51 +0200 Subject: [PATCH 25/26] Rewrite GetConvexHull --- osu.Game/Utils/GeometryUtils.cs | 76 +++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index d4c1dc2db7..4c90421aca 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -152,42 +152,72 @@ namespace osu.Game.Utils /// The points to calculate a convex hull. public static List GetConvexHull(IEnumerable points) { - List p = points.ToList(); + // Naming convention implies positive y upwards. - if (p.Count < 3) - return p; + bool isCCW(Vector2 a, Vector2 b, Vector2 c) => crossProduct(b - a, c - a) > 0; - p.Sort((a, b) => a.X == b.X ? a.Y.CompareTo(b.Y) : a.X.CompareTo(b.X)); + float crossProduct(Vector2 v1, Vector2 v2) => v1.X * v2.Y - v1.Y * v2.X; - List upper = new List(); - List lower = new List(); + var pointsList = points.ToList(); - // Build the lower hull - for (int i = 0; i < p.Count; i++) + pointsList.Sort(delegate (Vector2 point1, Vector2 point2) { - while (lower.Count >= 2 && cross(lower[^2], lower[^1], p[i]) <= 0) - lower.RemoveAt(lower.Count - 1); + if (point1.X == point2.X) + return point1.Y.CompareTo(point2.Y); + return point1.X.CompareTo(point2.X); + }); - lower.Add(p[i]); + if (pointsList.Count < 3) + return pointsList; + + var convexHullUpper = new List + { + pointsList[0], + pointsList[1] + }; + var convexHullLower = new List + { + pointsList[pointsList.Count - 1], + pointsList[pointsList.Count - 2] + }; + + for (int i_points = 2; i_points < pointsList.Count; i_points++) + { + Vector2 c = pointsList[i_points]; + for (int i_hull = convexHullUpper.Count - 1; i_hull > 0; i_hull--) + { + Vector2 a = convexHullUpper[^2]; + Vector2 b = convexHullUpper[^1]; + if (isCCW(a, b, c)) + convexHullUpper.Remove(b); + else + break; + } + convexHullUpper.Add(c); } - // Build the upper hull - for (int i = p.Count - 1; i >= 0; i--) + for (int i_points = pointsList.Count - 3; i_points >= 0; i_points--) { - while (upper.Count >= 2 && cross(upper[^2], upper[^1], p[i]) <= 0) - upper.RemoveAt(upper.Count - 1); - - upper.Add(p[i]); + Vector2 c = pointsList[i_points]; + for (int i_hull = convexHullLower.Count - 1; i_hull > 0; i_hull--) + { + Vector2 a = convexHullLower[^2]; + Vector2 b = convexHullLower[^1]; + if (isCCW(a, b, c)) + convexHullLower.Remove(b); + else + break; + } + convexHullLower.Add(c); } - // Remove the last point of each half because it's a duplicate of the first point of the other half - lower.RemoveAt(lower.Count - 1); - upper.RemoveAt(upper.Count - 1); + convexHullUpper.RemoveAt(convexHullUpper.Count - 1); + convexHullLower.RemoveAt(convexHullLower.Count - 1); - lower.AddRange(upper); - return lower; + convexHullUpper.AddRange(convexHullLower); + var convexHull = convexHullUpper; - float cross(Vector2 o, Vector2 a, Vector2 b) => (a.X - o.X) * (b.Y - o.Y) - (a.Y - o.Y) * (b.X - o.X); + return convexHull; } public static List GetConvexHull(IEnumerable hitObjects) => From 30096c1c7198168c667f85873a60461410df296a Mon Sep 17 00:00:00 2001 From: OliBomby Date: Sat, 14 Sep 2024 17:26:04 +0200 Subject: [PATCH 26/26] clean up code --- osu.Game/Utils/GeometryUtils.cs | 74 ++++++++++++--------------------- 1 file changed, 27 insertions(+), 47 deletions(-) diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs index 4c90421aca..8572ac6609 100644 --- a/osu.Game/Utils/GeometryUtils.cs +++ b/osu.Game/Utils/GeometryUtils.cs @@ -152,72 +152,52 @@ namespace osu.Game.Utils /// The points to calculate a convex hull. public static List GetConvexHull(IEnumerable points) { - // Naming convention implies positive y upwards. - - bool isCCW(Vector2 a, Vector2 b, Vector2 c) => crossProduct(b - a, c - a) > 0; - - float crossProduct(Vector2 v1, Vector2 v2) => v1.X * v2.Y - v1.Y * v2.X; - - var pointsList = points.ToList(); - - pointsList.Sort(delegate (Vector2 point1, Vector2 point2) - { - if (point1.X == point2.X) - return point1.Y.CompareTo(point2.Y); - return point1.X.CompareTo(point2.X); - }); + var pointsList = points.OrderBy(p => p.X).ThenBy(p => p.Y).ToList(); if (pointsList.Count < 3) return pointsList; - var convexHullUpper = new List + var convexHullLower = new List { pointsList[0], pointsList[1] }; - var convexHullLower = new List + var convexHullUpper = new List { - pointsList[pointsList.Count - 1], - pointsList[pointsList.Count - 2] + pointsList[^1], + pointsList[^2] }; - for (int i_points = 2; i_points < pointsList.Count; i_points++) + // Build the lower hull. + for (int i = 2; i < pointsList.Count; i++) { - Vector2 c = pointsList[i_points]; - for (int i_hull = convexHullUpper.Count - 1; i_hull > 0; i_hull--) - { - Vector2 a = convexHullUpper[^2]; - Vector2 b = convexHullUpper[^1]; - if (isCCW(a, b, c)) - convexHullUpper.Remove(b); - else - break; - } - convexHullUpper.Add(c); - } + Vector2 c = pointsList[i]; + while (convexHullLower.Count > 1 && isClockwise(convexHullLower[^2], convexHullLower[^1], c)) + convexHullLower.RemoveAt(convexHullLower.Count - 1); - for (int i_points = pointsList.Count - 3; i_points >= 0; i_points--) - { - Vector2 c = pointsList[i_points]; - for (int i_hull = convexHullLower.Count - 1; i_hull > 0; i_hull--) - { - Vector2 a = convexHullLower[^2]; - Vector2 b = convexHullLower[^1]; - if (isCCW(a, b, c)) - convexHullLower.Remove(b); - else - break; - } convexHullLower.Add(c); } - convexHullUpper.RemoveAt(convexHullUpper.Count - 1); + // 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); - convexHullUpper.AddRange(convexHullLower); - var convexHull = convexHullUpper; + convexHullLower.AddRange(convexHullUpper); - return convexHull; + 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 GetConvexHull(IEnumerable hitObjects) =>