diff --git a/osu.Framework.Tests/MathUtils/TestPathApproximator.cs b/osu.Framework.Tests/MathUtils/TestPathApproximator.cs index 8bf2db49c9..f545f42a29 100644 --- a/osu.Framework.Tests/MathUtils/TestPathApproximator.cs +++ b/osu.Framework.Tests/MathUtils/TestPathApproximator.cs @@ -17,7 +17,7 @@ public void TestLagrange() // lagrange of (0,0) (0.5,0.35) (1,1) is equal to 0.6x*x + 0.4x Vector2[] points = { new Vector2(0, 0), new Vector2(0.5f, 0.35f), new Vector2(1, 1) }; - List approximated = PathApproximator.ApproximateLagrangePolynomial(points); + List approximated = PathApproximator.LagrangePolynomialToPiecewiseLinear(points); Assert.Greater(approximated.Count, 10, "Approximated polynomial should have at least 10 points to test"); for (int i = 0; i < approximated.Count; i++) @@ -34,7 +34,7 @@ public void TestBSpline() { Vector2[] points = { new Vector2(0, 0), new Vector2(1, 0), new Vector2(1, -1), new Vector2(-1, -1), new Vector2(-1, 1), new Vector2(3, 2), new Vector2(3, 0) }; - List approximated = PathApproximator.ApproximateBSpline(points, 4); + List approximated = PathApproximator.BSplineToPiecewiseLinear(points, 4); Assert.AreEqual(approximated.Count, 29, "Approximated path should have 29 points to test"); Assert.True(Precision.AlmostEquals(approximated[0], points[0], 1e-4f)); Assert.True(Precision.AlmostEquals(approximated[28], points[6], 1e-4f)); diff --git a/osu.Framework.Tests/Visual/Drawables/TestSceneCircularArcBoundingBox.cs b/osu.Framework.Tests/Visual/Drawables/TestSceneCircularArcBoundingBox.cs index 9617092306..bee820a296 100644 --- a/osu.Framework.Tests/Visual/Drawables/TestSceneCircularArcBoundingBox.cs +++ b/osu.Framework.Tests/Visual/Drawables/TestSceneCircularArcBoundingBox.cs @@ -74,7 +74,7 @@ protected override void LoadComplete() if (copy.Length != 3) return; - path.Vertices = PathApproximator.ApproximateCircularArc(copy); + path.Vertices = PathApproximator.CircularArcToPiecewiseLinear(copy); var bounds = PathApproximator.CircularArcBoundingBox(copy); boundingBox.Size = bounds.Size; diff --git a/osu.Framework.Tests/Visual/Drawables/TestSceneCustomEasingCurve.cs b/osu.Framework.Tests/Visual/Drawables/TestSceneCustomEasingCurve.cs index 56c1438ea3..d7673038de 100644 --- a/osu.Framework.Tests/Visual/Drawables/TestSceneCustomEasingCurve.cs +++ b/osu.Framework.Tests/Visual/Drawables/TestSceneCustomEasingCurve.cs @@ -165,7 +165,7 @@ protected override void Update() vectorPath.AddRange(ordered.Select(p => p.PointPosition.Value)); vectorPath.Add(new Vector2(DrawWidth, 0)); - var bezierPath = PathApproximator.ApproximateBezier(vectorPath.ToArray()); + var bezierPath = PathApproximator.BezierToPiecewiseLinear(vectorPath.ToArray()); path.Vertices = bezierPath; path.Position = -path.PositionInBoundingBox(Vector2.Zero); diff --git a/osu.Framework.Tests/Visual/Drawables/TestSceneInteractivePathDrawing.cs b/osu.Framework.Tests/Visual/Drawables/TestSceneInteractivePathDrawing.cs new file mode 100644 index 0000000000..4ebe43eca6 --- /dev/null +++ b/osu.Framework.Tests/Visual/Drawables/TestSceneInteractivePathDrawing.cs @@ -0,0 +1,109 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osuTK.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Lines; +using osu.Framework.Input.Events; +using osuTK.Input; +using osu.Framework.Utils; +using osuTK; + +namespace osu.Framework.Tests.Visual.Drawables +{ + [System.ComponentModel.Description("Approximate a hand-drawn path with minimal B-spline control points")] + public partial class TestSceneInteractivePathDrawing : FrameworkTestScene + { + private readonly Path rawDrawnPath; + private readonly Path approximatedDrawnPath; + private readonly Container controlPointViz; + + private readonly IncrementalBSplineBuilder bSplineBuilder = new IncrementalBSplineBuilder(); + + public TestSceneInteractivePathDrawing() + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + rawDrawnPath = new Path + { + Colour = Color4.DeepPink, + PathRadius = 5, + }, + approximatedDrawnPath = new Path + { + Colour = Color4.Blue, + PathRadius = 3, + }, + controlPointViz = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.5f, + }, + } + }; + + updateViz(); + OnUpdate += _ => updateViz(); + + AddStep("Reset path", () => + { + bSplineBuilder.Clear(); + }); + + AddSliderStep($"{nameof(bSplineBuilder.Degree)}", 1, 5, 3, v => + { + bSplineBuilder.Degree = v; + }); + AddSliderStep($"{nameof(bSplineBuilder.Tolerance)}", 0f, 1f, 0.1f, v => + { + bSplineBuilder.Tolerance = v; + }); + } + + private void updateControlPointsViz() + { + controlPointViz.Clear(); + + foreach (var cp in bSplineBuilder.GetControlPoints()) + { + controlPointViz.Add(new Box + { + Origin = Anchor.Centre, + Size = new Vector2(10), + Position = cp, + Colour = Color4.LightGreen, + }); + } + } + + protected override bool OnDragStart(DragStartEvent e) + { + if (e.Button == MouseButton.Left) + { + bSplineBuilder.Clear(); + bSplineBuilder.AddLinearPoint(rawDrawnPath.ToLocalSpace(ToScreenSpace(e.MousePosition))); + return true; + } + + return false; + } + + private void updateViz() + { + rawDrawnPath.Vertices = bSplineBuilder.GetInputPath(); + approximatedDrawnPath.Vertices = bSplineBuilder.OutputPath; + + updateControlPointsViz(); + } + + protected override void OnDrag(DragEvent e) + { + bSplineBuilder.AddLinearPoint(rawDrawnPath.ToLocalSpace(ToScreenSpace(e.MousePosition))); + } + } +} diff --git a/osu.Framework.Tests/Visual/Drawables/TestScenePathApproximator.cs b/osu.Framework.Tests/Visual/Drawables/TestScenePathApproximator.cs index f473b6903b..5deed3cb67 100644 --- a/osu.Framework.Tests/Visual/Drawables/TestScenePathApproximator.cs +++ b/osu.Framework.Tests/Visual/Drawables/TestScenePathApproximator.cs @@ -20,26 +20,26 @@ public TestScenePathApproximator() { Cell(0).AddRange(new[] { - createLabel("ApproximateBezier"), - new ApproximatedPathTest(PathApproximator.ApproximateBezier), + createLabel(nameof(PathApproximator.BezierToPiecewiseLinear)), + new ApproximatedPathTest(PathApproximator.BezierToPiecewiseLinear), }); Cell(1).AddRange(new[] { - createLabel("ApproximateCatmull"), - new ApproximatedPathTest(PathApproximator.ApproximateCatmull), + createLabel(nameof(PathApproximator.CatmullToPiecewiseLinear)), + new ApproximatedPathTest(PathApproximator.CatmullToPiecewiseLinear), }); Cell(2).AddRange(new[] { - createLabel("ApproximateCircularArc"), - new ApproximatedPathTest(PathApproximator.ApproximateCircularArc), + createLabel(nameof(PathApproximator.CircularArcToPiecewiseLinear)), + new ApproximatedPathTest(PathApproximator.CircularArcToPiecewiseLinear), }); Cell(3).AddRange(new[] { - createLabel("ApproximateLagrangePolynomial"), - new ApproximatedPathTest(PathApproximator.ApproximateLagrangePolynomial), + createLabel(nameof(PathApproximator.LagrangePolynomialToPiecewiseLinear)), + new ApproximatedPathTest(PathApproximator.LagrangePolynomialToPiecewiseLinear), }); } @@ -50,10 +50,10 @@ public TestScenePathApproximator() Colour = Color4.White, }; + public delegate List ApproximatorFunc(ReadOnlySpan controlPoints); + private partial class ApproximatedPathTest : SmoothPath { - public delegate List ApproximatorFunc(ReadOnlySpan controlPoints); - public ApproximatedPathTest(ApproximatorFunc approximator) { Vector2[] points = new Vector2[5]; diff --git a/osu.Framework/Graphics/Lines/Path.cs b/osu.Framework/Graphics/Lines/Path.cs index 509d88eb63..e0c502e760 100644 --- a/osu.Framework/Graphics/Lines/Path.cs +++ b/osu.Framework/Graphics/Lines/Path.cs @@ -237,6 +237,16 @@ public void AddVertex(Vector2 pos) Invalidate(Invalidation.DrawSize); } + public void ReplaceVertex(int index, Vector2 pos) + { + vertices[index] = pos; + + vertexBoundsCache.Invalidate(); + segmentsCache.Invalidate(); + + Invalidate(Invalidation.DrawSize); + } + private readonly List segmentsBacking = new List(); private readonly Cached segmentsCache = new Cached(); private List segments => segmentsCache.IsValid ? segmentsBacking : generateSegments(); diff --git a/osu.Framework/Utils/IncrementalBSplineBuilder.cs b/osu.Framework/Utils/IncrementalBSplineBuilder.cs new file mode 100644 index 0000000000..1f4b9eef39 --- /dev/null +++ b/osu.Framework/Utils/IncrementalBSplineBuilder.cs @@ -0,0 +1,193 @@ +// 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.Caching; +using osuTK; + +namespace osu.Framework.Utils +{ + /// + /// A class for incrementally building B-Spline paths from a series of linear points. + /// This can be used to obtain a B-Spline with a minimal number of control points for any + /// given set of linear input points, for example, a hand-drawn path. + /// + public class IncrementalBSplineBuilder + { + private readonly List inputPath = new List(); + + private readonly Cached> outputCache = new Cached> + { + Value = new List() + }; + + private readonly List controlPoints = new List(); + + private int degree; + + /// + /// Gets or sets the degree of the B-Spline. Must not be negative. Default is 3. + /// + /// Thrown when the value is negative. + public int Degree + { + get => degree; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), "Degree must not be negative."); + + degree = value; + outputCache.Invalidate(); + } + } + + private float tolerance; + + /// + /// Gets or sets the tolerance for determining when to add a new control point. Must not be negative. Default is 0.1. + /// + /// Thrown when the value is negative. + public float Tolerance + { + get => tolerance; + set + { + if (tolerance < 0) + throw new ArgumentOutOfRangeException(nameof(value), "Tolerance must not be negative."); + + tolerance = value; + outputCache.Invalidate(); + } + } + + /// + /// The piecewise linear approximation of the B-spline created from the input path. + /// + public IReadOnlyList OutputPath + { + get + { + if (!outputCache.IsValid) + redrawApproximatedPath(); + + return outputCache.Value; + } + } + + /// + /// Initializes a new instance of the class with specified degree and tolerance. + /// + /// The degree of the B-Spline. + /// The tolerance for control point addition. + public IncrementalBSplineBuilder(int degree = 3, float tolerance = 0.1f) + { + Degree = degree; + Tolerance = tolerance; + } + + /// + /// The list of control points of the B-Spline. This is inferred from the input path. + /// + public IReadOnlyList GetControlPoints() + => controlPoints.ToArray(); + + /// + /// The list of input points. + /// + public IReadOnlyList GetInputPath() + => inputPath.ToArray(); + + private void redrawApproximatedPath() + { + // Set value of output cache to update the cache to be valid. + outputCache.Value = new List(); + if (inputPath.Count == 0) + return; + + var oldInputs = inputPath.ToList(); + + inputPath.Clear(); + controlPoints.Clear(); + + foreach (var v in oldInputs) + AddLinearPoint(v); + } + + /// + /// Clears the input path and the B-Spline. + /// + public void Clear() + { + inputPath.Clear(); + controlPoints.Clear(); + outputCache.Value = new List(); + } + + /// + /// Adds a linear point to the path and updates the B-Spline accordingly. + /// + /// The vector representing the point to add. + public void AddLinearPoint(Vector2 v) + { + if (!outputCache.IsValid) + redrawApproximatedPath(); + + if (inputPath.Count == 0) + { + inputPath.Add(v); + // We add the first point twice so we can track the + // end point of the raw path using the last control point. + controlPoints.Add(inputPath[0]); + controlPoints.Add(inputPath[0]); + return; + } + + inputPath.Add(v); + + var cps = controlPoints; + Debug.Assert(cps.Count >= 2); + + cps[^1] = inputPath[^1]; + + // Calculate the normalized momentum vectors for both raw and approximated paths. + // Momentum here refers to a direction vector representing the path's direction of movement. + var mraw = momentumDirection(inputPath, 3); + var mcp = cps.Count > 2 ? momentumDirection(outputCache.Value, 1) : Vector2.Zero; + + // Determine the alignment between the raw path and control path's momentums. + // It uses Vector2.Dot which calculates the cosine of the angle between two vectors. + // This alignment is used to adjust the control points based on the path's direction change. + float alignment = mraw == Vector2.Zero || mcp == Vector2.Zero ? 1.0f : MathF.Max(Vector2.Dot(mraw, mcp), 0.01f); + + // Calculate the distance between the last two control points. + // This distance is then used, along with alignment, to decide if a new control point is needed. + // The threshold for adding a new control point is based on the alignment and a predefined accuracy factor. + float distance = Vector2.Distance(cps[^1], cps[^2]); + if (distance / MathF.Pow(alignment, 4) > Tolerance * 1000) + cps.Add(cps[^1]); + + outputCache.Value = PathApproximator.BSplineToPiecewiseLinear(cps.ToArray(), degree); + } + + private Vector2 momentumDirection(IReadOnlyList vertices, int window) + { + if (vertices.Count < window + 1) + return Vector2.Zero; + + var sum = Vector2.Zero; + for (int i = 0; i < window; i++) + sum += vertices[^(i + 1)] - vertices[^(i + 2)]; + + // We choose a very small acceptable difference here to ensure that + // small momenta are not ignored. + if (Precision.AlmostEquals(sum.X, 0, 1e-7f) && Precision.AlmostEquals(sum.Y, 0, 1e-7f)) + return Vector2.Zero; + + return (sum / window).Normalized(); + } + } +} diff --git a/osu.Framework/Utils/PathApproximator.cs b/osu.Framework/Utils/PathApproximator.cs index 470a841342..261d00499d 100644 --- a/osu.Framework/Utils/PathApproximator.cs +++ b/osu.Framework/Utils/PathApproximator.cs @@ -29,27 +29,27 @@ public static class PathApproximator /// /// The control points. /// A list of vectors representing the piecewise-linear approximation. - public static List ApproximateBezier(ReadOnlySpan controlPoints) + public static List BezierToPiecewiseLinear(ReadOnlySpan controlPoints) { - return ApproximateBSpline(controlPoints); + return BSplineToPiecewiseLinear(controlPoints, 0); } /// - /// Creates a piecewise-linear approximation of a clamped uniform B-spline with polynomial order p, + /// Creates a piecewise-linear approximation of a clamped uniform B-spline with polynomial order degree, /// by dividing it into a series of bezier control points at its knots, then adaptively repeatedly /// subdividing those until their approximation error vanishes below a given threshold. - /// Retains previous bezier approximation functionality when p is 0 or too large to create knots. - /// Algorithm unsuitable for large values of p with many knots. + /// Retains previous bezier approximation functionality when degree is 0 or too large to create knots. + /// Algorithm unsuitable for large values of degree with many knots. /// /// The control points. - /// The polynomial order. + /// The polynomial order. /// A list of vectors representing the piecewise-linear approximation. - public static List ApproximateBSpline(ReadOnlySpan controlPoints, int p = 0) + public static List BSplineToPiecewiseLinear(ReadOnlySpan controlPoints, int degree) { List output = new List(); - int n = controlPoints.Length - 1; + int pointCount = controlPoints.Length - 1; - if (n < 0) + if (pointCount < 0) return output; Stack toFlatten = new Stack(); @@ -57,38 +57,38 @@ public static List ApproximateBSpline(ReadOnlySpan controlPoin var points = controlPoints.ToArray(); - if (p > 0 && p < n) + if (degree > 0 && degree < pointCount) { // Subdivide B-spline into bezier control points at knots. - for (int i = 0; i < n - p; i++) + for (int i = 0; i < pointCount - degree; i++) { - var subBezier = new Vector2[p + 1]; + var subBezier = new Vector2[degree + 1]; subBezier[0] = points[i]; - // Destructively insert the knot p-1 times via Boehm's algorithm. - for (int j = 0; j < p - 1; j++) + // Destructively insert the knot degree-1 times via Boehm's algorithm. + for (int j = 0; j < degree - 1; j++) { subBezier[j + 1] = points[i + 1]; - for (int k = 1; k < p - j; k++) + for (int k = 1; k < degree - j; k++) { - int l = Math.Min(k, n - p - i); + int l = Math.Min(k, pointCount - degree - i); points[i + k] = (l * points[i + k] + points[i + k + 1]) / (l + 1); } } - subBezier[p] = points[i + 1]; + subBezier[degree] = points[i + 1]; toFlatten.Push(subBezier); } - toFlatten.Push(points[(n - p)..]); + toFlatten.Push(points[(pointCount - degree)..]); // Reverse the stack so elements can be accessed in order. toFlatten = new Stack(toFlatten); } else { // B-spline subdivision unnecessary, degenerate to single bezier. - p = n; + degree = pointCount; toFlatten.Push(points); } // "toFlatten" contains all the curves which are not yet approximated well enough. @@ -97,8 +97,8 @@ public static List ApproximateBSpline(ReadOnlySpan controlPoin // Depth-first search // over the tree resulting from the subdivisions we make.) - var subdivisionBuffer1 = new Vector2[p + 1]; - var subdivisionBuffer2 = new Vector2[p * 2 + 1]; + var subdivisionBuffer1 = new Vector2[degree + 1]; + var subdivisionBuffer2 = new Vector2[degree * 2 + 1]; Vector2[] leftChild = subdivisionBuffer2; @@ -112,7 +112,7 @@ public static List ApproximateBSpline(ReadOnlySpan controlPoin // an extension to De Casteljau's algorithm to obtain a piecewise-linear approximation // of the bezier curve represented by our control points, consisting of the same amount // of points as there are control points. - bezierApproximate(parent, output, subdivisionBuffer1, subdivisionBuffer2, p + 1); + bezierApproximate(parent, output, subdivisionBuffer1, subdivisionBuffer2, degree + 1); freeBuffers.Push(parent); continue; @@ -120,18 +120,18 @@ public static List ApproximateBSpline(ReadOnlySpan controlPoin // If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep // subdividing the curve we are currently operating on. - Vector2[] rightChild = freeBuffers.Count > 0 ? freeBuffers.Pop() : new Vector2[p + 1]; - bezierSubdivide(parent, leftChild, rightChild, subdivisionBuffer1, p + 1); + Vector2[] rightChild = freeBuffers.Count > 0 ? freeBuffers.Pop() : new Vector2[degree + 1]; + bezierSubdivide(parent, leftChild, rightChild, subdivisionBuffer1, degree + 1); // We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration. - for (int i = 0; i < p + 1; ++i) + for (int i = 0; i < degree + 1; ++i) parent[i] = leftChild[i]; toFlatten.Push(rightChild); toFlatten.Push(parent); } - output.Add(controlPoints[n]); + output.Add(controlPoints[pointCount]); return output; } @@ -139,7 +139,7 @@ public static List ApproximateBSpline(ReadOnlySpan controlPoin /// Creates a piecewise-linear approximation of a Catmull-Rom spline. /// /// A list of vectors representing the piecewise-linear approximation. - public static List ApproximateCatmull(ReadOnlySpan controlPoints) + public static List CatmullToPiecewiseLinear(ReadOnlySpan controlPoints) { var result = new List((controlPoints.Length - 1) * catmull_detail * 2); @@ -164,11 +164,11 @@ public static List ApproximateCatmull(ReadOnlySpan controlPoin /// Creates a piecewise-linear approximation of a circular arc curve. /// /// A list of vectors representing the piecewise-linear approximation. - public static List ApproximateCircularArc(ReadOnlySpan controlPoints) + public static List CircularArcToPiecewiseLinear(ReadOnlySpan controlPoints) { CircularArcProperties pr = new CircularArcProperties(controlPoints); if (!pr.IsValid) - return ApproximateBezier(controlPoints); + return BezierToPiecewiseLinear(controlPoints); // We select the amount of points for the approximation by requiring the discrete curvature // to be smaller than the provided tolerance. The exact angle required to meet the tolerance @@ -244,7 +244,7 @@ public static RectangleF CircularArcBoundingBox(ReadOnlySpan controlPoi /// Basically, returns the input. /// /// A list of vectors representing the piecewise-linear approximation. - public static List ApproximateLinear(ReadOnlySpan controlPoints) + public static List LinearToPiecewiseLinear(ReadOnlySpan controlPoints) { var result = new List(controlPoints.Length); @@ -258,7 +258,7 @@ public static List ApproximateLinear(ReadOnlySpan controlPoint /// Creates a piecewise-linear approximation of a lagrange polynomial. /// /// A list of vectors representing the piecewise-linear approximation. - public static List ApproximateLagrangePolynomial(ReadOnlySpan controlPoints) + public static List LagrangePolynomialToPiecewiseLinear(ReadOnlySpan controlPoints) { // TODO: add some smarter logic here, chebyshev nodes? const int num_steps = 51;