From 97f6a5052fd0efc92e7559bf1664191d5c828800 Mon Sep 17 00:00:00 2001 From: Jason Davies Date: Thu, 10 Oct 2024 20:29:07 +0100 Subject: [PATCH 1/4] Detect coincident points when drawing arcs with rounding. When rounding is used, it's possible for `arc()` to generate empty arcs in the case where the start and end points are almost coincident, and become coincident after rounding is applied. This adds a check for coincident points after rounding is applied, and splits the arc into two if coincident points are detected. Fixes #38. --- src/path.js | 31 ++++++++++++++++++++++++++++++- test/pathRound-test.js | 25 ++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/path.js b/src/path.js index e0b35cb..4cd9266 100644 --- a/src/path.js +++ b/src/path.js @@ -23,12 +23,33 @@ function appendRound(digits) { }; } +function round(digits) { + const k = 10 ** digits; + return function(value) { + return Math.round(value * k) / k; + }; +} + +function equal(x0, y0, x1, y1) { + return x0 === x1 && y0 === y1; +} + +function equalRound(digits) { + let d = Math.floor(digits); + if (d > 15) return equal; + const r = round(digits); + return function(x0, y0, x1, y1) { + return r(x0) === r(x1) && r(y0) === r(y1); + }; +} + export class Path { constructor(digits) { this._x0 = this._y0 = // start of current subpath this._x1 = this._y1 = null; // end of current subpath this._ = ""; this._append = digits == null ? append : appendRound(digits); + this._equal = digits === null ? equal : equalRound(digits); } moveTo(x, y) { this._append`M${this._x0 = this._x1 = +x},${this._y0 = this._y1 = +y}`; @@ -133,7 +154,15 @@ export class Path { // Is this arc non-empty? Draw an arc! else if (da > epsilon) { - this._append`A${r},${r},0,${+(da >= pi)},${cw},${this._x1 = x + r * Math.cos(a1)},${this._y1 = y + r * Math.sin(a1)}`; + // If the start and end points are coincident after rounding, we need to draw two consecutive arcs. + const x1 = x + r * Math.cos(a1); + const y1 = y + r * Math.sin(a1); + if (da >= pi && this._equal(x0, y0, x1, y1)) { + da /= 2; + let a00 = a0 + da; + this._append`A${r},${r},0,${+(da >= pi)},${cw},${x + r * Math.cos(a00)},${y + r * Math.sin(a00)}`; + } + this._append`A${r},${r},0,${+(da >= pi)},${cw},${this._x1 = x1},${this._y1 = y1}`; } } rect(x, y, w, h) { diff --git a/test/pathRound-test.js b/test/pathRound-test.js index 6c82827..d714c69 100644 --- a/test/pathRound-test.js +++ b/test/pathRound-test.js @@ -50,6 +50,29 @@ it("pathRound.arc(x, y, r, a0, a1, ccw) limits the precision", () => { assert.strictEqual(p + "", precision(p0 + "", 1)); }); +it("pathRound.arc(x, y, r, a0, a1, false) draws two arcs for near-circular arcs with rounding", () => { + const p0 = path(), p = pathRound(1); + const a0 = -1.5707963267948966; + const a1 = 4.712383653719071; + const a00 = a0 + (a1 - a0) / 2; + p0.arc(0, 0, 75, a0, a00); + p0.arc(0, 0, 75, a00, a1); + p.arc(0, 0, 75, a0, a1); + assert.strictEqual(p + "", precision(p0 + "", 1)); +}); + +it("pathRound.arc(x, y, r, a0, a1, true) draws two arcs for near-circular arcs with rounding", () => { + const p0 = path(), p = pathRound(1); + const a0 = 0; + const a1 = a0 + 1e-5; + const da = 2 * Math.PI - 1e-5; + const a00 = a0 - da / 2; + p0.arc(0, 0, 75, a0, a00, true); + p0.arc(0, 0, 75, a00, a1, true); + p.arc(0, 0, 75, a0, a1, true); + assert.strictEqual(p + "", precision(p0 + "", 1)); +}); + it("pathRound.arcTo(x1, y1, x2, y2, r) limits the precision", () => { const p0 = path(), p = pathRound(1); p0.arcTo(10.0001, 10.0001, 123.456, 456.789, 12345.6789); @@ -79,5 +102,5 @@ it("pathRound.rect(x, y, w, h) limits the precision", () => { }); function precision(str, precision) { - return str.replace(/\d+\.\d+/g, s => +parseFloat(s).toFixed(precision)); + return str.replace(/-?\d+\.\d+(e-?\d+)?/g, s => +parseFloat(s).toFixed(precision)); } From dbde484d923855c7456a0dae60a848c911a5db3d Mon Sep 17 00:00:00 2001 From: Jason Davies Date: Wed, 16 Oct 2024 10:04:57 +0100 Subject: [PATCH 2/4] Add changes suggested by @Fil. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip unnecessary division for performance. Use `d` rather than `digits` to compute the power of 10. Co-authored-by: Philippe Rivière --- src/path.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/path.js b/src/path.js index 4cd9266..98015c9 100644 --- a/src/path.js +++ b/src/path.js @@ -23,26 +23,21 @@ function appendRound(digits) { }; } -function round(digits) { - const k = 10 ** digits; - return function(value) { - return Math.round(value * k) / k; - }; -} - function equal(x0, y0, x1, y1) { return x0 === x1 && y0 === y1; } function equalRound(digits) { - let d = Math.floor(digits); + const d = Math.floor(digits); if (d > 15) return equal; - const r = round(digits); + const k = 10 ** d; + const r = function(value) { + return Math.round(value * k); + }; return function(x0, y0, x1, y1) { return r(x0) === r(x1) && r(y0) === r(y1); }; } - export class Path { constructor(digits) { this._x0 = this._y0 = // start of current subpath From a452da6aaa3cbd282917c5704ac4dfc671ff408f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 16 Oct 2024 15:01:54 +0200 Subject: [PATCH 3/4] restore newline --- src/path.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/path.js b/src/path.js index 98015c9..4d65632 100644 --- a/src/path.js +++ b/src/path.js @@ -38,6 +38,7 @@ function equalRound(digits) { return r(x0) === r(x1) && r(y0) === r(y1); }; } + export class Path { constructor(digits) { this._x0 = this._y0 = // start of current subpath From e9311980cf5bbd690b648214db8e2082dc04a621 Mon Sep 17 00:00:00 2001 From: Jason Davies Date: Wed, 16 Oct 2024 14:47:35 +0100 Subject: [PATCH 4/4] Add suggestions from @mbostock: - Use pessimistic `!(d <= 15)` check in case of NaN. - Use `digits == null` check to match previous line. --- src/path.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/path.js b/src/path.js index 4d65632..960efde 100644 --- a/src/path.js +++ b/src/path.js @@ -29,7 +29,7 @@ function equal(x0, y0, x1, y1) { function equalRound(digits) { const d = Math.floor(digits); - if (d > 15) return equal; + if (!(d <= 15)) return equal; const k = 10 ** d; const r = function(value) { return Math.round(value * k); @@ -45,7 +45,7 @@ export class Path { this._x1 = this._y1 = null; // end of current subpath this._ = ""; this._append = digits == null ? append : appendRound(digits); - this._equal = digits === null ? equal : equalRound(digits); + this._equal = digits == null ? equal : equalRound(digits); } moveTo(x, y) { this._append`M${this._x0 = this._x1 = +x},${this._y0 = this._y1 = +y}`;