Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add precise scaling control to osu! editor #28309

Merged
merged 21 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ private void load()

RightToolbox.AddRange(new EditorToolboxGroup[]
{
new TransformToolboxGroup { RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler, },
new TransformToolboxGroup
{
RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler,
ScaleHandler = (OsuSelectionScaleHandler)BlueprintContainer.SelectionHandler.ScaleHandler,
},
FreehandlSliderToolboxGroup
}
);
Expand Down
4 changes: 2 additions & 2 deletions osu.Game.Rulesets.Osu/Edit/OsuSelectionRotationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ protected override void LoadComplete()
private void updateState()
{
var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects);
CanRotateSelectionOrigin.Value = quad.Width > 0 || quad.Height > 0;
CanRotatePlayfieldOrigin.Value = selectedMovableObjects.Any();
CanRotateAroundSelectionOrigin.Value = quad.Width > 0 || quad.Height > 0;
CanRotateAroundPlayfieldOrigin.Value = selectedMovableObjects.Any();
}

private OsuHitObject[]? objectsInRotation;
Expand Down
55 changes: 38 additions & 17 deletions osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSelectionScaleHandler : SelectionScaleHandler
{
/// <summary>
/// Whether scaling anchored by the center of the playfield can currently be performed.
/// </summary>
public Bindable<bool> CanScaleFromPlayfieldOrigin { get; private set; } = new BindableBool();

/// <summary>
/// Whether a single slider is currently selected, which results in a different scaling behaviour.
/// </summary>
public Bindable<bool> IsScalingSlider { get; private set; } = new BindableBool();

[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }

Expand Down Expand Up @@ -53,6 +63,8 @@ private void updateState()
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<OsuHitObject, OriginalHitObjectState>? objectsInScale;
Expand All @@ -67,7 +79,7 @@ public override void Begin()

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 => p.Position))
? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position))
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
defaultOrigin = OriginalSurroundingQuad.Value.Centre;
}
Expand All @@ -92,7 +104,7 @@ public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAx
}
else
{
scale = getClampedScale(OriginalSurroundingQuad.Value, actualOrigin, scale);
scale = ClampScaleToPlayfieldBounds(scale, actualOrigin);

foreach (var (ho, originalState) in objectsInScale)
{
Expand Down Expand Up @@ -158,27 +170,36 @@ private void scaleSlider(Slider slider, Vector2 scale, Vector2[] originalPathPos
/// <summary>
/// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip.
/// </summary>
/// <param name="selectionQuad">The quad surrounding the hitobjects</param>
/// <param name="origin">The origin from which the scale operation is performed</param>
/// <param name="scale">The scale to be clamped</param>
/// <returns>The clamped scale vector</returns>
private Vector2 getClampedScale(Quad selectionQuad, Vector2 origin, Vector2 scale)
public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null)
{
//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)
return scale;

Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null);

var tl1 = Vector2.Divide(-origin, selectionQuad.TopLeft - origin);
var tl2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - origin, selectionQuad.TopLeft - origin);
var br1 = Vector2.Divide(-origin, selectionQuad.BottomRight - origin);
var br2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - origin, selectionQuad.BottomRight - origin);

if (!Precision.AlmostEquals(selectionQuad.TopLeft.X - origin.X, 0))
scale.X = selectionQuad.TopLeft.X - origin.X < 0 ? MathHelper.Clamp(scale.X, tl2.X, tl1.X) : MathHelper.Clamp(scale.X, tl1.X, tl2.X);
if (!Precision.AlmostEquals(selectionQuad.TopLeft.Y - origin.Y, 0))
scale.Y = selectionQuad.TopLeft.Y - origin.Y < 0 ? MathHelper.Clamp(scale.Y, tl2.Y, tl1.Y) : MathHelper.Clamp(scale.Y, tl1.Y, tl2.Y);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.X - origin.X, 0))
scale.X = selectionQuad.BottomRight.X - origin.X < 0 ? MathHelper.Clamp(scale.X, br2.X, br1.X) : MathHelper.Clamp(scale.X, br1.X, br2.X);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.Y - origin.Y, 0))
scale.Y = selectionQuad.BottomRight.Y - origin.Y < 0 ? MathHelper.Clamp(scale.Y, br2.Y, br1.Y) : MathHelper.Clamp(scale.Y, br1.Y, br2.Y);
if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider)
origin = slider.Position;

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);

return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
}
Expand Down
8 changes: 6 additions & 2 deletions osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ protected override void LoadComplete()
{
base.LoadComplete();

ScheduleAfterChildren(() => angleInput.TakeFocus());
ScheduleAfterChildren(() =>
{
angleInput.TakeFocus();
angleInput.SelectAll();
});
angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue });
rotationOrigin.Items.First().Select();

rotationHandler.CanRotateSelectionOrigin.BindValueChanged(e =>
rotationHandler.CanRotateAroundSelectionOrigin.BindValueChanged(e =>
{
selectionCentreButton.Selected.Disabled = !e.NewValue;
}, true);
Expand Down
212 changes: 212 additions & 0 deletions osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
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;

namespace osu.Game.Rulesets.Osu.Edit
{
public partial class PreciseScalePopover : OsuPopover
{
private readonly OsuSelectionScaleHandler scaleHandler;

private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.PlayfieldCentre, true, true));

private SliderWithTextBoxInput<float> scaleInput = null!;
private BindableNumber<float> scaleInputBindable = null!;
private EditorRadioButtonCollection scaleOrigin = null!;

private RadioButton playfieldCentreButton = null!;
private RadioButton selectionCentreButton = null!;

private OsuCheckbox xCheckBox = null!;
private OsuCheckbox yCheckBox = null!;

public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler)
{
this.scaleHandler = scaleHandler;

AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
}

[BackgroundDependencyLoader]
private void load()
{
Child = new FillFlowContainer
{
Width = 220,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(20),
Children = new Drawable[]
{
scaleInput = new SliderWithTextBoxInput<float>("Scale:")
{
Current = scaleInputBindable = new BindableNumber<float>
{
MinValue = 0.5f,
MaxValue = 2,
Precision = 0.001f,
Value = 1,
Default = 1,
},
Instantaneous = true
},
scaleOrigin = new EditorRadioButtonCollection
{
RelativeSizeAxes = Axes.X,
Items = new[]
{
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 })
}
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(4),
Children = new Drawable[]
{
xCheckBox = new OsuCheckbox(false)
{
RelativeSizeAxes = Axes.X,
LabelText = "X-axis",
Current = { Value = true },
},
yCheckBox = new OsuCheckbox(false)
{
RelativeSizeAxes = Axes.X,
LabelText = "Y-axis",
Current = { Value = true },
},
bdach marked this conversation as resolved.
Show resolved Hide resolved
}
},
}
};
playfieldCentreButton.Selected.DisabledChanged += isDisabled =>
{
playfieldCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to playfield centre." : string.Empty;
};
selectionCentreButton.Selected.DisabledChanged += isDisabled =>
{
selectionCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to its centre." : string.Empty;
};
}

protected override void LoadComplete()
{
base.LoadComplete();

ScheduleAfterChildren(() =>
{
scaleInput.TakeFocus();
scaleInput.SelectAll();
});
scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue });

xCheckBox.Current.BindValueChanged(x => setAxis(x.NewValue, yCheckBox.Current.Value));
yCheckBox.Current.BindValueChanged(y => setAxis(xCheckBox.Current.Value, y.NewValue));

selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value);
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;

scaleOrigin.Items.First(b => !b.Selected.Disabled).Select();

scaleInfo.BindValueChanged(scale =>
{
var newScale = new Vector2(scale.NewValue.XAxis ? scale.NewValue.Scale : 1, scale.NewValue.YAxis ? scale.NewValue.Scale : 1);
scaleHandler.Update(newScale, getOriginPosition(scale.NewValue));
});
}

private void updateAxisCheckBoxesEnabled()
{
if (scaleInfo.Value.Origin == ScaleOrigin.PlayfieldCentre)
{
toggleAxisAvailable(xCheckBox.Current, true);
toggleAxisAvailable(yCheckBox.Current, true);
}
else
{
toggleAxisAvailable(xCheckBox.Current, scaleHandler.CanScaleX.Value);
toggleAxisAvailable(yCheckBox.Current, scaleHandler.CanScaleY.Value);
}
}

private void toggleAxisAvailable(Bindable<bool> axisBindable, bool available)
{
// enable the bindable to allow setting the value
axisBindable.Disabled = false;
// restore the presumed default value given the axis's new availability state
axisBindable.Value = available;
axisBindable.Disabled = !available;
}

private void updateMaxScale()
{
if (!scaleHandler.OriginalSurroundingQuad.HasValue)
return;

const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value));

if (!scaleInfo.Value.XAxis)
scale.X = max_scale;
if (!scaleInfo.Value.YAxis)
scale.Y = max_scale;

scaleInputBindable.MaxValue = MathF.Max(1, MathF.Min(scale.X, scale.Y));
}

private void setOrigin(ScaleOrigin origin)
{
scaleInfo.Value = scaleInfo.Value with { Origin = origin };
updateMaxScale();
updateAxisCheckBoxesEnabled();
}

private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null;

private void setAxis(bool x, bool y)
{
scaleInfo.Value = scaleInfo.Value with { XAxis = x, YAxis = y };
updateMaxScale();
}

protected override void PopIn()
{
base.PopIn();
scaleHandler.Begin();
updateMaxScale();
}

protected override void PopOut()
{
base.PopOut();

if (IsLoaded) scaleHandler.Commit();
}
}

public enum ScaleOrigin
{
PlayfieldCentre,
SelectionCentre
}

public record PreciseScaleInfo(float Scale, ScaleOrigin Origin, bool XAxis, bool YAxis);
}
Loading
Loading