// 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.Collections.Specialized; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Utils; using osu.Game.Rulesets.Objects.Types; using osuTK; namespace osu.Game.Rulesets.Objects { public class SliderPath { /// /// The current version of this . Updated when any change to the path occurs. /// [JsonIgnore] public IBindable Version => version; private readonly Bindable version = new Bindable(); /// /// The user-set distance of the path. If non-null, will match this value, /// and the path will be shortened/lengthened to match this length. /// public readonly Bindable ExpectedDistance = new Bindable(); /// /// The control points of the path. /// public readonly BindableList ControlPoints = new BindableList(); private readonly List calculatedPath = new List(); private readonly List cumulativeLength = new List(); private readonly Cached pathCache = new Cached(); private double calculatedLength; /// /// Creates a new . /// public SliderPath() { ExpectedDistance.ValueChanged += _ => invalidate(); ControlPoints.CollectionChanged += (_, args) => { switch (args.Action) { case NotifyCollectionChangedAction.Add: foreach (var c in args.NewItems.Cast()) { c.Changed += invalidate; c.Changed += updatePathTypes; } break; case NotifyCollectionChangedAction.Reset: case NotifyCollectionChangedAction.Remove: foreach (var c in args.OldItems.Cast()) { c.Changed -= invalidate; c.Changed -= updatePathTypes; } break; } invalidate(); }; } /// /// Creates a new initialised with a list of control points. /// /// An optional set of s to initialise the path with. /// A user-set distance of the path that may be shorter or longer than the true distance between all control points. /// The path will be shortened/lengthened to match this length. If null, the path will use the true distance between all control points. [JsonConstructor] public SliderPath(PathControlPoint[] controlPoints, double? expectedDistance = null) : this() { ControlPoints.AddRange(controlPoints); ExpectedDistance.Value = expectedDistance; } public SliderPath(PathType type, Vector2[] controlPoints, double? expectedDistance = null) : this(controlPoints.Select((c, i) => new PathControlPoint(c, i == 0 ? (PathType?)type : null)).ToArray(), expectedDistance) { } /// /// The distance of the path after lengthening/shortening to account for . /// [JsonIgnore] public double Distance { get { ensureValid(); return cumulativeLength.Count == 0 ? 0 : cumulativeLength[^1]; } } /// /// The distance of the path prior to lengthening/shortening to account for . /// public double CalculatedDistance { get { ensureValid(); return calculatedLength; } } /// /// Computes the slider path until a given progress that ranges from 0 (beginning of the slider) /// to 1 (end of the slider) and stores the generated path in the given list. /// /// The list to be filled with the computed path. /// Start progress. Ranges from 0 (beginning of the slider) to 1 (end of the slider). /// End progress. Ranges from 0 (beginning of the slider) to 1 (end of the slider). public void GetPathToProgress(List path, double p0, double p1) { ensureValid(); double d0 = progressToDistance(p0); double d1 = progressToDistance(p1); path.Clear(); int i = 0; for (; i < calculatedPath.Count && cumulativeLength[i] < d0; ++i) { } path.Add(interpolateVertices(i, d0)); for (; i < calculatedPath.Count && cumulativeLength[i] <= d1; ++i) path.Add(calculatedPath[i]); path.Add(interpolateVertices(i, d1)); } /// /// Computes the position on the slider at a given progress that ranges from 0 (beginning of the path) /// to 1 (end of the path). /// /// Ranges from 0 (beginning of the path) to 1 (end of the path). /// public Vector2 PositionAt(double progress) { ensureValid(); double d = progressToDistance(progress); return interpolateVertices(indexOfDistance(d), d); } /// /// Returns the control points belonging to the same segment as the one given. /// The first point has a PathType which all other points inherit. /// /// One of the control points in the segment. /// public List PointsInSegment(PathControlPoint controlPoint) { bool found = false; List pointsInCurrentSegment = new List(); foreach (PathControlPoint point in ControlPoints) { if (point.Type.Value != null) { if (!found) pointsInCurrentSegment.Clear(); else { pointsInCurrentSegment.Add(point); break; } } pointsInCurrentSegment.Add(point); if (point == controlPoint) found = true; } return pointsInCurrentSegment; } /// /// Handles correction of invalid path types. /// private void updatePathTypes() { foreach (PathControlPoint segmentStartPoint in ControlPoints.Where(p => p.Type.Value != null)) { if (segmentStartPoint.Type.Value != PathType.PerfectCurve) continue; Vector2[] points = PointsInSegment(segmentStartPoint).Select(p => p.Position.Value).ToArray(); if (points.Length == 3 && !validCircularArcSegment(points)) segmentStartPoint.Type.Value = PathType.Bezier; } } /// /// Returns whether the given points are arranged in a valid way. Invalid if points /// are almost entirely linear - as this causes the radius to approach infinity, /// which would exhaust memory when drawing / approximating. /// /// The three points that make up this circular arc segment. /// private bool validCircularArcSegment(IReadOnlyList points) { Vector2 a = points[0]; Vector2 b = points[1]; Vector2 c = points[2]; float maxLength = points.Max(p => p.Length); Vector2 normA = new Vector2(a.X / maxLength, a.Y / maxLength); Vector2 normB = new Vector2(b.X / maxLength, b.Y / maxLength); Vector2 normC = new Vector2(c.X / maxLength, c.Y / maxLength); float det = (normA.X - normB.X) * (normB.Y - normC.Y) - (normB.X - normC.X) * (normA.Y - normB.Y); float acSq = (a - c).LengthSquared; float abSq = (a - b).LengthSquared; float bcSq = (b - c).LengthSquared; // Exterior = curve wraps around the long way between end-points // Exterior bottleneck is drawing-related, interior bottleneck is approximation-related, // where the latter is much faster, hence differing thresholds bool exterior = abSq > acSq || bcSq > acSq; float threshold = exterior ? 0.05f : 0.001f; return Math.Abs(det) >= threshold; } private void invalidate() { pathCache.Invalidate(); version.Value++; } private void ensureValid() { if (pathCache.IsValid) return; calculatePath(); calculateLength(); pathCache.Validate(); } private void calculatePath() { calculatedPath.Clear(); if (ControlPoints.Count == 0) return; Vector2[] vertices = new Vector2[ControlPoints.Count]; for (int i = 0; i < ControlPoints.Count; i++) vertices[i] = ControlPoints[i].Position.Value; int start = 0; for (int i = 0; i < ControlPoints.Count; i++) { if (ControlPoints[i].Type.Value == null && i < ControlPoints.Count - 1) continue; // The current vertex ends the segment var segmentVertices = vertices.AsSpan().Slice(start, i - start + 1); var segmentType = ControlPoints[start].Type.Value ?? PathType.Linear; foreach (Vector2 t in calculateSubPath(segmentVertices, segmentType)) { if (calculatedPath.Count == 0 || calculatedPath.Last() != t) calculatedPath.Add(t); } // Start the new segment at the current vertex start = i; } } private List calculateSubPath(ReadOnlySpan subControlPoints, PathType type) { switch (type) { case PathType.Linear: return PathApproximator.ApproximateLinear(subControlPoints); case PathType.PerfectCurve: if (subControlPoints.Length != 3) break; List subpath = PathApproximator.ApproximateCircularArc(subControlPoints); // If for some reason a circular arc could not be fit to the 3 given points, fall back to a numerically stable bezier approximation. if (subpath.Count == 0) break; return subpath; case PathType.Catmull: return PathApproximator.ApproximateCatmull(subControlPoints); } return PathApproximator.ApproximateBezier(subControlPoints); } private void calculateLength() { calculatedLength = 0; cumulativeLength.Clear(); cumulativeLength.Add(0); for (int i = 0; i < calculatedPath.Count - 1; i++) { Vector2 diff = calculatedPath[i + 1] - calculatedPath[i]; calculatedLength += diff.Length; cumulativeLength.Add(calculatedLength); } if (ExpectedDistance.Value is double expectedDistance && calculatedLength != expectedDistance) { // The last length is always incorrect cumulativeLength.RemoveAt(cumulativeLength.Count - 1); int pathEndIndex = calculatedPath.Count - 1; if (calculatedLength > expectedDistance) { // The path will be shortened further, in which case we should trim any more unnecessary lengths and their associated path segments while (cumulativeLength.Count > 0 && cumulativeLength[^1] >= expectedDistance) { cumulativeLength.RemoveAt(cumulativeLength.Count - 1); calculatedPath.RemoveAt(pathEndIndex--); } } if (pathEndIndex <= 0) { // The expected distance is negative or zero // TODO: Perhaps negative path lengths should be disallowed altogether cumulativeLength.Add(0); return; } // The direction of the segment to shorten or lengthen Vector2 dir = (calculatedPath[pathEndIndex] - calculatedPath[pathEndIndex - 1]).Normalized(); calculatedPath[pathEndIndex] = calculatedPath[pathEndIndex - 1] + dir * (float)(expectedDistance - cumulativeLength[^1]); cumulativeLength.Add(expectedDistance); } } private int indexOfDistance(double d) { int i = cumulativeLength.BinarySearch(d); if (i < 0) i = ~i; return i; } private double progressToDistance(double progress) { return Math.Clamp(progress, 0, 1) * Distance; } private Vector2 interpolateVertices(int i, double d) { if (calculatedPath.Count == 0) return Vector2.Zero; if (i <= 0) return calculatedPath.First(); if (i >= calculatedPath.Count) return calculatedPath.Last(); Vector2 p0 = calculatedPath[i - 1]; Vector2 p1 = calculatedPath[i]; double d0 = cumulativeLength[i - 1]; double d1 = cumulativeLength[i]; // Avoid division by and almost-zero number in case two points are extremely close to each other. if (Precision.AlmostEquals(d0, d1)) return p0; double w = (d - d0) / (d1 - d0); return p0 + (p1 - p0) * (float)w; } } }