From 597b0cfd1c7f6aefbd134fa15a83c0c97280b9e7 Mon Sep 17 00:00:00 2001 From: kionell Date: Mon, 21 Sep 2020 22:39:48 +0300 Subject: [PATCH 01/21] Update Beatmap.js Sometimes, hit objects have no arguments, which causes the program to crash as we try to access the -1 index. --- src/Beatmap.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Beatmap.js b/src/Beatmap.js index c2446f3..c21eb69 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -204,7 +204,7 @@ class Beatmap { hitType: parseInt(hitType, 10), hitSound: parseInt(hitSound) }; - if (args[args.length - 1].includes(":")) { + if (args.length && args[args.length - 1].includes(":")) { // some sliders don't use the extras if (hitType & HitType.Hold) { let [ From d03b85246c872d953e07b4f2e03c483d52a15018 Mon Sep 17 00:00:00 2001 From: kionell Date: Tue, 22 Sep 2020 23:08:11 +0300 Subject: [PATCH 02/21] Missing AR fix Very old maps don't have AR, but for compatibility, it is replaced with OD --- src/Beatmap.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Beatmap.js b/src/Beatmap.js index c21eb69..df8d503 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -182,7 +182,8 @@ class Beatmap { break; case "OverallDifficulty": beatmap[section][key] = parseFloat(value); - break; + key = "ApproachRate"; + if (beatmap[section][key]) break; case "ApproachRate": beatmap[section][key] = parseFloat(value); break; From e8a78edf6cc6eaf6f7f8c34a3affb1966b4315b2 Mon Sep 17 00:00:00 2001 From: kionell Date: Tue, 22 Sep 2020 23:57:57 +0300 Subject: [PATCH 03/21] Fix wrong slider tick calculations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I can't tell you exactly which beatmaps have the wrong max combo value, but I've tested some long maps: (Correct values ​​on the right were taken from osu api) 1589320: Before - 3205x of 3155x After - 3155x of 3155x 1765081: Before - 2233x of 2180x After - 2180x of 2180x 942356: Before - 2088x of 1944x After - 1944x of 1944x 1528842: Before - 10125x of 10215x After - 10214x of 10215x (idk why it lost 1 combo) 1605148 (2B map): Before - 2809x (???) of 1925x After - 1920x of 1925x --- src/Rulesets/Osu/Objects/Slider.js | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Rulesets/Osu/Objects/Slider.js b/src/Rulesets/Osu/Objects/Slider.js index 360e2fe..9fb1598 100644 --- a/src/Rulesets/Osu/Objects/Slider.js +++ b/src/Rulesets/Osu/Objects/Slider.js @@ -15,19 +15,26 @@ class Slider extends HitObject { finalize(timingPoint, parentTimingPoint, beatmap) { let velocityMultiplier = 1; - if (timingPoint.inherited && timingPoint.beatLength < 0) + let difficulty = beatmap.Difficulty; + + if (!timingPoint.inherited && timingPoint.beatLength < 0) { velocityMultiplier = -100 / timingPoint.beatLength; - let pixelsPerBeat = - beatmap.Difficulty.SliderMultiplier * 100 * velocityMultiplier; + } + + let pixelsPerBeat = difficulty.SliderMultiplier * 100; + + if (beatmap.Version >= 8) { + pixelsPerBeat *= velocityMultiplier; + } + let beats = (this.pixelLength * this.repeat) / pixelsPerBeat; - let duration = Math.ceil( - beats * parentTimingPoint ? parentTimingPoint.beatLength : 1 - ); + let parentBeatLength = parentTimingPoint ? parentTimingPoint.beatLength : 1; + let duration = Math.ceil(beats * parentBeatLength); + this.endTime = this.startTime + duration; this.combo = - Math.ceil( - ((beats - 0.01) / this.repeat) * beatmap.Difficulty.SliderTickRate - ) - 1; + Math.ceil((beats - 0.1) / this.repeat * difficulty.SliderTickRate) - 1; + this.combo *= this.repeat; this.combo += this.repeat + 1; } From 63ea68504605bb0ae060e8f7188769156f508edb Mon Sep 17 00:00:00 2001 From: kionell Date: Thu, 24 Sep 2020 05:07:15 +0300 Subject: [PATCH 04/21] Min & max bpm in declaration --- src/Beatmap.d.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Beatmap.d.ts b/src/Beatmap.d.ts index 203550f..44440e5 100644 --- a/src/Beatmap.d.ts +++ b/src/Beatmap.d.ts @@ -12,6 +12,8 @@ export default class Beatmap { SampleSet: string; StackLeniency: number; Mode: number; + MinBPM: number; + MaxBPM: number; LetterboxInBreaks: boolean; WidescreenStoryboard: boolean; }; From bb83fcb4e6c43e4981c7f81a3a884074a9320453 Mon Sep 17 00:00:00 2001 From: kionell Date: Thu, 24 Sep 2020 05:08:37 +0300 Subject: [PATCH 05/21] BPM calculation --- src/Beatmap.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Beatmap.js b/src/Beatmap.js index df8d503..f921b4d 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -338,6 +338,11 @@ class Beatmap { for (const tp of beatmap.TimingPoints) { if (!tp.inherited) parentPoint = tp; + let bpm = Math.round(60000 / tp.beatLength); + + beatmap.General.MinBPM = Math.min(beatmap.General.MinBPM, bpm) || bpm; + beatmap.General.MaxBPM = Math.max(beatmap.General.MaxBPM, bpm) || bpm; + for (let hitObject of beatmap.HitObjects.filter( ho => ho.startTime >= tp.time )) { From b02d7f508bed057a4dcc03872a08fe5d140b25bd Mon Sep 17 00:00:00 2001 From: Kionell Date: Thu, 24 Sep 2020 22:30:33 +0300 Subject: [PATCH 06/21] Slider end position calculation --- src/Beatmap.js | 14 ++- src/Utils/Curves.js | 217 ++++++++++++++++++++++++++++++++++++++++ src/Utils/SliderCalc.js | 138 +++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 src/Utils/Curves.js create mode 100644 src/Utils/SliderCalc.js diff --git a/src/Beatmap.js b/src/Beatmap.js index f921b4d..f573e91 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -3,6 +3,7 @@ const Colour = require("./Colour"); const Crunch = require("./Utils/OsuCruncher"); const HitType = require("./Enum/HitType"); const OsuHitObjectFactory = require("./Rulesets/Osu/HitObjectFactory"); +const SliderCalc = require("./Utils/SliderCalc"); class Beatmap { constructor() { @@ -253,7 +254,7 @@ class Beatmap { repeat, pixelLength, edgeHitSounds, - edgeAdditions + edgeAdditions, ] = args; let [type, ...curves] = curvyBits.split("|"); let curvePoints = curves @@ -262,10 +263,17 @@ class Beatmap { hitObject = { ...hitObject, curveType: type, - curvePoints, + curvePoints: [curvePoints[0], ...curvePoints], repeat: parseInt(repeat, 10), - pixelLength: parseInt(pixelLength, 10) + pixelLength: parseInt(pixelLength, 10), }; + + hitObject.endPos = SliderCalc.getEndPoint( + hitObject.curveType, + hitObject.pixelLength, + hitObject.curvePoints + ); + if (edgeHitSounds) { hitObject.edgeHitSounds = edgeHitSounds .split("|") diff --git a/src/Utils/Curves.js b/src/Utils/Curves.js new file mode 100644 index 0000000..0ac0c98 --- /dev/null +++ b/src/Utils/Curves.js @@ -0,0 +1,217 @@ +'use strict'; + +/** + * Taken from Osu-Web with some fixes + * https://github.com/pictuga/osu-web + */ + +function isPointInCircle(point, center, radius) { + return distancePoints(point, center) <= radius; +} + +function distancePoints(p1, p2) { + var x = (p1[0]-p2[0]); + var y = (p1[1]-p2[1]); + return Math.sqrt(x*x+ y*y); +} + +function distanceFromPoints(array) { + var distance = 0; + + for (var i = 1; i <= array.length - 1; i++) + distance += distancePoints(array[i], array[i-1]); + + return distance; +} + +function angleFromPoints(p1, p2) { + return Math.atan((p2[1]-p1[1])/(p2[0]-p1[0])); +} + +function cartFromPol(r, teta) { + var x2 = (r*Math.cos(teta)); + var y2 = (r*Math.sin(teta)); + + return [x2, y2]; +} + +function pointAtDistance(array, distance) { + //needs a serious cleanup ! + + var current_distance = 0; + var last_distance = 0; + var coord, angle, cart, new_distance; + + if (array.length < 2) return [0, 0, 0, 0]; + + if (distance == 0) { + var angle = angleFromPoints(array[0], array[1]); + return [array[0][0], array[0][1], angle, 0]; + } + + if (distanceFromPoints(array) <= distance) { + var angle = angleFromPoints(array[array.length-2], array[array.length-1]); + return [ + array[array.length-1][0], + array[array.length-1][1], + angle, + array.length-2 + ]; + } + + for (var i = 0; i <= array.length - 2; i++) { + var x = (array[i][0]-array[i+1][0]); + var y = (array[i][1]-array[i+1][1]); + + new_distance = (Math.sqrt(x*x+y*y)); + current_distance += new_distance; + + if (distance <= current_distance) break; + } + + current_distance -= new_distance; + + if (distance == current_distance) { + coord = [array[i][0], array[i][1]]; + angle = angleFromPoints(array[i], array[i+1]); + } else { + angle = angleFromPoints(array[i], array[i+1]); + cart = cartFromPol((distance - current_distance), angle); + + if (array[i][0] > array[i+1][0]) + coord = [(array[i][0] - cart[0]), (array[i][1] - cart[1])]; + else + coord = [(array[i][0] + cart[0]), (array[i][1] + cart[1])]; + } + + return [coord[0], coord[1], angle, i]; +} + +function factorial(n) { + n = parseInt(n) || 1; + + var result = 1; + for (var i = 1; i <= n; i++) result *= i; + + return result; +} + +function Cpn(p, n) { + if (p < 0 || p > n) + return 0; + var p = Math.min(p, n - p); + var out = 1; + for (var i = 1; i < p + 1; i++) + out = out * (n - p + i) / i; + return out; +} + +function array_values(array) { + var out = []; + for (var i in array) out.push(array[i]); + return out; +} + +function array_calc(op, array1, array2) { + var min = Math.min(array1.length, array2.length); + var retour = []; + + for (var i = 0; i < min; i++) + retour.push(array1[i] + op * array2[i]); + + return retour; +} + +/*************************************************************/ + +function Bezier(points) { + this.points = points; + this.order = points.length; + + this.step = 0.0025 / this.order; // x0.10 + this.pos = {}; + this.calcPoints(); +}; + +Bezier.prototype.at = function(t) { + //B(t) = sum_(i=0)^n (i parmis n) (1-t)^(n-i) * t^i * P_i + if (typeof this.pos[t] != "undefined") return this.pos[t]; + var x = 0, + y = 0; + var n = this.order - 1; + + for (var i = 0; i <= n; i++) { + x += Cpn(i, n) * Math.pow((1 - t), (n - i)) * Math.pow(t, i) * this.points[i][0]; + y += Cpn(i, n) * Math.pow((1 - t), (n - i)) * Math.pow(t, i) * this.points[i][1]; + } + + this.pos[t] = [x, y]; + + return [x, y]; +}; + +// Changed to approximate length +Bezier.prototype.calcPoints = function() { + if (Object.keys(this.pos).length) return; + + this.pxlength = 0; + var prev = this.at(0); + var current; + for (var i = 0; i < 1 + this.step; i += this.step) { + var current = this.at(i); + this.pxlength += distancePoints(prev, current); + prev = current; + } +}; + + +/*************************************************************/ + + +Bezier.prototype.pointAtDistance = Catmull.prototype.pointAtDistance = function(dist) { + switch (this.order) { + case 0: + return false; + case 1: + return this.points[0]; + default: + this.calcPoints(); + return pointAtDistance(array_values(this.pos), dist).slice(0, 2); + } +}; + +/*************************************************************/ + +function Catmull(points) { + this.points = points; + this.order = points.length; + + this.step = 0.025; + this.pos = []; + this.calcPoints(); +}; + +Catmull.prototype.at = function(x, t) { + var v1 = (x >= 1 ? this.points[x - 1] : this.points[x]); + var v2 = this.points[x]; + var v3 = (x + 1 < this.order ? this.points[x + 1] : array_calc('1', v2, array_calc('-1', v2, v1))); + var v4 = (x + 2 < this.order ? this.points[x + 2] : array_calc('1', v3, array_calc('-1', v3, v2))); + + var retour = []; + for (var i = 0; i <= 1; i++) { + retour[i] = 0.5 * ( + (-v1[i] + 3 * v2[i] - 3 * v3[i] + v4[i]) * t * t * t + (2 * v1[i] - 5 * v2[i] + 4 * v3[i] - v4[i]) * t * t + (-v1[i] + v3[i]) * t + 2 * v2[i]); + } + + return retour; +}; + +Catmull.prototype.calcPoints = function() { + if (this.pos.length) return; + for (var i = 0; i < this.order - 1; i++) + for (var t = 0; t < 1 + this.step; t += this.step) + this.pos.push(this.at(i, t)); +}; + +exports.Bezier = Bezier; +exports.Catmull = Catmull; diff --git a/src/Utils/SliderCalc.js b/src/Utils/SliderCalc.js new file mode 100644 index 0000000..97c710b --- /dev/null +++ b/src/Utils/SliderCalc.js @@ -0,0 +1,138 @@ +'use strict'; + +var curves = require('./curves'); +var Bezier = curves.Bezier; + +/** + * Get the endpoint of a slider + * @param {String} sliderType slider curve type + * @param {Float} sliderLength slider length + * @param {Array} points list of slider points + * @return {Object} endPoint the coordinates of the slider edge + */ +exports.getEndPoint = function (sliderType, sliderLength, points) { + if (!sliderType || !sliderLength || !points) return; + + switch (sliderType) { + case 'linear': + return pointOnLine(points[0], points[1], sliderLength); + case 'catmull': + // not supported, anyway it's only used in old beatmaps + return undefined; + case 'bezier': + if (!points || points.length < 2) { return undefined; } + if (points.length == 2) { return pointOnLine(points[0], points[1], sliderLength); } + + var pts = points.slice(); + var bezier; + var previous; + var point; + for (var i = 0, l = pts.length; i < l; i++) { + point = pts[i]; + + if (!previous) { + previous = point; + continue; + } + + if (point[0] == previous[0] && point[1] == previous[1]) { + bezier = new Bezier(pts.splice(0, i)); + sliderLength -= bezier.pxlength; + i = 0; + l = pts.length; + } + + previous = point; + } + + bezier = new Bezier(pts); + return bezier.pointAtDistance(sliderLength); + case 'pass-through': + if (!points || points.length < 2) { return undefined; } + if (points.length == 2) { return pointOnLine(points[0], points[1], sliderLength); } + if (points.length > 3) { return exports.getEndPoint('bezier', sliderLength, points); } + + var p1 = points[0]; + var p2 = points[1]; + var p3 = points[2]; + + var circumCicle = getCircumCircle(p1, p2, p3); + var radians = sliderLength / circumCicle.radius; + if (isLeft(p1, p2, p3)) radians *= -1; + + return rotate(circumCicle.cx, circumCicle.cy, p1[0], p1[1], radians); + } +}; + +function pointOnLine(p1, p2, length) { + var fullLength = Math.sqrt(Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)); + var n = fullLength - length; + + var x = (n * p1[0] + length * p2[0]) / fullLength; + var y = (n * p1[1] + length * p2[1]) / fullLength; + return [x, y]; +} + +/** + * Get coordinates of a point in a circle, given the center, a startpoint and a distance in radians + * @param {Float} cx center x + * @param {Float} cy center y + * @param {Float} x startpoint x + * @param {Float} y startpoint y + * @param {Float} radians distance from the startpoint + * @return {Object} the new point coordinates after rotation + */ +function rotate(cx, cy, x, y, radians) { + var cos = Math.cos(radians); + var sin = Math.sin(radians); + + return [ + (cos * (x - cx)) - (sin * (y - cy)) + cx, + (sin * (x - cx)) + (cos * (y - cy)) + cy + ]; +} + +/** + * Check if C is on left side of [AB] + * @param {Object} a startpoint of the segment + * @param {Object} b endpoint of the segment + * @param {Object} c the point we want to locate + * @return {Boolean} true if on left side + */ +function isLeft(a, b, c) { + return ((b[0] - a[0])*(c[1] - a[1]) - (b[1] - a[1])*(c[0] - a[0])) < 0; +} + +/** + * Get circum circle of 3 points + * @param {Object} p1 first point + * @param {Object} p2 second point + * @param {Object} p3 third point + * @return {Object} circumCircle + */ +function getCircumCircle(p1, p2, p3) { + var x1 = p1[0]; + var y1 = p1[1]; + + var x2 = p2[0]; + var y2 = p2[1]; + + var x3 = p3[0]; + var y3 = p3[1]; + + //center of circle + var D = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + + var Ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / D; + var Uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / D; + + var px = Ux - x1; + var py = Uy - y1; + var r = Math.sqrt(px * px + py * py); + + return { + cx: Ux, + cy: Uy, + radius: r + }; +} \ No newline at end of file From 9d14d7930503767217e9e8f35cc12a4a294e0216 Mon Sep 17 00:00:00 2001 From: Kionell Date: Thu, 24 Sep 2020 22:33:25 +0300 Subject: [PATCH 07/21] Updated package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 426ef37..d928f81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osu-bpdpc", - "version": "0.1.1", + "version": "0.2.1", "description": "Osu beatmap parser, difficulty and performance calculator", "main": "index.js", "scripts": { From 7e8a378cfdc20b060ab56cc775612a1b8b42156f Mon Sep 17 00:00:00 2001 From: Kionell Date: Thu, 24 Sep 2020 22:51:05 +0300 Subject: [PATCH 08/21] Fix for slider types in slider calculation --- src/Utils/SliderCalc.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Utils/SliderCalc.js b/src/Utils/SliderCalc.js index 97c710b..ccf43a6 100644 --- a/src/Utils/SliderCalc.js +++ b/src/Utils/SliderCalc.js @@ -14,12 +14,12 @@ exports.getEndPoint = function (sliderType, sliderLength, points) { if (!sliderType || !sliderLength || !points) return; switch (sliderType) { - case 'linear': + case 'L': return pointOnLine(points[0], points[1], sliderLength); - case 'catmull': + case 'C': // not supported, anyway it's only used in old beatmaps return undefined; - case 'bezier': + case 'B': if (!points || points.length < 2) { return undefined; } if (points.length == 2) { return pointOnLine(points[0], points[1], sliderLength); } @@ -47,10 +47,10 @@ exports.getEndPoint = function (sliderType, sliderLength, points) { bezier = new Bezier(pts); return bezier.pointAtDistance(sliderLength); - case 'pass-through': + case 'P': if (!points || points.length < 2) { return undefined; } if (points.length == 2) { return pointOnLine(points[0], points[1], sliderLength); } - if (points.length > 3) { return exports.getEndPoint('bezier', sliderLength, points); } + if (points.length > 3) { return exports.getEndPoint('B', sliderLength, points); } var p1 = points[0]; var p2 = points[1]; From f975021ef7f61a5f6000ef04397cd8fc3f967f95 Mon Sep 17 00:00:00 2001 From: Kionell Date: Thu, 24 Sep 2020 23:14:35 +0300 Subject: [PATCH 09/21] Replace arrays with vectors --- src/Beatmap.js | 9 +++++- src/Utils/SliderCalc.js | 65 +++++++++++++++++++++-------------------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/src/Beatmap.js b/src/Beatmap.js index f573e91..3a530df 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -268,12 +268,19 @@ class Beatmap { pixelLength: parseInt(pixelLength, 10), }; - hitObject.endPos = SliderCalc.getEndPoint( + let endPoint = SliderCalc.getEndPoint( hitObject.curveType, hitObject.pixelLength, hitObject.curvePoints ); + if (endPoint && endPoint.x && endPoint.x) { + hitObject.endPos = endPoint; + } else { + // If endPosition could not be calculated, approximate it by setting it to the last point + hitObject.endPos = hitObject.curvePoints[hitObject.curvePoints.length - 1]; + } + if (edgeHitSounds) { hitObject.edgeHitSounds = edgeHitSounds .split("|") diff --git a/src/Utils/SliderCalc.js b/src/Utils/SliderCalc.js index ccf43a6..a9e4723 100644 --- a/src/Utils/SliderCalc.js +++ b/src/Utils/SliderCalc.js @@ -1,14 +1,14 @@ 'use strict'; -var curves = require('./curves'); -var Bezier = curves.Bezier; +const Vector2 = require("./Utils/Vector2"); +const {Bezier} = require('./curves'); /** * Get the endpoint of a slider - * @param {String} sliderType slider curve type - * @param {Float} sliderLength slider length + * @param {string} sliderType slider curve type + * @param {number} sliderLength slider length * @param {Array} points list of slider points - * @return {Object} endPoint the coordinates of the slider edge + * @return {object} endPoint the coordinates of the slider edge */ exports.getEndPoint = function (sliderType, sliderLength, points) { if (!sliderType || !sliderLength || !points) return; @@ -35,7 +35,7 @@ exports.getEndPoint = function (sliderType, sliderLength, points) { continue; } - if (point[0] == previous[0] && point[1] == previous[1]) { + if (point.x == previous.x && point.y == previous.y) { bezier = new Bezier(pts.splice(0, i)); sliderLength -= bezier.pxlength; i = 0; @@ -60,27 +60,28 @@ exports.getEndPoint = function (sliderType, sliderLength, points) { var radians = sliderLength / circumCicle.radius; if (isLeft(p1, p2, p3)) radians *= -1; - return rotate(circumCicle.cx, circumCicle.cy, p1[0], p1[1], radians); + return rotate(circumCicle.cx, circumCicle.cy, p1.x, p1.y, radians); } }; function pointOnLine(p1, p2, length) { - var fullLength = Math.sqrt(Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)); + var fullLength = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); var n = fullLength - length; - var x = (n * p1[0] + length * p2[0]) / fullLength; - var y = (n * p1[1] + length * p2[1]) / fullLength; - return [x, y]; + var x = (n * p1.x + length * p2.x) / fullLength; + var y = (n * p1.y + length * p2.y) / fullLength; + + return new Vector2(x, y); } /** * Get coordinates of a point in a circle, given the center, a startpoint and a distance in radians - * @param {Float} cx center x - * @param {Float} cy center y - * @param {Float} x startpoint x - * @param {Float} y startpoint y - * @param {Float} radians distance from the startpoint - * @return {Object} the new point coordinates after rotation + * @param {number} cx center x + * @param {number} cy center y + * @param {number} x startpoint x + * @param {number} y startpoint y + * @param {number} radians distance from the startpoint + * @return {object} the new point coordinates after rotation */ function rotate(cx, cy, x, y, radians) { var cos = Math.cos(radians); @@ -94,31 +95,31 @@ function rotate(cx, cy, x, y, radians) { /** * Check if C is on left side of [AB] - * @param {Object} a startpoint of the segment - * @param {Object} b endpoint of the segment - * @param {Object} c the point we want to locate - * @return {Boolean} true if on left side + * @param {object} a startpoint of the segment + * @param {object} b endpoint of the segment + * @param {object} c the point we want to locate + * @return {boolean} true if on left side */ function isLeft(a, b, c) { - return ((b[0] - a[0])*(c[1] - a[1]) - (b[1] - a[1])*(c[0] - a[0])) < 0; + return ((b.x - a.x)*(c.y - a.y) - (b.y - a.y)*(c.x - a.x)) < 0; } /** * Get circum circle of 3 points - * @param {Object} p1 first point - * @param {Object} p2 second point - * @param {Object} p3 third point - * @return {Object} circumCircle + * @param {object} p1 first point + * @param {object} p2 second point + * @param {object} p3 third point + * @return {object} circumCircle */ function getCircumCircle(p1, p2, p3) { - var x1 = p1[0]; - var y1 = p1[1]; + var x1 = p1.x; + var y1 = p1.y; - var x2 = p2[0]; - var y2 = p2[1]; + var x2 = p2.x; + var y2 = p2.y; - var x3 = p3[0]; - var y3 = p3[1]; + var x3 = p3.x; + var y3 = p3.y; //center of circle var D = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); From a654bb2a82e588c637d9e29b8040bbba2fc6266e Mon Sep 17 00:00:00 2001 From: Kionell Date: Fri, 25 Sep 2020 00:12:08 +0300 Subject: [PATCH 10/21] Rewrites in SliderCalc and Curves --- src/Utils/Curves.js | 304 ++++++++++++++++++++++------------------ src/Utils/SliderCalc.js | 98 +++++++------ 2 files changed, 219 insertions(+), 183 deletions(-) diff --git a/src/Utils/Curves.js b/src/Utils/Curves.js index 0ac0c98..103e66c 100644 --- a/src/Utils/Curves.js +++ b/src/Utils/Curves.js @@ -1,69 +1,66 @@ 'use strict'; -/** - * Taken from Osu-Web with some fixes - * https://github.com/pictuga/osu-web - */ +function distancePoints(p1, p2) +{ + let x = (p1[0] - p2[0]); + let y = (p1[1] - p2[1]); -function isPointInCircle(point, center, radius) { - return distancePoints(point, center) <= radius; + return Math.sqrt(x * x + y * y); } -function distancePoints(p1, p2) { - var x = (p1[0]-p2[0]); - var y = (p1[1]-p2[1]); - return Math.sqrt(x*x+ y*y); -} - -function distanceFromPoints(array) { - var distance = 0; +function distanceFromPoints(array) +{ + let distance = 0; - for (var i = 1; i <= array.length - 1; i++) - distance += distancePoints(array[i], array[i-1]); + for (let i = 1, len = array.length - 1; i <= len; ++i) { + distance += distancePoints(array[i], array[i - 1]); + } return distance; } -function angleFromPoints(p1, p2) { - return Math.atan((p2[1]-p1[1])/(p2[0]-p1[0])); +function angleFromPoints(p1, p2) +{ + return Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0])); } -function cartFromPol(r, teta) { - var x2 = (r*Math.cos(teta)); - var y2 = (r*Math.sin(teta)); +function cartFromPol(r, teta) +{ + let x2 = (r * Math.cos(teta)); + let y2 = (r * Math.sin(teta)); return [x2, y2]; } -function pointAtDistance(array, distance) { +function pointAtDistance(array, distance) +{ //needs a serious cleanup ! - - var current_distance = 0; - var last_distance = 0; - var coord, angle, cart, new_distance; + let current_distance = 0; + let coord, angle, cart, new_distance; if (array.length < 2) return [0, 0, 0, 0]; - if (distance == 0) { - var angle = angleFromPoints(array[0], array[1]); + if (distance === 0) { + let angle = angleFromPoints(array[0], array[1]); return [array[0][0], array[0][1], angle, 0]; } if (distanceFromPoints(array) <= distance) { - var angle = angleFromPoints(array[array.length-2], array[array.length-1]); + let angle = angleFromPoints(array[array.length - 2], array[array.length - 1]); + return [ - array[array.length-1][0], - array[array.length-1][1], + array[array.length - 1][0], + array[array.length - 1][1], angle, - array.length-2 + array.length - 2 ]; } - for (var i = 0; i <= array.length - 2; i++) { - var x = (array[i][0]-array[i+1][0]); - var y = (array[i][1]-array[i+1][1]); + for (let i = 0; i <= array.length - 2; i++) { + let x = (array[i][0] - array[i + 1][0]); + let y = (array[i][1] - array[i + 1][1]); - new_distance = (Math.sqrt(x*x+y*y)); + new_distance = (Math.sqrt(x * x + y * y)); current_distance += new_distance; if (distance <= current_distance) break; @@ -71,14 +68,15 @@ function pointAtDistance(array, distance) { current_distance -= new_distance; - if (distance == current_distance) { + if (distance === current_distance) { coord = [array[i][0], array[i][1]]; - angle = angleFromPoints(array[i], array[i+1]); - } else { - angle = angleFromPoints(array[i], array[i+1]); - cart = cartFromPol((distance - current_distance), angle); + angle = angleFromPoints(array[i], array[i + 1]); + } + else { + angle = angleFromPoints(array[i], array[i + 1]); + cart = cartFromPol((distance - current_distance), angle); - if (array[i][0] > array[i+1][0]) + if (array[i][0] > array[i + 1][0]) coord = [(array[i][0] - cart[0]), (array[i][1] - cart[1])]; else coord = [(array[i][0] + cart[0]), (array[i][1] + cart[1])]; @@ -87,88 +85,152 @@ function pointAtDistance(array, distance) { return [coord[0], coord[1], angle, i]; } -function factorial(n) { - n = parseInt(n) || 1; - - var result = 1; - for (var i = 1; i <= n; i++) result *= i; - - return result; -} - -function Cpn(p, n) { - if (p < 0 || p > n) +function Cpn(p, n) +{ + if (p < 0 || p > n) { return 0; - var p = Math.min(p, n - p); - var out = 1; - for (var i = 1; i < p + 1; i++) + } + + let p = Math.min(p, n - p); + let out = 1; + + for (let i = 1; i < p + 1; i++) { out = out * (n - p + i) / i; + } + return out; } -function array_values(array) { - var out = []; - for (var i in array) out.push(array[i]); +function array_values(array) +{ + let out = []; + + for (let i in array) { + out.push(array[i]); + } + return out; } -function array_calc(op, array1, array2) { - var min = Math.min(array1.length, array2.length); - var retour = []; +function array_calc(op, array1, array2) +{ + let min = Math.min(array1.length, array2.length); + let retour = []; - for (var i = 0; i < min; i++) + for (let i = 0; i < min; ++i) { retour.push(array1[i] + op * array2[i]); - + } + return retour; } -/*************************************************************/ - -function Bezier(points) { - this.points = points; - this.order = points.length; +class Bezier +{ + constructor(points) + { + this.points = points; + this.order = points.length; + + this.step = 0.0025 / this.order; // x0.10 + this.pos = {}; + this.calcPoints(); + } - this.step = 0.0025 / this.order; // x0.10 - this.pos = {}; - this.calcPoints(); + at(t) + { + //B(t) = sum_(i=0)^n (i parmis n) (1-t)^(n-i) * t^i * P_i + if (typeof this.pos[t] !== "undefined") { + return this.pos[t]; + } + + let x = 0, y = 0; + let n = this.order - 1; + + for (let i = 0; i <= n; ++i) { + x += Cpn(i, n) * Math.pow((1 - t), (n - i)) + * Math.pow(t, i) * this.points[i].x; + + y += Cpn(i, n) * Math.pow((1 - t), (n - i)) + * Math.pow(t, i) * this.points[i].y; + } + + this.pos[t] = [x, y]; + + return [x, y]; + }; + + // Changed to approximate length + calcPoints() + { + if (Object.keys(this.pos).length) { + return; + } + + this.pxlength = 0; + + let prev = this.at(0); + let current; + + for (let i = 0; i < 1 + this.step; i += this.step) { + current = this.at(i); + this.pxlength += distancePoints(prev, current); + prev = current; + } + }; }; -Bezier.prototype.at = function(t) { - //B(t) = sum_(i=0)^n (i parmis n) (1-t)^(n-i) * t^i * P_i - if (typeof this.pos[t] != "undefined") return this.pos[t]; - var x = 0, - y = 0; - var n = this.order - 1; +class Catmull +{ + constructor(points) + { + this.points = points; + this.order = points.length; - for (var i = 0; i <= n; i++) { - x += Cpn(i, n) * Math.pow((1 - t), (n - i)) * Math.pow(t, i) * this.points[i][0]; - y += Cpn(i, n) * Math.pow((1 - t), (n - i)) * Math.pow(t, i) * this.points[i][1]; + this.step = 0.025; + this.pos = []; + this.calcPoints(); } - this.pos[t] = [x, y]; - - return [x, y]; + at(x, t) + { + let v1 = x >= 1 ? this.points[x - 1] : this.points[x]; + let v2 = this.points[x]; + + let v3 = x + 1 < this.order + ? this.points[x + 1] + : array_calc('1', v2, array_calc('-1', v2, v1)); + + let v4 = x + 2 < this.order + ? this.points[x + 2] + : array_calc('1', v3, array_calc('-1', v3, v2)); + + let retour = [ + 0.5 * ((-v1.x + 3 * v2.x - 3 * v3.x + v4.x) * t ** 3 + + (2 * v1.x - 5 * v2.x + 4 * v3.x - v4.x) + * t * t + (-v1.x + v3.x) * t + 2 * v2.x), + + 0.5 * ((-v1.y + 3 * v2.y - 3 * v3.y + v4.y) * t ** 3 + + (2 * v1.y - 5 * v2.y + 4 * v3.y - v4.y) + * t * t + (-v1.y + v3.y) * t + 2 * v2.y) + ] + + return retour; + }; + + calcPoints() + { + if (this.pos.length) { + return; + } + + for (let i = 0, len1 = this.order - 1; i < len1; ++i) + for (let t = 0, len2 = 1 + this.step; t < len2; t += this.step) + this.pos.push(this.at(i, t)); + }; }; -// Changed to approximate length -Bezier.prototype.calcPoints = function() { - if (Object.keys(this.pos).length) return; - - this.pxlength = 0; - var prev = this.at(0); - var current; - for (var i = 0; i < 1 + this.step; i += this.step) { - var current = this.at(i); - this.pxlength += distancePoints(prev, current); - prev = current; - } -}; - - -/*************************************************************/ - - -Bezier.prototype.pointAtDistance = Catmull.prototype.pointAtDistance = function(dist) { +Bezier.prototype.pointAtDistance = Catmull.prototype.pointAtDistance = function (dist) +{ switch (this.order) { case 0: return false; @@ -180,38 +242,4 @@ Bezier.prototype.pointAtDistance = Catmull.prototype.pointAtDistance = function( } }; -/*************************************************************/ - -function Catmull(points) { - this.points = points; - this.order = points.length; - - this.step = 0.025; - this.pos = []; - this.calcPoints(); -}; - -Catmull.prototype.at = function(x, t) { - var v1 = (x >= 1 ? this.points[x - 1] : this.points[x]); - var v2 = this.points[x]; - var v3 = (x + 1 < this.order ? this.points[x + 1] : array_calc('1', v2, array_calc('-1', v2, v1))); - var v4 = (x + 2 < this.order ? this.points[x + 2] : array_calc('1', v3, array_calc('-1', v3, v2))); - - var retour = []; - for (var i = 0; i <= 1; i++) { - retour[i] = 0.5 * ( - (-v1[i] + 3 * v2[i] - 3 * v3[i] + v4[i]) * t * t * t + (2 * v1[i] - 5 * v2[i] + 4 * v3[i] - v4[i]) * t * t + (-v1[i] + v3[i]) * t + 2 * v2[i]); - } - - return retour; -}; - -Catmull.prototype.calcPoints = function() { - if (this.pos.length) return; - for (var i = 0; i < this.order - 1; i++) - for (var t = 0; t < 1 + this.step; t += this.step) - this.pos.push(this.at(i, t)); -}; - -exports.Bezier = Bezier; -exports.Catmull = Catmull; +module.exports = {Bezier, Catmull}; \ No newline at end of file diff --git a/src/Utils/SliderCalc.js b/src/Utils/SliderCalc.js index a9e4723..ea1a23d 100644 --- a/src/Utils/SliderCalc.js +++ b/src/Utils/SliderCalc.js @@ -1,7 +1,7 @@ 'use strict'; -const Vector2 = require("./Utils/Vector2"); -const {Bezier} = require('./curves'); +const Vector2 = require("./Vector2"); +const {Bezier} = require('./Curves'); /** * Get the endpoint of a slider @@ -10,7 +10,8 @@ const {Bezier} = require('./curves'); * @param {Array} points list of slider points * @return {object} endPoint the coordinates of the slider edge */ -exports.getEndPoint = function (sliderType, sliderLength, points) { +function getEndPoint(sliderType, sliderLength, points) +{ if (!sliderType || !sliderLength || !points) return; switch (sliderType) { @@ -20,14 +21,13 @@ exports.getEndPoint = function (sliderType, sliderLength, points) { // not supported, anyway it's only used in old beatmaps return undefined; case 'B': - if (!points || points.length < 2) { return undefined; } - if (points.length == 2) { return pointOnLine(points[0], points[1], sliderLength); } - - var pts = points.slice(); - var bezier; - var previous; - var point; - for (var i = 0, l = pts.length; i < l; i++) { + if (!points || points.length < 2) { return undefined; } + if (points.length === 2) { return pointOnLine(points[0], points[1], sliderLength); } + + let pts = points.slice(); + let bezier, previous, point; + + for (let i = 0, l = pts.length; i < l; i++) { point = pts[i]; if (!previous) { @@ -35,8 +35,8 @@ exports.getEndPoint = function (sliderType, sliderLength, points) { continue; } - if (point.x == previous.x && point.y == previous.y) { - bezier = new Bezier(pts.splice(0, i)); + if (point.x === previous.x && point.y === previous.y) { + bezier = new Bezier(pts.splice(0, i)); sliderLength -= bezier.pxlength; i = 0; l = pts.length; @@ -46,30 +46,33 @@ exports.getEndPoint = function (sliderType, sliderLength, points) { } bezier = new Bezier(pts); + return bezier.pointAtDistance(sliderLength); case 'P': - if (!points || points.length < 2) { return undefined; } - if (points.length == 2) { return pointOnLine(points[0], points[1], sliderLength); } - if (points.length > 3) { return exports.getEndPoint('B', sliderLength, points); } + if (!points || points.length < 2) { return undefined; } + if (points.length === 2) { return pointOnLine(points[0], points[1], sliderLength); } + if (points.length > 3) { return getEndPoint('B', sliderLength, points); } - var p1 = points[0]; - var p2 = points[1]; - var p3 = points[2]; + let p1 = points[0]; + let p2 = points[1]; + let p3 = points[2]; + + let circumCicle = getCircumCircle(p1, p2, p3); + let radians = sliderLength / circumCicle.radius; - var circumCicle = getCircumCircle(p1, p2, p3); - var radians = sliderLength / circumCicle.radius; if (isLeft(p1, p2, p3)) radians *= -1; return rotate(circumCicle.cx, circumCicle.cy, p1.x, p1.y, radians); } }; -function pointOnLine(p1, p2, length) { - var fullLength = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); - var n = fullLength - length; +function pointOnLine(p1, p2, length) +{ + let fullLength = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + let n = fullLength - length; - var x = (n * p1.x + length * p2.x) / fullLength; - var y = (n * p1.y + length * p2.y) / fullLength; + let x = (n * p1.x + length * p2.x) / fullLength; + let y = (n * p1.y + length * p2.y) / fullLength; return new Vector2(x, y); } @@ -83,9 +86,10 @@ function pointOnLine(p1, p2, length) { * @param {number} radians distance from the startpoint * @return {object} the new point coordinates after rotation */ -function rotate(cx, cy, x, y, radians) { - var cos = Math.cos(radians); - var sin = Math.sin(radians); +function rotate(cx, cy, x, y, radians) +{ + let cos = Math.cos(radians); + let sin = Math.sin(radians); return [ (cos * (x - cx)) - (sin * (y - cy)) + cx, @@ -100,8 +104,9 @@ function rotate(cx, cy, x, y, radians) { * @param {object} c the point we want to locate * @return {boolean} true if on left side */ -function isLeft(a, b, c) { - return ((b.x - a.x)*(c.y - a.y) - (b.y - a.y)*(c.x - a.x)) < 0; +function isLeft(a, b, c) +{ + return ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) < 0; } /** @@ -111,29 +116,32 @@ function isLeft(a, b, c) { * @param {object} p3 third point * @return {object} circumCircle */ -function getCircumCircle(p1, p2, p3) { - var x1 = p1.x; - var y1 = p1.y; +function getCircumCircle(p1, p2, p3) +{ + let x1 = p1.x; + let y1 = p1.y; - var x2 = p2.x; - var y2 = p2.y; + let x2 = p2.x; + let y2 = p2.y; - var x3 = p3.x; - var y3 = p3.y; + let x3 = p3.x; + let y3 = p3.y; //center of circle - var D = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + let D = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); - var Ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / D; - var Uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / D; + let Ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / D; + let Uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / D; - var px = Ux - x1; - var py = Uy - y1; - var r = Math.sqrt(px * px + py * py); + let px = Ux - x1; + let py = Uy - y1; + let r = Math.sqrt(px * px + py * py); return { cx: Ux, cy: Uy, radius: r }; -} \ No newline at end of file +} + +module.exports = {getEndPoint}; \ No newline at end of file From a2bf54257e9af6f244d626d11db88e98ef794eb4 Mon Sep 17 00:00:00 2001 From: Kionell Date: Fri, 25 Sep 2020 00:26:15 +0300 Subject: [PATCH 11/21] ES5 fixes in Curves --- src/Utils/Curves.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Utils/Curves.js b/src/Utils/Curves.js index 103e66c..3112516 100644 --- a/src/Utils/Curves.js +++ b/src/Utils/Curves.js @@ -56,7 +56,7 @@ function pointAtDistance(array, distance) ]; } - for (let i = 0; i <= array.length - 2; i++) { + for (var i = 0, len = array.length - 2; i <= len; ++i) { let x = (array[i][0] - array[i + 1][0]); let y = (array[i][1] - array[i + 1][1]); @@ -91,7 +91,7 @@ function Cpn(p, n) return 0; } - let p = Math.min(p, n - p); + p = Math.min(p, n - p); let out = 1; for (let i = 1; i < p + 1; i++) { From 04dcdefa6e326811c8ce816c1eaa4397a274b293 Mon Sep 17 00:00:00 2001 From: Kionell Date: Fri, 25 Sep 2020 00:38:02 +0300 Subject: [PATCH 12/21] Replace arrays with vectors in curves --- package.json | 2 +- src/Utils/Curves.js | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index d928f81..6bd9e33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "osu-bpdpc", - "version": "0.2.1", + "version": "0.2.2", "description": "Osu beatmap parser, difficulty and performance calculator", "main": "index.js", "scripts": { diff --git a/src/Utils/Curves.js b/src/Utils/Curves.js index 3112516..2af5057 100644 --- a/src/Utils/Curves.js +++ b/src/Utils/Curves.js @@ -1,5 +1,7 @@ 'use strict'; +const Vector2 = require('./Vector2'); + function distancePoints(p1, p2) { let x = (p1[0] - p2[0]); @@ -41,19 +43,11 @@ function pointAtDistance(array, distance) if (array.length < 2) return [0, 0, 0, 0]; if (distance === 0) { - let angle = angleFromPoints(array[0], array[1]); - return [array[0][0], array[0][1], angle, 0]; + return new Vector2(array[0][0], array[0][1]); } if (distanceFromPoints(array) <= distance) { - let angle = angleFromPoints(array[array.length - 2], array[array.length - 1]); - - return [ - array[array.length - 1][0], - array[array.length - 1][1], - angle, - array.length - 2 - ]; + return new Vector2(array[array.length - 1][0], array[array.length - 1][1]); } for (var i = 0, len = array.length - 2; i <= len; ++i) { @@ -82,7 +76,7 @@ function pointAtDistance(array, distance) coord = [(array[i][0] + cart[0]), (array[i][1] + cart[1])]; } - return [coord[0], coord[1], angle, i]; + return new Vector2(coord[0], coord[1]); } function Cpn(p, n) @@ -238,7 +232,7 @@ Bezier.prototype.pointAtDistance = Catmull.prototype.pointAtDistance = function return this.points[0]; default: this.calcPoints(); - return pointAtDistance(array_values(this.pos), dist).slice(0, 2); + return pointAtDistance(array_values(this.pos), dist); } }; From e552158f43f58ff57343186e6b4ca94469c43142 Mon Sep 17 00:00:00 2001 From: Kionell Date: Fri, 25 Sep 2020 00:47:29 +0300 Subject: [PATCH 13/21] Add end position to other objects --- src/Beatmap.js | 3 ++- src/Rulesets/Osu/Objects/Circle.js | 6 +++++- src/Rulesets/Osu/Objects/HitObject.js | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Beatmap.js b/src/Beatmap.js index 3a530df..ab8a8b2 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -274,7 +274,7 @@ class Beatmap { hitObject.curvePoints ); - if (endPoint && endPoint.x && endPoint.x) { + if (endPoint && endPoint.x && endPoint.y) { hitObject.endPos = endPoint; } else { // If endPosition could not be calculated, approximate it by setting it to the last point @@ -299,6 +299,7 @@ class Beatmap { if (hitType & HitType.Spinner) { hitObject = { ...hitObject, + endPos: pos, endTime: parseInt(args[0], 10) }; } diff --git a/src/Rulesets/Osu/Objects/Circle.js b/src/Rulesets/Osu/Objects/Circle.js index 34b192f..00c4d4c 100644 --- a/src/Rulesets/Osu/Objects/Circle.js +++ b/src/Rulesets/Osu/Objects/Circle.js @@ -2,7 +2,11 @@ const HitObject = require("./HitObject"); class Circle extends HitObject { constructor(hitObject) { - super({ ...hitObject, endTime: hitObject.startTime }); + super({ + ...hitObject, + endPos: hitObject.startPos, + endTime: hitObject.startTime + }); } toOsu() { diff --git a/src/Rulesets/Osu/Objects/HitObject.js b/src/Rulesets/Osu/Objects/HitObject.js index f52b655..764c5c2 100644 --- a/src/Rulesets/Osu/Objects/HitObject.js +++ b/src/Rulesets/Osu/Objects/HitObject.js @@ -3,6 +3,7 @@ const HitType = require("../../../Enum/HitType"); class HitObject { constructor({ pos, + endPos, startTime, endTime, hitType, @@ -11,6 +12,7 @@ class HitObject { combo = 1 }) { this.pos = pos; + this.endPos = endPos; this.startTime = startTime; this.endTime = endTime; this.hitType = hitType; From 5a9e5be27ab61d3d239f082790e5f72b6d942707 Mon Sep 17 00:00:00 2001 From: Kionell Date: Fri, 25 Sep 2020 00:50:52 +0300 Subject: [PATCH 14/21] Fix circle end position --- src/Rulesets/Osu/Objects/Circle.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rulesets/Osu/Objects/Circle.js b/src/Rulesets/Osu/Objects/Circle.js index 00c4d4c..70dad60 100644 --- a/src/Rulesets/Osu/Objects/Circle.js +++ b/src/Rulesets/Osu/Objects/Circle.js @@ -4,7 +4,7 @@ class Circle extends HitObject { constructor(hitObject) { super({ ...hitObject, - endPos: hitObject.startPos, + endPos: hitObject.pos, endTime: hitObject.startTime }); } From 0eedafbd7b22535796cdd34eca842aabef0602ca Mon Sep 17 00:00:00 2001 From: Kionell Date: Fri, 25 Sep 2020 00:57:54 +0300 Subject: [PATCH 15/21] Fix circle end position --- src/Beatmap.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Beatmap.js b/src/Beatmap.js index ab8a8b2..95f3d83 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -299,7 +299,7 @@ class Beatmap { if (hitType & HitType.Spinner) { hitObject = { ...hitObject, - endPos: pos, + endPos: hitObject.pos, endTime: parseInt(args[0], 10) }; } From 11259e026d75d83625e80a1dd1234a3a7edeeb28 Mon Sep 17 00:00:00 2001 From: Kionell Date: Sat, 26 Sep 2020 16:33:07 +0300 Subject: [PATCH 16/21] Return the old number of curve points --- src/Beatmap.js | 2 +- src/Utils/Curves.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Beatmap.js b/src/Beatmap.js index 95f3d83..7abfb1a 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -263,7 +263,7 @@ class Beatmap { hitObject = { ...hitObject, curveType: type, - curvePoints: [curvePoints[0], ...curvePoints], + curvePoints, repeat: parseInt(repeat, 10), pixelLength: parseInt(pixelLength, 10), }; diff --git a/src/Utils/Curves.js b/src/Utils/Curves.js index 2af5057..e4f0bd3 100644 --- a/src/Utils/Curves.js +++ b/src/Utils/Curves.js @@ -213,7 +213,7 @@ class Catmull calcPoints() { - if (this.pos.length) { + if (Object.keys(this.pos).length) { return; } From 3e9cc3e084e2a8ffebf2713912abd24f816837d1 Mon Sep 17 00:00:00 2001 From: Kionell Date: Sat, 26 Sep 2020 18:26:44 +0300 Subject: [PATCH 17/21] Remove negative beatLength from bpm calculation --- src/Beatmap.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Beatmap.js b/src/Beatmap.js index 7abfb1a..2aa6ff0 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -356,8 +356,10 @@ class Beatmap { let bpm = Math.round(60000 / tp.beatLength); - beatmap.General.MinBPM = Math.min(beatmap.General.MinBPM, bpm) || bpm; - beatmap.General.MaxBPM = Math.max(beatmap.General.MaxBPM, bpm) || bpm; + if (bpm > 0) { + beatmap.General.MinBPM = Math.min(beatmap.General.MinBPM, bpm) || bpm; + beatmap.General.MaxBPM = Math.max(beatmap.General.MaxBPM, bpm) || bpm; + } for (let hitObject of beatmap.HitObjects.filter( ho => ho.startTime >= tp.time From 00ce497d2c90f6755e0a9d06b96cf5320c50ec93 Mon Sep 17 00:00:00 2001 From: Kionell Date: Sat, 26 Sep 2020 21:30:38 +0300 Subject: [PATCH 18/21] Updated Vector2 declaration file --- src/Utils/Vector2.d.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Utils/Vector2.d.ts b/src/Utils/Vector2.d.ts index 81daa13..cc321a5 100644 --- a/src/Utils/Vector2.d.ts +++ b/src/Utils/Vector2.d.ts @@ -19,6 +19,11 @@ export default class Vector2 { */ public scale(multiplier: number): Vector2; + /** + * Divides the vector and returns a new instance. + */ + public divide(divisor: number): Vector2; + /** * Returns the length of the 2 points in the vector */ @@ -29,6 +34,11 @@ export default class Vector2 { */ public distance(vec: Vector2): number; + /** + * Returns normaliled vector + */ + public normalize(): Vector2; + /** * Clones the current vector and returns it * Kinda useless but ¯\_(ツ)_/¯ From 82dee69a033135124b6404cfb74942df94aa65ec Mon Sep 17 00:00:00 2001 From: Kionell Date: Sat, 26 Sep 2020 21:31:09 +0300 Subject: [PATCH 19/21] New functionality to the Vector2 --- src/Utils/Vector2.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Utils/Vector2.js b/src/Utils/Vector2.js index 69abd9b..7bed5a6 100644 --- a/src/Utils/Vector2.js +++ b/src/Utils/Vector2.js @@ -16,17 +16,29 @@ class Vector2 { return new Vector2(this.x * scale, this.y * scale); } + divide(scale) { + return new Vector2(this.x / scale, this.y / scale); + } + length() { - return Math.sqrt(this.x * this.x + (this.y + this.y)); + return Math.sqrt(this.x * this.x + this.y * this.y); } distance(vec) { let x = this.x - vec.x; let y = this.y - vec.y; + let dist = x * x + y * y; + return Math.sqrt(dist); } + normalize() { + let length = this.length(); + + return new Vector2(this.x / length, this.y / length); + } + clone() { return new Vector2(this.x, this.y); } From dbfa4c01a0d530848746060bde69aacd85a020c4 Mon Sep 17 00:00:00 2001 From: Kionell Date: Sun, 27 Sep 2020 17:01:24 +0300 Subject: [PATCH 20/21] New Vector2 functionality --- src/Utils/Vector2.d.ts | 20 ++++++++++++++++++++ src/Utils/Vector2.js | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Utils/Vector2.d.ts b/src/Utils/Vector2.d.ts index cc321a5..a4a782d 100644 --- a/src/Utils/Vector2.d.ts +++ b/src/Utils/Vector2.d.ts @@ -9,16 +9,31 @@ export default class Vector2 { */ public add(vec: Vector2): Vector2; + /** + * Adds a vector to current and returns a new instance with single precision + */ + public fadd(vec: Vector2): Vector2; + /** * Subtracts a vector to current and returns a new instance */ public subtract(vec: Vector2): Vector2; + /** + * Subtracts a vector to current and returns a new instance with single precision + */ + public fsubtract(vec: Vector2): Vector2; + /** * Scales the vector and returns a new instance */ public scale(multiplier: number): Vector2; + /** + * Returns a new instance with a dot product of a vector. + */ + public dot(vec: Vector2): Vector2; + /** * Divides the vector and returns a new instance. */ @@ -29,6 +44,11 @@ export default class Vector2 { */ public length(): number; + /** + * Returns the single precision length of the 2 points in the vector + */ + public flength(): number; + /** * Returns the distance between 2 vectors */ diff --git a/src/Utils/Vector2.js b/src/Utils/Vector2.js index 7bed5a6..654771a 100644 --- a/src/Utils/Vector2.js +++ b/src/Utils/Vector2.js @@ -8,14 +8,32 @@ class Vector2 { return new Vector2(this.x + vec.x, this.y + vec.y); } + fadd(vec) { + return new Vector2( + Math.fround(this.x) + Math.fround(vec.x), + Math.fround(this.y) + Math.fround(vec.y) + ); + } + subtract(vec) { return new Vector2(this.x - vec.x, this.y - vec.y); } + fsubtract(vec) { + return new Vector2( + Math.fround(this.x) - Math.fround(vec.x), + Math.fround(this.y) - Math.fround(vec.y) + ); + } + scale(scale) { return new Vector2(this.x * scale, this.y * scale); } + dot(vec) { + return new Vector2(this.x * vec.x, this.y * vec.y); + } + divide(scale) { return new Vector2(this.x / scale, this.y / scale); } @@ -24,6 +42,15 @@ class Vector2 { return Math.sqrt(this.x * this.x + this.y * this.y); } + flength() { + return Math.fround( + Math.sqrt( + Math.fround(this.x) * Math.fround(this.x) + + Math.fround(this.y) * Math.fround(this.y) + ) + ); + } + distance(vec) { let x = this.x - vec.x; let y = this.y - vec.y; From 5e36fa82f258e9828a7c800a51f21354da0ffdfa Mon Sep 17 00:00:00 2001 From: Kionell Date: Sun, 27 Sep 2020 17:03:02 +0300 Subject: [PATCH 21/21] Completely rewritten slider path algorithm --- src/Beatmap.js | 22 +- src/Utils/Curves.js | 239 --------------------- src/Utils/PathApproximator.js | 384 ++++++++++++++++++++++++++++++++++ src/Utils/SliderCalc.js | 147 ------------- src/Utils/SliderPath.js | 290 +++++++++++++++++++++++++ 5 files changed, 690 insertions(+), 392 deletions(-) delete mode 100644 src/Utils/Curves.js create mode 100644 src/Utils/PathApproximator.js delete mode 100644 src/Utils/SliderCalc.js create mode 100644 src/Utils/SliderPath.js diff --git a/src/Beatmap.js b/src/Beatmap.js index 2aa6ff0..a296348 100644 --- a/src/Beatmap.js +++ b/src/Beatmap.js @@ -3,7 +3,7 @@ const Colour = require("./Colour"); const Crunch = require("./Utils/OsuCruncher"); const HitType = require("./Enum/HitType"); const OsuHitObjectFactory = require("./Rulesets/Osu/HitObjectFactory"); -const SliderCalc = require("./Utils/SliderCalc"); +const {SliderPath, PathControlPoint} = require("./Utils/SliderPath"); class Beatmap { constructor() { @@ -268,14 +268,24 @@ class Beatmap { pixelLength: parseInt(pixelLength, 10), }; - let endPoint = SliderCalc.getEndPoint( - hitObject.curveType, - hitObject.pixelLength, - hitObject.curvePoints + hitObject.pathPoints = [new PathControlPoint( + new Vector2(0, 0), hitObject.curveType + )]; + + hitObject.curvePoints.forEach(x => { + let point = new PathControlPoint(x.subtract(hitObject.pos)); + + hitObject.pathPoints.push(point); + }); + + let sliderPath = new SliderPath( + hitObject.pathPoints, hitObject.pixelLength ); + + let endPoint = sliderPath.positionAt(1); if (endPoint && endPoint.x && endPoint.y) { - hitObject.endPos = endPoint; + hitObject.endPos = hitObject.pos.add(endPoint); } else { // If endPosition could not be calculated, approximate it by setting it to the last point hitObject.endPos = hitObject.curvePoints[hitObject.curvePoints.length - 1]; diff --git a/src/Utils/Curves.js b/src/Utils/Curves.js deleted file mode 100644 index e4f0bd3..0000000 --- a/src/Utils/Curves.js +++ /dev/null @@ -1,239 +0,0 @@ -'use strict'; - -const Vector2 = require('./Vector2'); - -function distancePoints(p1, p2) -{ - let x = (p1[0] - p2[0]); - let y = (p1[1] - p2[1]); - - return Math.sqrt(x * x + y * y); -} - -function distanceFromPoints(array) -{ - let distance = 0; - - for (let i = 1, len = array.length - 1; i <= len; ++i) { - distance += distancePoints(array[i], array[i - 1]); - } - - return distance; -} - -function angleFromPoints(p1, p2) -{ - return Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0])); -} - -function cartFromPol(r, teta) -{ - let x2 = (r * Math.cos(teta)); - let y2 = (r * Math.sin(teta)); - - return [x2, y2]; -} - -function pointAtDistance(array, distance) -{ - //needs a serious cleanup ! - let current_distance = 0; - let coord, angle, cart, new_distance; - - if (array.length < 2) return [0, 0, 0, 0]; - - if (distance === 0) { - return new Vector2(array[0][0], array[0][1]); - } - - if (distanceFromPoints(array) <= distance) { - return new Vector2(array[array.length - 1][0], array[array.length - 1][1]); - } - - for (var i = 0, len = array.length - 2; i <= len; ++i) { - let x = (array[i][0] - array[i + 1][0]); - let y = (array[i][1] - array[i + 1][1]); - - new_distance = (Math.sqrt(x * x + y * y)); - current_distance += new_distance; - - if (distance <= current_distance) break; - } - - current_distance -= new_distance; - - if (distance === current_distance) { - coord = [array[i][0], array[i][1]]; - angle = angleFromPoints(array[i], array[i + 1]); - } - else { - angle = angleFromPoints(array[i], array[i + 1]); - cart = cartFromPol((distance - current_distance), angle); - - if (array[i][0] > array[i + 1][0]) - coord = [(array[i][0] - cart[0]), (array[i][1] - cart[1])]; - else - coord = [(array[i][0] + cart[0]), (array[i][1] + cart[1])]; - } - - return new Vector2(coord[0], coord[1]); -} - -function Cpn(p, n) -{ - if (p < 0 || p > n) { - return 0; - } - - p = Math.min(p, n - p); - let out = 1; - - for (let i = 1; i < p + 1; i++) { - out = out * (n - p + i) / i; - } - - return out; -} - -function array_values(array) -{ - let out = []; - - for (let i in array) { - out.push(array[i]); - } - - return out; -} - -function array_calc(op, array1, array2) -{ - let min = Math.min(array1.length, array2.length); - let retour = []; - - for (let i = 0; i < min; ++i) { - retour.push(array1[i] + op * array2[i]); - } - - return retour; -} - -class Bezier -{ - constructor(points) - { - this.points = points; - this.order = points.length; - - this.step = 0.0025 / this.order; // x0.10 - this.pos = {}; - this.calcPoints(); - } - - at(t) - { - //B(t) = sum_(i=0)^n (i parmis n) (1-t)^(n-i) * t^i * P_i - if (typeof this.pos[t] !== "undefined") { - return this.pos[t]; - } - - let x = 0, y = 0; - let n = this.order - 1; - - for (let i = 0; i <= n; ++i) { - x += Cpn(i, n) * Math.pow((1 - t), (n - i)) - * Math.pow(t, i) * this.points[i].x; - - y += Cpn(i, n) * Math.pow((1 - t), (n - i)) - * Math.pow(t, i) * this.points[i].y; - } - - this.pos[t] = [x, y]; - - return [x, y]; - }; - - // Changed to approximate length - calcPoints() - { - if (Object.keys(this.pos).length) { - return; - } - - this.pxlength = 0; - - let prev = this.at(0); - let current; - - for (let i = 0; i < 1 + this.step; i += this.step) { - current = this.at(i); - this.pxlength += distancePoints(prev, current); - prev = current; - } - }; -}; - -class Catmull -{ - constructor(points) - { - this.points = points; - this.order = points.length; - - this.step = 0.025; - this.pos = []; - this.calcPoints(); - } - - at(x, t) - { - let v1 = x >= 1 ? this.points[x - 1] : this.points[x]; - let v2 = this.points[x]; - - let v3 = x + 1 < this.order - ? this.points[x + 1] - : array_calc('1', v2, array_calc('-1', v2, v1)); - - let v4 = x + 2 < this.order - ? this.points[x + 2] - : array_calc('1', v3, array_calc('-1', v3, v2)); - - let retour = [ - 0.5 * ((-v1.x + 3 * v2.x - 3 * v3.x + v4.x) * t ** 3 - + (2 * v1.x - 5 * v2.x + 4 * v3.x - v4.x) - * t * t + (-v1.x + v3.x) * t + 2 * v2.x), - - 0.5 * ((-v1.y + 3 * v2.y - 3 * v3.y + v4.y) * t ** 3 - + (2 * v1.y - 5 * v2.y + 4 * v3.y - v4.y) - * t * t + (-v1.y + v3.y) * t + 2 * v2.y) - ] - - return retour; - }; - - calcPoints() - { - if (Object.keys(this.pos).length) { - return; - } - - for (let i = 0, len1 = this.order - 1; i < len1; ++i) - for (let t = 0, len2 = 1 + this.step; t < len2; t += this.step) - this.pos.push(this.at(i, t)); - }; -}; - -Bezier.prototype.pointAtDistance = Catmull.prototype.pointAtDistance = function (dist) -{ - switch (this.order) { - case 0: - return false; - case 1: - return this.points[0]; - default: - this.calcPoints(); - return pointAtDistance(array_values(this.pos), dist); - } -}; - -module.exports = {Bezier, Catmull}; \ No newline at end of file diff --git a/src/Utils/PathApproximator.js b/src/Utils/PathApproximator.js new file mode 100644 index 0000000..aa10d21 --- /dev/null +++ b/src/Utils/PathApproximator.js @@ -0,0 +1,384 @@ +const Vector2 = require('./Vector2'); + +/** + * Helper methods to approximate a path by interpolating a sequence of control points. + */ +class PathApproximator +{ + static #bezier_tolerance = Math.fround(0.25); + static #circular_arc_tolerance = Math.fround(0.1); + + /** + * The amount of pieces to calculate for each control point quadruplet. + */ + static #catmull_detail = 50; + + /** + * Creates a piecewise-linear approximation of a bezier curve, by adaptively repeatedly subdividing + * the control points until their approximation error vanishes below a given threshold. + * @returns A list of vectors representing the piecewise-linear approximation. + */ + static approximateBezier(controlPoints) + { + let output = []; + let count = controlPoints.length; + + if (count === 0) { + return output; + } + + let subdivisionBuffer1 = []; + let subdivisionBuffer2 = []; + + let toFlatten = [controlPoints.slice()]; + let freeBuffers = []; + + let leftChild = subdivisionBuffer2; + + while (toFlatten.length > 0) { + let parent = toFlatten.pop(); + + if (PathApproximator.#bezierIsFlatEnough(parent)) { + // If the control points we currently operate on are sufficiently "flat", we use + // 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. + PathApproximator.#bezierApproximate(parent, output, subdivisionBuffer1, subdivisionBuffer2, count); + + freeBuffers.push(parent); + continue; + } + + // If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep + // subdividing the curve we are currently operating on. + let rightChild = freeBuffers.length > 0 ? freeBuffers.pop() : []; + + PathApproximator.#bezierSubdivide(parent, leftChild, rightChild, subdivisionBuffer1, count); + + // We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration. + for (let i = 0; i < count; ++i) { + parent[i] = leftChild[i]; + } + + toFlatten.push(rightChild); + toFlatten.push(parent); + } + + output.push(controlPoints[count - 1]); + + return output; + } + + /** + * Creates a piecewise-linear approximation of a Catmull-Rom spline. + * @returns A list of vectors representing the piecewise-linear approximation. + */ + static approximateCatmull(controlPoints) + { + let result = []; + let controlPointsLength = controlPoints.Length; + + for (let i = 0; i < controlPointsLength - 1; i++) { + let v1 = i > 0 ? controlPoints[i - 1] : controlPoints[i]; + let v2 = controlPoints[i]; + let v3 = i < controlPointsLength - 1 ? controlPoints[i + 1] : v2.add(v2).subtract(v1); + let v4 = i < controlPointsLength - 2 ? controlPoints[i + 2] : v3.add(v3).subtract(v2); + + for (let c = 0; c < PathApproximator.#catmull_detail; c++) { + result.push(PathApproximator.#catmullFindPoint(v1, v2, v3, v4, Math.fround(c) / PathApproximator.#catmull_detail)); + result.push(PathApproximator.#catmullFindPoint(v1, v2, v3, v4, Math.fround(c + 1) / PathApproximator.#catmull_detail)); + } + } + + return result; + } + + /** + * Creates a piecewise-linear approximation of a circular arc curve. + * @returns A list of vectors representing the piecewise-linear approximation. + */ + static approximateCircularArc(controlPoints) + { + let a = controlPoints[0]; + let b = controlPoints[1]; + let c = controlPoints[2]; + + let aSq = b.subtract(c).length() ** 2; + let bSq = a.subtract(c).length() ** 2; + let cSq = a.subtract(b).length() ** 2; + + // If we have a degenerate triangle where a side-length is almost zero, then give up and fall + // back to a more numerically stable method. + if (Math.abs(aSq - 0) < 0.003 || Math.abs(bSq - 0) < 0.003 || Math.abs(cSq - 0) < 0.003) { + return []; + } + + let s = Math.fround(aSq * (bSq + cSq - aSq)); + let t = Math.fround(bSq * (aSq + cSq - bSq)); + let u = Math.fround(cSq * (aSq + bSq - cSq)); + + let sum = Math.fround(s + t + u); + + // If we have a degenerate triangle with an almost-zero size, then give up and fall + // back to a more numerically stable method. + if (Math.abs(sum - 0) < 0.003) { + return []; + } + + let centre = a.scale(s).add(b.scale(t)).add(c.scale(u)).divide(sum); + let dA = a.subtract(centre); + let dC = c.subtract(centre); + + let r = dA.length(); + + let thetaStart = Math.atan2(dA.y, dA.x); + let thetaEnd = Math.atan2(dC.y, dC.x); + + while (thetaEnd < thetaStart) { + thetaEnd += 2 * Math.PI; + } + + let dir = 1; + let thetaRange = thetaEnd - thetaStart; + + // Decide in which direction to draw the circle, depending on which side of + // AC B lies. + let orthoAtoC = c.subtract(a); + + orthoAtoC = new Vector2(orthoAtoC.y, -orthoAtoC.x); + + if (orthoAtoC.dot(b.subtract(a)) < 0) { + dir = -dir; + thetaRange = 2 * Math.PI - thetaRange; + } + + // 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 + // is: 2 * Math.Acos(1 - TOLERANCE / r) + // The special case is required for extremely short sliders where the radius is smaller than + // the tolerance. This is a pathological rather than a realistic case. + let amountPoints = 2 * r <= PathApproximator.#circular_arc_tolerance ? 2 + : Math.max(2, Math.ceil(thetaRange / (2 * Math.acos(1 - PathApproximator.#circular_arc_tolerance / r)))); + + let output = []; + let fract, theta, o; + + for (let i = 0; i < amountPoints; ++i) { + fract = i / (amountPoints - 1); + theta = thetaStart + dir * fract * thetaRange; + + o = new Vector2(Math.fround(Math.cos(theta)), Math.fround(Math.sin(theta))).scale(r); + + output.push(centre.add(o)); + } + + return output; + } + + /** + * Creates a piecewise-linear approximation of a linear curve. + * Basically, returns the input. + * @returns A list of vectors representing the piecewise-linear approximation. + */ + static approximateLinear(controlPoints) + { + return controlPoints; + } + + /** + * Creates a piecewise-linear approximation of a lagrange polynomial. + * @returns A list of vectors representing the piecewise-linear approximation. + */ + static approximateLagrangePolynomial(controlPoints) + { + // TODO: add some smarter logic here, chebyshev nodes? + const num_steps = 51; + + let result = []; + + let weights = PathApproximator.#barycentricWeights(controlPoints); + + let minX = controlPoints[0].x; + let maxX = controlPoints[0].x; + + for (let i = 1, len = controlPoints.length; i < len; i++) { + minX = Math.min(minX, controlPoints[i].x); + maxX = Math.max(maxX, controlPoints[i].x); + } + + let dx = maxX - minX; + + for (let i = 0; i < num_steps; i++) { + let x = minX + dx / (num_steps - 1) * i; + let y = Math.fround(PathApproximator.#barycentricLagrange(controlPoints, weights, x)); + + result.push(new Vector2(x, y)); + } + + return result; + } + + /** + * Calculates the Barycentric weights for a Lagrange polynomial for a given set of coordinates. + * Can be used as a helper function to compute a Lagrange polynomial repeatedly. + * @param points An array of coordinates. No two x should be the same. + */ + static #barycentricWeights(points) + { + let n = points.length; + let w = []; + + for (let i = 0; i < n; i++) { + w[i] = 1; + + for (let j = 0; j < n; j++) { + if (i != j) { + w[i] *= points[i].x - points[j].x; + } + } + + w[i] = 1.0 / w[i]; + } + + return w; + } + + /** + * Calculates the Lagrange basis polynomial for a given set of x coordinates based on previously computed barycentric weights. + * @param points An array of coordinates. No two x should be the same. + * @param weights An array of precomputed barycentric weights. + * @param time The x coordinate to calculate the basis polynomial for. + */ + static #barycentricLagrange(points, weights, time) + { + if (points === null || points.Length === 0) { + throw new Error("points must contain at least one point"); + } + + if (points.Length !== weights.Length) { + throw new Error("points must contain exactly as many items as {nameof(weights)}"); + } + + let numerator = 0; + let denominator = 0; + + for (let i = 0, len = points.Length; i < len; i++) { + // while this is not great with branch prediction, it prevents NaN at control point X coordinates + if (time === points[i].x) { + return points[i].y; + } + + let li = weights[i] / (time - points[i].x); + + numerator += li * points[i].y; + denominator += li; + } + + return numerator / denominator; + } + + /** + * Make sure the 2nd order derivative (approximated using finite elements) is within tolerable bounds. + * NOTE: The 2nd order derivative of a 2d curve represents its curvature, so intuitively this function + * checks (as the name suggests) whether our approximation is _locally_ "flat". More curvy parts + * need to have a denser approximation to be more "flat". + * @param controlPoints The control points to check for flatness. + * @returns Whether the control points are flat enough. + */ + static #bezierIsFlatEnough(controlPoints) + { + let sub, sum, scale; + + for (let i = 1, len = controlPoints.length; i < len - 1; i++) { + scale = controlPoints[i].scale(2); + sub = controlPoints[i - 1].subtract(scale); + sum = sub.add(controlPoints[i + 1]); + + if (sum.length() ** 2 > PathApproximator.#bezier_tolerance ** 2 * 4) { + return false; + } + } + + return true; + } + + /** + * Subdivides n control points representing a bezier curve into 2 sets of n control points, each + * describing a bezier curve equivalent to a half of the original curve. Effectively this splits + * the original curve into 2 curves which result in the original curve when pieced back together. + * @param controlPoints The control points to split. + * @param l Output: The control points corresponding to the left half of the curve. + * @param r Output: The control points corresponding to the right half of the curve. + * @param subdivisionBuffer The first buffer containing the current subdivision state. + * @param count The number of control points in the original list. + */ + static #bezierSubdivide(controlPoints, l, r, subdivisionBuffer, count) + { + let midpoints = subdivisionBuffer; + + for (let i = 0; i < count; ++i) { + midpoints[i] = controlPoints[i]; + } + + for (let i = 0; i < count; ++i) { + l[i] = midpoints[0]; + r[count - i - 1] = midpoints[count - i - 1]; + + for (let j = 0; j < count - i - 1; j++) { + midpoints[j] = midpoints[j].add(midpoints[j + 1]).divide(2); + } + } + } + + /** + * This uses 3) { return getEndPoint('B', sliderLength, points); } - - let p1 = points[0]; - let p2 = points[1]; - let p3 = points[2]; - - let circumCicle = getCircumCircle(p1, p2, p3); - let radians = sliderLength / circumCicle.radius; - - if (isLeft(p1, p2, p3)) radians *= -1; - - return rotate(circumCicle.cx, circumCicle.cy, p1.x, p1.y, radians); - } -}; - -function pointOnLine(p1, p2, length) -{ - let fullLength = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); - let n = fullLength - length; - - let x = (n * p1.x + length * p2.x) / fullLength; - let y = (n * p1.y + length * p2.y) / fullLength; - - return new Vector2(x, y); -} - -/** - * Get coordinates of a point in a circle, given the center, a startpoint and a distance in radians - * @param {number} cx center x - * @param {number} cy center y - * @param {number} x startpoint x - * @param {number} y startpoint y - * @param {number} radians distance from the startpoint - * @return {object} the new point coordinates after rotation - */ -function rotate(cx, cy, x, y, radians) -{ - let cos = Math.cos(radians); - let sin = Math.sin(radians); - - return [ - (cos * (x - cx)) - (sin * (y - cy)) + cx, - (sin * (x - cx)) + (cos * (y - cy)) + cy - ]; -} - -/** - * Check if C is on left side of [AB] - * @param {object} a startpoint of the segment - * @param {object} b endpoint of the segment - * @param {object} c the point we want to locate - * @return {boolean} true if on left side - */ -function isLeft(a, b, c) -{ - return ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) < 0; -} - -/** - * Get circum circle of 3 points - * @param {object} p1 first point - * @param {object} p2 second point - * @param {object} p3 third point - * @return {object} circumCircle - */ -function getCircumCircle(p1, p2, p3) -{ - let x1 = p1.x; - let y1 = p1.y; - - let x2 = p2.x; - let y2 = p2.y; - - let x3 = p3.x; - let y3 = p3.y; - - //center of circle - let D = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); - - let Ux = ((x1 * x1 + y1 * y1) * (y2 - y3) + (x2 * x2 + y2 * y2) * (y3 - y1) + (x3 * x3 + y3 * y3) * (y1 - y2)) / D; - let Uy = ((x1 * x1 + y1 * y1) * (x3 - x2) + (x2 * x2 + y2 * y2) * (x1 - x3) + (x3 * x3 + y3 * y3) * (x2 - x1)) / D; - - let px = Ux - x1; - let py = Uy - y1; - let r = Math.sqrt(px * px + py * py); - - return { - cx: Ux, - cy: Uy, - radius: r - }; -} - -module.exports = {getEndPoint}; \ No newline at end of file diff --git a/src/Utils/SliderPath.js b/src/Utils/SliderPath.js new file mode 100644 index 0000000..678466a --- /dev/null +++ b/src/Utils/SliderPath.js @@ -0,0 +1,290 @@ +const PathApproximator = require('./PathApproximator'); +const Vector2 = require('./Vector2'); + +class SliderPath +{ + /** + * 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. + */ + expectedDistance; + + /** + * The control points of the path. + */ + controlPoints; + + calculatedPath; + cumulativeLength; + #pathCache; + + calculatedLength; + + constructor(controlPoints, expectedDistance = null) + { + this.controlPoints = controlPoints.slice(); + this.expectedDistance = expectedDistance; + } + + /** + * The distance of the path after lengthening/shortening to account for . + */ + get distance() + { + this.#ensureValid(); + + return this.cumulativeLength.length === 0 + ? 0 : this.cumulativeLength[this.cumulativeLength.length - 1]; + } + + /** + * The distance of the path prior to lengthening/shortening to account for . + */ + get calculatedDistance() + { + this.#ensureValid(); + + return this.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. + * @param path The list to be filled with the computed path. + * @param p0 Start progress. Ranges from 0 (beginning of the slider) to 1 (end of the slider). + * @param p1 End progress. Ranges from 0 (beginning of the slider) to 1 (end of the slider). + */ + getPathToProgress(path, p0, p1) + { + this.#ensureValid(); + + let d0 = this.#progressToDistance(p0); + let d1 = this.#progressToDistance(p1); + + let i = 0; + + while (i < this.calculatedPath.length && this.cumulativeLength[i++] < d0); + + path = [this.#interpolateVertices(i, d0)]; + + while (i < this.calculatedPath.length && this.cumulativeLength[i++] <= d1) { + path.push(this.calculatedPath[i]); + } + + path.push(this.#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). + * @param progress Ranges from 0 (beginning of the path) to 1 (end of the path). + */ + positionAt(progress) + { + this.#ensureValid(); + + let d = this.#progressToDistance(progress); + + return this.#interpolateVertices(this.#indexOfDistance(d), d); + } + + #ensureValid() + { + if (this.#pathCache) { + return; + } + + this.#calculatePath(); + this.#calculateLength(); + + this.#pathCache = true; + } + + #calculatePath() + { + let controlPointsLength = this.controlPoints.length; + + if (controlPointsLength === 0) { + return; + } + + this.calculatedPath = []; + + let vertices = []; + + for (let i = 0; i < controlPointsLength; i++) { + vertices[i] = this.controlPoints[i].pos; + } + + let start = 0; + + for (let i = 0; i < controlPointsLength; ++i) { + if (!this.controlPoints[i].type && i < controlPointsLength - 1) { + continue; + } + + // The current vertex ends the segment + let segmentVertices = vertices.slice(start, i + 1); + let segmentType = this.controlPoints[start].type || 'L'; + + for (let t of this.#calculateSubPath(segmentVertices, segmentType)) { + if (this.calculatedPath.length === 0 + || this.calculatedPath[this.calculatedPath.length - 1] != t) { + this.calculatedPath.push(t); + } + } + + // Start the new segment at the current vertex + start = i; + } + } + + #calculateSubPath(subControlPoints, type) + { + switch (type) { + case 'L': + return PathApproximator.approximateLinear(subControlPoints); + + case 'P': + if (subControlPoints.length !== 3) { + break; + } + + const 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.length === 0) { + break; + } + + return subpath; + + case 'C': + return PathApproximator.approximateCatmull(subControlPoints); + } + + return PathApproximator.approximateBezier(subControlPoints); + } + + #calculateLength() + { + this.calculatedLength = 0; + this.cumulativeLength = [0]; + + for (let i = 0, len = this.calculatedPath.length - 1; i < len; i++) { + let diff = this.calculatedPath[i + 1].subtract(this.calculatedPath[i]); + + this.calculatedLength += diff.length(); + this.cumulativeLength.push(this.calculatedLength); + } + + if (parseFloat(this.expectedDistance) && this.calculatedLength != this.expectedDistance) { + // The last length is always incorrect + this.cumulativeLength.pop(); + + let pathEndIndex = this.calculatedPath.length - 1; + + if (this.calculatedLength > this.expectedDistance) { + // The path will be shortened further, in which case we should trim any more unnecessary lengths and their associated path segments + while (this.cumulativeLength.length > 0 && + this.cumulativeLength[this.cumulativeLength.length - 1] >= this.expectedDistance) { + this.cumulativeLength.pop(); + this.calculatedPath.splice(pathEndIndex--, 1); + } + } + + if (pathEndIndex <= 0) { + // The expected distance is negative or zero + // TODO: Perhaps negative path lengths should be disallowed altogether + this.cumulativeLength.push(0); + return; + } + + // The direction of the segment to shorten or lengthen + let dir = this.calculatedPath[pathEndIndex] + .subtract(this.calculatedPath[pathEndIndex - 1]).normalize(); + + this.calculatedPath[pathEndIndex] = this.calculatedPath[pathEndIndex - 1] + .add(dir.scale(Math.fround(this.expectedDistance - this.cumulativeLength[this.cumulativeLength.length - 1]))); + + this.cumulativeLength.push(this.expectedDistance); + } + } + + #indexOfDistance(d) + { + let i = this.#binarySearch(this.cumulativeLength, d); + + if (i < 0) i = ~i; + + return i; + } + + #binarySearch(arr, x) + { + let start = 0, mid, end = arr.length - 1; + + // Iterate while start not meets end + while (start <= end) { + + // Find the mid index + mid = Math.floor((start + end) / 2); + + if (arr[mid] > x) { + end = mid - 1; + } + else if (arr[mid] <= x) { + start = mid + 1; + } + } + + return Math.floor((start + end) / 2); + } + + #progressToDistance(progress) + { + return Math.min(Math.max(progress, 0), 1) * this.distance; + } + + #interpolateVertices(i, d) + { + if (this.calculatedPath.length === 0) + return new Vector2(0, 0); + + if (i <= 0) { + return this.calculatedPath[0]; + } + + if (i >= this.calculatedPath.length) { + return this.calculatedPath[this.calculatedLength.length - 1]; + } + + let p0 = this.calculatedPath[i - 1]; + let p1 = this.calculatedPath[i]; + + let d0 = this.cumulativeLength[i - 1]; + let d1 = this.cumulativeLength[i]; + + // Avoid division by and almost-zero number in case two points are extremely close to each other. + if (Math.abs(d0 - d1) < 0.001) { + return p0; + } + + let w = (d - d0) / (d1 - d0); + + return p0.add(p1.subtract(p0).scale(Math.fround(w))); + } +} + +class PathControlPoint +{ + constructor(position, type) + { + this.pos = position; + this.type = type; + } +} + +module.exports = {SliderPath, PathControlPoint}; +