diff --git a/package-lock.json b/package-lock.json index 0be3539198..1bf864de8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1867,6 +1868,7 @@ "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.9.0" } @@ -1952,6 +1954,7 @@ "integrity": "sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.1.11", "@vitest/mocker": "4.1.11", @@ -2358,6 +2361,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.25", "caniuse-lite": "^1.0.30001810", @@ -5608,6 +5612,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", @@ -7275,6 +7280,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7426,6 +7432,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7782,6 +7789,7 @@ "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", @@ -7873,6 +7881,7 @@ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", diff --git a/src/accessibility/outputs.js b/src/accessibility/outputs.js index cffcd19165..1888e16600 100644 --- a/src/accessibility/outputs.js +++ b/src/accessibility/outputs.js @@ -444,7 +444,7 @@ function outputs(p5, fn) { } let include = {}; let add = true; - let middle = _getMiddle(f, args); + let middle = this._getMiddle(f, args); if (f === 'line') { //make color stroke include.color = this.ingredients.colors.stroke; @@ -461,6 +461,20 @@ function outputs(p5, fn) { } else { include.pos = `from ${p1} to ${p2}`; } + } else if (f === 'bezier' || f === 'spline') { + // Curves use stroke color if no fill, otherwise fill color + if (this._renderer.states.fillColor) { + include.color = this.ingredients.colors.fill; + include.area = this._getArea(f, args); + } else { + include.color = this.ingredients.colors.stroke; + include.area = 0; + } + //get middle of shapes + //calculate position using middle of shape + include.pos = this._getPos(...middle); + //calculate location using middle of shape + include.loc = _canvasLocator(middle, this.width, this.height); } else { if (f === 'point') { //make color stroke @@ -499,8 +513,7 @@ function outputs(p5, fn) { } }; - //gets middle point / centroid of shape - function _getMiddle(f, args) { + fn._getMiddle = function (f, args) { let x, y; if ( f === 'rectangle' || @@ -520,11 +533,160 @@ function outputs(p5, fn) { } else if (f === 'line') { x = (args[0] + args[2]) / 2; y = (args[1] + args[3]) / 2; + } else if (f === 'bezier') { + const { points, isFilled } = _sampleBezier(args); + if (isFilled) { + [x, y] = _polygonCentroid(points); + } else { + let sumX = 0, sumY = 0; + for (const p of points) { sumX += p.x; sumY += p.y; } + x = sumX / points.length; + y = sumY / points.length; + } + } else if (f === 'spline') { + const { points, isFilled } = _sampleSpline(args, this); + if (isFilled) { + [x, y] = _polygonCentroid(points); + } else { + let sumX = 0, sumY = 0; + for (const p of points) { sumX += p.x; sumY += p.y; } + x = sumX / points.length; + y = sumY / points.length; + } } else { x = args[0]; y = args[1]; } return [x, y]; + }; + + function _sampleBezier(args) { + const samples = 20; + const points = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const invT = 1 - t; + const invT2 = invT * invT; + const invT3 = invT2 * invT; + const t2 = t * t; + const t3 = t2 * t; + const px = + invT3 * args[0] + + 3 * invT2 * t * args[2] + + 3 * invT * t2 * args[4] + + t3 * args[6]; + const py = + invT3 * args[1] + + 3 * invT2 * t * args[3] + + 3 * invT * t2 * args[5] + + t3 * args[7]; + points.push({ x: px, y: py }); + } + return { points, isFilled: false }; + } + + function _getSplineControlPoints(args, ends) { + const [x1, y1, x2, y2, x3, y3, x4, y4] = args; + const p0 = { x: x1, y: y1 }; + const p1 = { x: x2, y: y2 }; + const p2 = { x: x3, y: y3 }; + const p3 = { x: x4, y: y4 }; + const vertices = [p0, p1, p2, p3]; + + const points = []; + points.push(p0); + for (const v of vertices) points.push(v); + + if (ends === 'INCLUDE') { + points.unshift(p0); + points.push(p3); + } + return points; + } + + function _catmullRomToBezier(controlPoints, tightness) { + const s = 1 - tightness; + const bezArrays = []; + for (let i = 0; i + 3 < controlPoints.length; i++) { + const a = controlPoints[i]; + const b = controlPoints[i + 1]; + const c = controlPoints[i + 2]; + const d = controlPoints[i + 3]; + const bezB = { + x: b.x + (c.x - a.x) * s / 6, + y: b.y + (c.y - a.y) * s / 6 + }; + const bezC = { + x: c.x + (b.x - d.x) * s / 6, + y: c.y + (b.y - d.y) * s / 6 + }; + const bezD = c; + bezArrays.push([bezB, bezC, bezD]); + } + return bezArrays; + } + + function _evaluateCubicBezier(controls, t) { + const [a, b, c, d] = controls; + const invT = 1 - t; + const invT2 = invT * invT; + const invT3 = invT2 * invT; + const t2 = t * t; + const t3 = t2 * t; + return { + x: invT3 * a.x + 3 * invT2 * t * b.x + 3 * invT * t2 * c.x + t3 * d.x, + y: invT3 * a.y + 3 * invT2 * t * b.y + 3 * invT * t2 * c.y + t3 * d.y + }; + } + + function _sampleSpline(args, p5Instance) { + const ends = p5Instance._renderer.states.splineProperties.ends; + const tightness = p5Instance._renderer.states.splineProperties.tightness; + const controlPoints = _getSplineControlPoints(args, ends); + const bezierArrays = _catmullRomToBezier(controlPoints, tightness); + + const samplesPerSegment = 20; + const points = []; + let startVertex = controlPoints[ends === 'INCLUDE' ? 1 : 0]; + + for (const bezArr of bezierArrays) { + const bezierControls = [startVertex, ...bezArr]; + for (let i = 0; i <= samplesPerSegment; i++) { + const t = i / samplesPerSegment; + if (i === 0 && points.length > 0) continue; + points.push(_evaluateCubicBezier(bezierControls, t)); + } + startVertex = bezArr[2]; + } + + const isFilled = !!p5Instance._renderer.states.fillColor; + return { points, isFilled }; + } + + function _polygonCentroid(points) { + if (points.length < 3) { + let sumX = 0, sumY = 0; + for (const p of points) { sumX += p.x; sumY += p.y; } + return [sumX / points.length, sumY / points.length]; + } + let area = 0; + let cx = 0, cy = 0; + for (let i = 0; i < points.length; i++) { + const j = (i + 1) % points.length; + const cross = points[i].x * points[j].y - points[j].x * points[i].y; + area += cross; + cx += (points[i].x + points[j].x) * cross; + cy += (points[i].y + points[j].y) * cross; + } + area *= 0.5; + if (Math.abs(area) < 1e-10) { + let sumX = 0, sumY = 0; + for (const p of points) { sumX += p.x; sumY += p.y; } + return [sumX / points.length, sumY / points.length]; + } + cx /= (6 * area); + cy /= (6 * area); + return [cx, cy]; } //gets position of shape in the canvas @@ -636,6 +798,26 @@ function outputs(p5, fn) { shapeArgs[4] * (shapeArgs[1] - shapeArgs[3]) ) / 2; // (Ax( By − Cy) + Bx(Cy − Ay) + Cx(Ay − By ))/2 + } else if (objectType === 'bezier') { + const { points, isFilled } = _sampleBezier(shapeArgs); + if (isFilled) { + points.push({ x: points[0].x, y: points[0].y }); + } + let area = 0; + for (let i = 0; i < points.length - 1; i++) { + area += points[i].x * points[i + 1].y - points[i + 1].x * points[i].y; + } + objectArea = Math.abs(area) / 2; + } else if (objectType === 'spline') { + const { points, isFilled } = _sampleSpline(shapeArgs, this); + if (isFilled) { + points.push({ x: points[0].x, y: points[0].y }); + } + let area = 0; + for (let i = 0; i < points.length - 1; i++) { + area += points[i].x * points[i + 1].y - points[i + 1].x * points[i].y; + } + objectArea = Math.abs(area) / 2; } // Store the positions of the canvas corners const canvasWidth = this.width * this._renderer._pixelDensity; diff --git a/src/shape/curves.js b/src/shape/curves.js index 842d13ecdf..40ada352ad 100644 --- a/src/shape/curves.js +++ b/src/shape/curves.js @@ -199,6 +199,11 @@ function curves(p5, fn) { this._renderer.bezier(...args); + // Accessibility output + if (this._addAccsOutput()) { + this._accsOutput('bezier', args); + } + return this; }; @@ -686,6 +691,11 @@ function curves(p5, fn) { } this._renderer.spline(...args); + // Accessibility output + if (this._addAccsOutput()) { + this._accsOutput('spline', args); + } + return this; }; diff --git a/test/unit/accessibility/outputs.js b/test/unit/accessibility/outputs.js index e4edbd5ad8..59467b5283 100644 --- a/test/unit/accessibility/outputs.js +++ b/test/unit/accessibility/outputs.js @@ -189,6 +189,184 @@ suite('outputs', function () { }); }); }); + + test('should create text output for bezier()', function () { + return new Promise(function (resolve, reject) { + expected = + '
  • red bezier, at middle, covering 3% of the canvas.
  • '; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + p.textOutput(); + p.fill(255, 0, 0); + p.bezier(10, 10, 30, 5, 60, 80, 80, 90); + }; + p.draw = function () { + if (p.frameCount === 1) { + let actual = document.getElementById( + 'myCanvasIDtextOutput_list' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create text output for spline()', function () { + return new Promise(function (resolve, reject) { + expected = + '
  • blue spline, at top left, covering 9% of the canvas.
  • '; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + p.textOutput(); + p.fill(0, 0, 255); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + }; + p.draw = function () { + if (p.frameCount === 1) { + let actual = document.getElementById( + 'myCanvasIDtextOutput_list' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create text output for spline() with ends EXCLUDE', function () { + return new Promise(function (resolve, reject) { + expected = + '
  • blue spline, at top left, covering 9% of the canvas.
  • '; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + p.textOutput(); + p.splineProperty('ends', p.EXCLUDE); + p.fill(0, 0, 255); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + }; + p.draw = function () { + if (p.frameCount === 1) { + let actual = document.getElementById( + 'myCanvasIDtextOutput_list' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create text output for spline() with non-zero tightness', function () { + return new Promise(function (resolve, reject) { + expected = + '
  • blue spline, at top left, covering 7% of the canvas.
  • '; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + p.textOutput(); + p.splineProperty('tightness', 2); + p.fill(0, 0, 255); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + }; + p.draw = function () { + if (p.frameCount === 1) { + let actual = document.getElementById( + 'myCanvasIDtextOutput_list' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create text output for spline() with noFill (stroked)', function () { + return new Promise(function (resolve, reject) { + expected = + '
  • black spline, at top left, covering 0% of the canvas.
  • '; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + p.textOutput(); + p.noFill(); + p.stroke(0); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + }; + p.draw = function () { + if (p.frameCount === 1) { + let actual = document.getElementById( + 'myCanvasIDtextOutput_list' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create text output for bezier() with noFill (stroked)', function () { + return new Promise(function (resolve, reject) { + expected = + '
  • black bezier, at middle, covering 0% of the canvas.
  • '; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + p.textOutput(); + p.noFill(); + p.stroke(0); + p.bezier(10, 10, 30, 5, 60, 80, 80, 90); + }; + p.draw = function () { + if (p.frameCount === 1) { + let actual = document.getElementById( + 'myCanvasIDtextOutput_list' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); }); suite('p5.prototype.gridOutput', function () { @@ -341,5 +519,177 @@ suite('outputs', function () { }); }); }); + + test('should create text output for bezier()', function () { + return new Promise(function (resolve, reject) { + expected = 'red bezier, location = middle, area = 3 %'; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + }; + p.draw = function () { + p.gridOutput(); + p.fill(255, 0, 0); + p.bezier(10, 10, 30, 5, 60, 80, 80, 90); + if (p.frameCount === 2) { + let actual = document.getElementById( + 'myCanvasIDgridOutputshape0' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create text output for spline()', function () { + return new Promise(function (resolve, reject) { + expected = 'blue spline, location = top left, area = 9 %'; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + }; + p.draw = function () { + p.gridOutput(); + p.fill(0, 0, 255); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + if (p.frameCount === 2) { + let actual = document.getElementById( + 'myCanvasIDgridOutputshape0' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create grid output for spline() with ends EXCLUDE', function () { + return new Promise(function (resolve, reject) { + expected = 'blue spline, location = top left, area = 9 %'; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + }; + p.draw = function () { + p.gridOutput(); + p.splineProperty('ends', p.EXCLUDE); + p.fill(0, 0, 255); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + if (p.frameCount === 2) { + let actual = document.getElementById( + 'myCanvasIDgridOutputshape0' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create grid output for spline() with non-zero tightness', function () { + return new Promise(function (resolve, reject) { + expected = 'blue spline, location = top left, area = 7 %'; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + }; + p.draw = function () { + p.gridOutput(); + p.splineProperty('tightness', 2); + p.fill(0, 0, 255); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + if (p.frameCount === 2) { + let actual = document.getElementById( + 'myCanvasIDgridOutputshape0' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create grid output for spline() with noFill (stroked)', function () { + return new Promise(function (resolve, reject) { + expected = 'black spline, location = top left, area = 0 %'; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + }; + p.draw = function () { + p.gridOutput(); + p.noFill(); + p.stroke(0); + p.spline(10, 10, 30, 5, 60, 80, 80, 90); + if (p.frameCount === 2) { + let actual = document.getElementById( + 'myCanvasIDgridOutputshape0' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); + + test('should create grid output for bezier() with noFill (stroked)', function () { + return new Promise(function (resolve, reject) { + expected = 'black bezier, location = middle, area = 0 %'; + new p5(function (p) { + p.setup = function () { + let cnv = p.createCanvas(100, 100); + cnv.id('myCanvasID'); + }; + p.draw = function () { + p.gridOutput(); + p.noFill(); + p.stroke(0); + p.bezier(10, 10, 30, 5, 60, 80, 80, 90); + if (p.frameCount === 2) { + let actual = document.getElementById( + 'myCanvasIDgridOutputshape0' + ).innerHTML; + if (actual === expected) { + resolve(); + } else { + reject(' expected: ' + expected + ' ---> found: ' + actual); + } + p.remove(); + } + }; + }); + }); + }); }); -}); +}); \ No newline at end of file