// 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.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; 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; using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Utils; using osuTK; namespace osu.Game.Rulesets.Osu.Edit { public partial class OsuSelectionScaleHandler : SelectionScaleHandler { /// /// Whether scaling anchored by the center of the playfield can currently be performed. /// public Bindable CanScaleFromPlayfieldOrigin { get; private set; } = new BindableBool(); /// /// Whether a single slider is currently selected, which results in a different scaling behaviour. /// public Bindable IsScalingSlider { get; private set; } = new BindableBool(); [Resolved] private IEditorChangeHandler? changeHandler { get; set; } [Resolved(CanBeNull = true)] private IDistanceSnapProvider? snapProvider { get; set; } private BindableList selectedItems { get; } = new BindableList(); [BackgroundDependencyLoader] private void load(EditorBeatmap editorBeatmap) { selectedItems.BindTo(editorBeatmap.SelectedHitObjects); } protected override void LoadComplete() { base.LoadComplete(); selectedItems.CollectionChanged += (_, __) => updateState(); updateState(); } private void updateState() { var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects); CanScaleX.Value = quad.Width > 0; CanScaleY.Value = quad.Height > 0; CanScaleDiagonally.Value = CanScaleX.Value && CanScaleY.Value; CanScaleFromPlayfieldOrigin.Value = selectedMovableObjects.Any(); IsScalingSlider.Value = selectedMovableObjects.Count() == 1 && selectedMovableObjects.First() is Slider; } private Dictionary? objectsInScale; private Vector2? defaultOrigin; private List? originalConvexHull; public override void Begin() { if (OperationInProgress.Value) throw new InvalidOperationException($"Cannot {nameof(Begin)} a scale operation while another is in progress!"); base.Begin(); 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); originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2 ? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position)) : GeometryUtils.GetConvexHull(objectsInScale.Keys); defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1; } public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0) { if (!OperationInProgress.Value) throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!"); 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 // is not looking to change the duration of the slider but expand the whole pattern. if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider) { scaleSlider(slider, scale, actualOrigin, objectsInScale[slider], axisRotation); } else { scale = ClampScaleToPlayfieldBounds(scale, actualOrigin, adjustAxis, axisRotation); foreach (var (ho, originalState) in objectsInScale) { ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position, axisRotation); } } moveSelectionInBounds(); } public override void Commit() { if (!OperationInProgress.Value) throw new InvalidOperationException($"Cannot {nameof(Commit)} a rotate operation without calling {nameof(Begin)} first!"); changeHandler?.EndChange(); base.Commit(); objectsInScale = null; OriginalSurroundingQuad = null; defaultOrigin = null; } private IEnumerable selectedMovableObjects => selectedItems.Cast() .Where(h => h is not Spinner); 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 origin, OriginalHitObjectState originalInfo, float axisRotation = 0) { Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null); 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 = GeometryUtils.GetScaledPosition(scale, Vector2.Zero, originalInfo.PathControlPointPositions[i], axisRotation); slider.Path.ControlPoints[i].Type = originalInfo.PathControlPointTypes[i]; } // Snap the slider's length to the current beat divisor // to calculate the final resulting duration / bounding box before the final checks. slider.SnapTo(snapProvider); slider.Position = GeometryUtils.GetScaledPosition(scale, origin, originalInfo.Position, axisRotation); //if sliderhead or sliderend end up outside playfield, revert scaling. Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider }); (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad); if (xInBounds && yInBounds && slider.Path.HasValidLength) return; for (int i = 0; i < slider.Path.ControlPoints.Count; i++) slider.Path.ControlPoints[i].Position = originalInfo.PathControlPointPositions[i]; slider.Position = originalInfo.Position; // Snap the slider's length again to undo the potentially-invalid length applied by the previous snap. slider.SnapTo(snapProvider); } private (bool X, bool Y) isQuadInBounds(Quad quad) { bool xInBounds = (quad.TopLeft.X >= 0) && (quad.BottomRight.X <= OsuPlayfield.BASE_SIZE.X); bool yInBounds = (quad.TopLeft.Y >= 0) && (quad.BottomRight.Y <= OsuPlayfield.BASE_SIZE.Y); return (xInBounds, yInBounds); } /// /// 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. /// The rotation of the axes in degrees /// The clamped scale vector 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 || adjustAxis == Axes.None) return scale; Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null); 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; IEnumerable points; 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 = clampToBounds(scale, point, Vector2.Zero, OsuPlayfield.BASE_SIZE); return scale; // Clamps the scale vector s such that the point p scaled by s is within the rectangle defined by lowerBounds and upperBounds Vector2 clampToBounds(Vector2 s, Vector2 p, Vector2 lowerBounds, Vector2 upperBounds) { p -= actualOrigin; lowerBounds -= actualOrigin; upperBounds -= actualOrigin; // a.X is the rotated X component of p with respect to the X bounds // a.Y is the rotated X component of p with respect to the Y bounds // b.X is the rotated Y component of p with respect to the X bounds // b.Y is the rotated Y component of p with respect to the Y bounds 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); float sLowerBound, sUpperBound; switch (adjustAxis) { case Axes.X: (sLowerBound, sUpperBound) = computeBounds(lowerBounds - b, upperBounds - b, a); s.X = MathHelper.Clamp(s.X, sLowerBound, sUpperBound); break; case Axes.Y: (sLowerBound, sUpperBound) = computeBounds(lowerBounds - a, upperBounds - a, b); s.Y = MathHelper.Clamp(s.Y, sLowerBound, sUpperBound); break; case Axes.Both: // Here we compute the bounds for the magnitude multiplier of the scale vector // Therefore the ratio s.X / s.Y will be maintained (sLowerBound, sUpperBound) = computeBounds(lowerBounds, upperBounds, a * s.X + b * s.Y); s.X = s.X < 0 ? MathHelper.Clamp(s.X, s.X * sUpperBound, s.X * sLowerBound) : MathHelper.Clamp(s.X, s.X * sLowerBound, s.X * sUpperBound); s.Y = s.Y < 0 ? MathHelper.Clamp(s.Y, s.Y * sUpperBound, s.Y * sLowerBound) : MathHelper.Clamp(s.Y, s.Y * sLowerBound, s.Y * sUpperBound); break; } return s; } // Computes the bounds for the magnitude of the scaled point p with respect to the bounds lowerBounds and upperBounds (float, float) computeBounds(Vector2 lowerBounds, Vector2 upperBounds, Vector2 p) { var sLowerBounds = Vector2.Divide(lowerBounds, p); var sUpperBounds = Vector2.Divide(upperBounds, p); // If the point is negative, then the bounds are flipped if (p.X < 0) (sLowerBounds.X, sUpperBounds.X) = (sUpperBounds.X, sLowerBounds.X); if (p.Y < 0) (sLowerBounds.Y, sUpperBounds.Y) = (sUpperBounds.Y, sLowerBounds.Y); // If the point is at zero, then any scale will have no effect on the point so the bounds are infinite // The float division would already give us infinity for the bounds, but the sign is not consistent so we have to manually set it if (Precision.AlmostEquals(p.X, 0)) (sLowerBounds.X, sUpperBounds.X) = (float.NegativeInfinity, float.PositiveInfinity); if (Precision.AlmostEquals(p.Y, 0)) (sLowerBounds.Y, sUpperBounds.Y) = (float.NegativeInfinity, float.PositiveInfinity); return (MathF.Max(sLowerBounds.X, sLowerBounds.Y), MathF.Min(sUpperBounds.X, sUpperBounds.Y)); } } private void moveSelectionInBounds() { Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys); Vector2 delta = Vector2.Zero; if (quad.TopLeft.X < 0) delta.X -= quad.TopLeft.X; if (quad.TopLeft.Y < 0) delta.Y -= quad.TopLeft.Y; if (quad.BottomRight.X > OsuPlayfield.BASE_SIZE.X) delta.X -= quad.BottomRight.X - OsuPlayfield.BASE_SIZE.X; if (quad.BottomRight.Y > OsuPlayfield.BASE_SIZE.Y) delta.Y -= quad.BottomRight.Y - OsuPlayfield.BASE_SIZE.Y; foreach (var (h, _) in objectsInScale!) h.Position += delta; } private struct OriginalHitObjectState { public Vector2 Position { get; } public Vector2[]? PathControlPointPositions { get; } public PathType?[]? PathControlPointTypes { 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(); } } } }