Skip to content

Commit

Permalink
fix(g.Curve): label jumping while being dragged along straight-line c…
Browse files Browse the repository at this point in the history
…urves (#2027)
  • Loading branch information
zbynekstara authored Feb 14, 2023
1 parent 78b63fc commit f9dd510
Show file tree
Hide file tree
Showing 2 changed files with 81 additions and 17 deletions.
59 changes: 42 additions & 17 deletions src/g/curve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -555,13 +555,12 @@ Curve.prototype = {
// Returns a list of curves whose flattened length is better than `opt.precision`.
// That is, observed difference in length between recursions is less than 10^(-3) = 0.001 = 0.1%
// (Observed difference is not real precision, but close enough as long as special cases are covered)
// (That is why skipping iteration 1 is important)
// As a rule of thumb, increasing `precision` by 1 requires two more division operations
// - Precision 0 (endpointDistance) - total of 2^0 - 1 = 0 operations (1 subdivision)
// - Precision 1 (<10% error) - total of 2^2 - 1 = 3 operations (4 subdivisions)
// - Precision 2 (<1% error) - total of 2^4 - 1 = 15 operations requires 4 division operations on all elements (15 operations total) (16 subdivisions)
// - Precision 3 (<0.1% error) - total of 2^6 - 1 = 63 operations - acceptable when drawing (64 subdivisions)
// - Precision 4 (<0.01% error) - total of 2^8 - 1 = 255 operations - high resolution, can be used to interpolate `t` (256 subdivisions)
// As a rule of thumb, increasing `precision` by 1 requires 2 more iterations (= levels of division operations)
// - Precision 0 (endpointDistance) - 0 iterations => total of 2^0 - 1 = 0 operations (1 subdivision)
// - Precision 1 (<10% error) - 2 iterations => total of 2^2 - 1 = 3 operations (4 subdivisions)
// - Precision 2 (<1% error) - 4 iterations => total of 2^4 - 1 = 15 operations requires 4 division operations on all elements (15 operations total) (16 subdivisions)
// - Precision 3 (<0.1% error) - 6 iterations => total of 2^6 - 1 = 63 operations - acceptable when drawing (64 subdivisions)
// - Precision 4 (<0.01% error) - 8 iterations => total of 2^8 - 1 = 255 operations - high resolution, can be used to interpolate `t` (256 subdivisions)
// (Variation of 1 recursion worse or better is possible depending on the curve, doubling/halving the number of operations accordingly)
getSubdivisions: function(opt) {

Expand All @@ -570,15 +569,41 @@ Curve.prototype = {
// not using opt.subdivisions
// not using localOpt

var subdivisions = [new Curve(this.start, this.controlPoint1, this.controlPoint2, this.end)];
var start = this.start;
var control1 = this.controlPoint1;
var control2 = this.controlPoint2;
var end = this.end;

var subdivisions = [new Curve(start, control1, control2, end)];
if (precision === 0) return subdivisions;

// special case #1: point-like curves
// - no need to calculate subdivisions, they would all be identical
var isPoint = !this.isDifferentiable();
if (isPoint) return subdivisions;

var previousLength = this.endpointDistance();

var precisionRatio = pow(10, -precision);

// special case #2: sine-like curves may have the same observed length in iteration 0 and 1 - skip iteration 1
// - not a problem for further iterations because cubic curves cannot have more than two local extrema
// - (i.e. cubic curves cannot intersect the baseline more than once)
// - therefore starting from iteration = 2 ensures that subsequent iterations do not produce sampling with equal length
// - (unless it's a straight-line curve, see below)
var minIterations = 2; // = 2*1

// special case #3: straight-line curves have the same observed length in all iterations
// - this causes observed precision ratio to always be 0 (= lower than `precisionRatio`, which is our exit condition)
// - we enforce the expected number of iterations = 2 * precision
var isLine = ((control1.cross(start, end) === 0) && (control2.cross(start, end) === 0));
if (isLine) {
minIterations = (2 * precision);
}

// recursively divide curve at `t = 0.5`
// until the difference between observed length at subsequent iterations is lower than precision
// until we reach `minIterations`
// and until the difference between observed length at subsequent iterations is lower than `precision`
var iteration = 0;
while (true) {
iteration += 1;
Expand All @@ -602,14 +627,14 @@ Curve.prototype = {
length += currentNewSubdivision.endpointDistance();
}

// check if we have reached required observed precision
// sine-like curves may have the same observed length in iteration 0 and 1 - skip iteration 1
// not a problem for further iterations because cubic curves cannot have more than two local extrema
// (i.e. cubic curves cannot intersect the baseline more than once)
// therefore two subsequent iterations cannot produce sampling with equal length
var observedPrecisionRatio = ((length !== 0) ? ((length - previousLength) / length) : 0);
if (iteration > 1 && observedPrecisionRatio < precisionRatio) {
return newSubdivisions;
// check if we have reached minimum number of iterations
if (iteration >= minIterations) {

// check if we have reached required observed precision
var observedPrecisionRatio = ((length !== 0) ? ((length - previousLength) / length) : 0);
if (observedPrecisionRatio < precisionRatio) {
return newSubdivisions;
}
}

// otherwise, set up for next iteration
Expand Down
39 changes: 39 additions & 0 deletions test/geometry/curve.js
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,45 @@ QUnit.module('curve', function() {
assert.equal(Array.isArray(curve.getSubdivisions({ precision: 5 })), true);
});

QUnit.test('special case #1 - point-like curves', function(assert) {

assert.expect(2);

var curve = new g.Curve('100 100', '100 100', '100 100', '100 100');
var curveSubdivisions = curve.getSubdivisions();
assert.equal(curveSubdivisions.length, 1); // shortcut code => 1 subdivision
assert.deepEqual(curveSubdivisions, [
new g.Curve(new g.Point(100, 100), new g.Point(100, 100), new g.Point(100, 100), new g.Point(100, 100))
]);
});

QUnit.test('special case #2 - sine-like curves', function(assert) {

assert.expect(3);

var curve = new g.Curve('0 0', '100 100', '200 -100', '300 0');
var curveSubdivisions = curve.getSubdivisions({ precision: 1 });
assert.equal(curveSubdivisions.length, 4); // iterations = minIterations = 2 iteration, so 2^(2) = 4 subdivisions
assert.equal(curveSubdivisions[0].end.x, 300/4);
assert.deepEqual(curveSubdivisions, [
new g.Curve(new g.Point(0, 0), new g.Point(25, 25), new g.Point(50, 31.25), new g.Point(75, 28.125)),
new g.Curve(new g.Point(75, 28.125), new g.Point(100, 25), new g.Point(125, 12.5), new g.Point(150, 0)),
new g.Curve(new g.Point(150, 0), new g.Point(175, -12.5), new g.Point(200, -25), new g.Point(225, -28.125)),
new g.Curve(new g.Point(225, -28.125), new g.Point(250, -31.25), new g.Point(275, -25), new g.Point(300, 0))
]);
});

QUnit.test('special case #3 - straight-line curves', function(assert) {

assert.expect(3);

var curve = new g.Curve('0 0', '100 0', '200 0', '300 0');
var curveSubdivisions = curve.getSubdivisions(); // using default precision = 3
assert.equal(curveSubdivisions.length, 64); // iterations = 2*precision = 2*3 = 6, so 2^(6) = 64 subdivisions
assert.equal(curveSubdivisions[0].end.x, 300/64);
assert.deepEqual(curveSubdivisions[0], new g.Curve(new g.Point(0, 0), new g.Point(1.5625, 0), new g.Point(3.125, 0), new g.Point(4.6875, 0)));
});

QUnit.test('returns an array with curve subdivisions up to precision', function(assert) {

var curve = new g.Curve('0 100', '50 200', '150 0', '200 100');
Expand Down

0 comments on commit f9dd510

Please sign in to comment.