diff --git a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js index 15ee89b..95d8109 100644 --- a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js +++ b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js @@ -73,6 +73,7 @@ const lineColor = lineStyle.color;// data._lineTint; context.lineWidth = lineStyle.width; + context.lineJoin = lineStyle.lineJoin; if (data.type === SHAPES.POLY) { diff --git a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js index 15ee89b..95d8109 100644 --- a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js +++ b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js @@ -73,6 +73,7 @@ const lineColor = lineStyle.color;// data._lineTint; context.lineWidth = lineStyle.width; + context.lineJoin = lineStyle.lineJoin; if (data.type === SHAPES.POLY) { diff --git a/packages/constants/src/index.js b/packages/constants/src/index.js index 971ef53..21280d1 100644 --- a/packages/constants/src/index.js +++ b/packages/constants/src/index.js @@ -282,3 +282,22 @@ MEDIUM: 'mediump', HIGH: 'highp', }; + +/** + * Determines the shape used to join two line segments where they meet when drawn with use of {@link PIXI.Graphics}. + * + * IMPORTANT - The WebGL renderer only (at least for now) + * + * @static + * @constant + * @name LINE_JOIN + * @memberof PIXI + * @property {string} MITER - connected segments are joined by extending their outside edges to connect at a single point + * @property {string} BEVEL - fills an additional triangular area between the common endpoint of connected segments + * @property {string} ROUND - rounds off the corners of a shape by filling an additional sector of disc + */ +export const LINE_JOIN = { + MITER: 'miter', + BEVEL: 'bevel', + ROUND: 'round', +}; diff --git a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js index 15ee89b..95d8109 100644 --- a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js +++ b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js @@ -73,6 +73,7 @@ const lineColor = lineStyle.color;// data._lineTint; context.lineWidth = lineStyle.width; + context.lineJoin = lineStyle.lineJoin; if (data.type === SHAPES.POLY) { diff --git a/packages/constants/src/index.js b/packages/constants/src/index.js index 971ef53..21280d1 100644 --- a/packages/constants/src/index.js +++ b/packages/constants/src/index.js @@ -282,3 +282,22 @@ MEDIUM: 'mediump', HIGH: 'highp', }; + +/** + * Determines the shape used to join two line segments where they meet when drawn with use of {@link PIXI.Graphics}. + * + * IMPORTANT - The WebGL renderer only (at least for now) + * + * @static + * @constant + * @name LINE_JOIN + * @memberof PIXI + * @property {string} MITER - connected segments are joined by extending their outside edges to connect at a single point + * @property {string} BEVEL - fills an additional triangular area between the common endpoint of connected segments + * @property {string} ROUND - rounds off the corners of a shape by filling an additional sector of disc + */ +export const LINE_JOIN = { + MITER: 'miter', + BEVEL: 'bevel', + ROUND: 'round', +}; diff --git a/packages/graphics/src/Graphics.js b/packages/graphics/src/Graphics.js index 638276d..abe1bbd 100644 --- a/packages/graphics/src/Graphics.js +++ b/packages/graphics/src/Graphics.js @@ -23,6 +23,7 @@ import Star from './utils/Star'; import { BLEND_MODES } from '@pixi/constants'; import { Container } from '@pixi/display'; +import { LINE_JOIN } from '../../constants/src'; const temp = new Float32Array(3); @@ -243,11 +244,12 @@ * @param {number} [alpha=1] - alpha of the line to draw, will update the objects stored style * @param {number} [alignment=1] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ - lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false) + lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { - this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native); + this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native, lineJoin); return this; } @@ -262,10 +264,11 @@ * @param {PIXI.Matrix} [matrix=null] Texture matrix to transform texture * @param {number} [alignment=0.5] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ lineTextureStyle(width = 0, texture = Texture.WHITE, color = 0xFFFFFF, alpha = 1, - matrix = null, alignment = 0.5, native = false) + matrix = null, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { if (this.currentPath) { @@ -295,6 +298,7 @@ alignment, native, visible, + lineJoin, }); } diff --git a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js index 15ee89b..95d8109 100644 --- a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js +++ b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js @@ -73,6 +73,7 @@ const lineColor = lineStyle.color;// data._lineTint; context.lineWidth = lineStyle.width; + context.lineJoin = lineStyle.lineJoin; if (data.type === SHAPES.POLY) { diff --git a/packages/constants/src/index.js b/packages/constants/src/index.js index 971ef53..21280d1 100644 --- a/packages/constants/src/index.js +++ b/packages/constants/src/index.js @@ -282,3 +282,22 @@ MEDIUM: 'mediump', HIGH: 'highp', }; + +/** + * Determines the shape used to join two line segments where they meet when drawn with use of {@link PIXI.Graphics}. + * + * IMPORTANT - The WebGL renderer only (at least for now) + * + * @static + * @constant + * @name LINE_JOIN + * @memberof PIXI + * @property {string} MITER - connected segments are joined by extending their outside edges to connect at a single point + * @property {string} BEVEL - fills an additional triangular area between the common endpoint of connected segments + * @property {string} ROUND - rounds off the corners of a shape by filling an additional sector of disc + */ +export const LINE_JOIN = { + MITER: 'miter', + BEVEL: 'bevel', + ROUND: 'round', +}; diff --git a/packages/graphics/src/Graphics.js b/packages/graphics/src/Graphics.js index 638276d..abe1bbd 100644 --- a/packages/graphics/src/Graphics.js +++ b/packages/graphics/src/Graphics.js @@ -23,6 +23,7 @@ import Star from './utils/Star'; import { BLEND_MODES } from '@pixi/constants'; import { Container } from '@pixi/display'; +import { LINE_JOIN } from '../../constants/src'; const temp = new Float32Array(3); @@ -243,11 +244,12 @@ * @param {number} [alpha=1] - alpha of the line to draw, will update the objects stored style * @param {number} [alignment=1] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ - lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false) + lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { - this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native); + this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native, lineJoin); return this; } @@ -262,10 +264,11 @@ * @param {PIXI.Matrix} [matrix=null] Texture matrix to transform texture * @param {number} [alignment=0.5] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ lineTextureStyle(width = 0, texture = Texture.WHITE, color = 0xFFFFFF, alpha = 1, - matrix = null, alignment = 0.5, native = false) + matrix = null, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { if (this.currentPath) { @@ -295,6 +298,7 @@ alignment, native, visible, + lineJoin, }); } diff --git a/packages/graphics/src/styles/LineStyle.js b/packages/graphics/src/styles/LineStyle.js index b746069..21f089f 100644 --- a/packages/graphics/src/styles/LineStyle.js +++ b/packages/graphics/src/styles/LineStyle.js @@ -1,4 +1,5 @@ import FillStyle from './FillStyle'; +import { LINE_JOIN } from '@pixi/constants'; /** * Represents the line style for Graphics. @@ -25,6 +26,7 @@ obj.width = this.width; obj.alignment = this.alignment; obj.native = this.native; + obj.lineJoin = this.lineJoin; return obj; } @@ -61,5 +63,13 @@ * @default false */ this.native = false; + + /** + * Shape used to join two line segments where they meet. + * + * @member {string} + * @default 'miter' + */ + this.lineJoin = LINE_JOIN.MITER; } } diff --git a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js index 15ee89b..95d8109 100644 --- a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js +++ b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js @@ -73,6 +73,7 @@ const lineColor = lineStyle.color;// data._lineTint; context.lineWidth = lineStyle.width; + context.lineJoin = lineStyle.lineJoin; if (data.type === SHAPES.POLY) { diff --git a/packages/constants/src/index.js b/packages/constants/src/index.js index 971ef53..21280d1 100644 --- a/packages/constants/src/index.js +++ b/packages/constants/src/index.js @@ -282,3 +282,22 @@ MEDIUM: 'mediump', HIGH: 'highp', }; + +/** + * Determines the shape used to join two line segments where they meet when drawn with use of {@link PIXI.Graphics}. + * + * IMPORTANT - The WebGL renderer only (at least for now) + * + * @static + * @constant + * @name LINE_JOIN + * @memberof PIXI + * @property {string} MITER - connected segments are joined by extending their outside edges to connect at a single point + * @property {string} BEVEL - fills an additional triangular area between the common endpoint of connected segments + * @property {string} ROUND - rounds off the corners of a shape by filling an additional sector of disc + */ +export const LINE_JOIN = { + MITER: 'miter', + BEVEL: 'bevel', + ROUND: 'round', +}; diff --git a/packages/graphics/src/Graphics.js b/packages/graphics/src/Graphics.js index 638276d..abe1bbd 100644 --- a/packages/graphics/src/Graphics.js +++ b/packages/graphics/src/Graphics.js @@ -23,6 +23,7 @@ import Star from './utils/Star'; import { BLEND_MODES } from '@pixi/constants'; import { Container } from '@pixi/display'; +import { LINE_JOIN } from '../../constants/src'; const temp = new Float32Array(3); @@ -243,11 +244,12 @@ * @param {number} [alpha=1] - alpha of the line to draw, will update the objects stored style * @param {number} [alignment=1] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ - lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false) + lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { - this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native); + this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native, lineJoin); return this; } @@ -262,10 +264,11 @@ * @param {PIXI.Matrix} [matrix=null] Texture matrix to transform texture * @param {number} [alignment=0.5] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ lineTextureStyle(width = 0, texture = Texture.WHITE, color = 0xFFFFFF, alpha = 1, - matrix = null, alignment = 0.5, native = false) + matrix = null, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { if (this.currentPath) { @@ -295,6 +298,7 @@ alignment, native, visible, + lineJoin, }); } diff --git a/packages/graphics/src/styles/LineStyle.js b/packages/graphics/src/styles/LineStyle.js index b746069..21f089f 100644 --- a/packages/graphics/src/styles/LineStyle.js +++ b/packages/graphics/src/styles/LineStyle.js @@ -1,4 +1,5 @@ import FillStyle from './FillStyle'; +import { LINE_JOIN } from '@pixi/constants'; /** * Represents the line style for Graphics. @@ -25,6 +26,7 @@ obj.width = this.width; obj.alignment = this.alignment; obj.native = this.native; + obj.lineJoin = this.lineJoin; return obj; } @@ -61,5 +63,13 @@ * @default false */ this.native = false; + + /** + * Shape used to join two line segments where they meet. + * + * @member {string} + * @default 'miter' + */ + this.lineJoin = LINE_JOIN.MITER; } } diff --git a/packages/graphics/src/utils/buildLine.js b/packages/graphics/src/utils/buildLine.js index 5ae3117..c1e0b2d 100644 --- a/packages/graphics/src/utils/buildLine.js +++ b/packages/graphics/src/utils/buildLine.js @@ -1,4 +1,9 @@ -import { Point } from '@pixi/math'; +import { Point, PI_2 } from '@pixi/math'; +import { LINE_JOIN } from '@pixi/constants'; + +const TOLERANCE = 0.0001; +const PI_LBOUND = Math.PI - TOLERANCE; +const PI_UBOUND = Math.PI + TOLERANCE; /** * Builds a line to draw @@ -96,8 +101,14 @@ let perpy = p1x - p2x; let perp2x = 0; let perp2y = 0; - let perp3x = 0; - let perp3y = 0; + + let midx = 0; + let midy = 0; + + let dist12 = 0; + let dist23 = 0; + let distMid = 0; + let minDist = 0; let dist = Math.sqrt((perpx * perpx) + (perpy * perpy)); @@ -133,73 +144,158 @@ perpx = -(p1y - p2y); perpy = p1x - p2x; - dist = Math.sqrt((perpx * perpx) + (perpy * perpy)); + perp2x = -(p2y - p3y); + perp2y = p2x - p3x; + + dist = len(perpx, perpy); perpx /= dist; perpy /= dist; perpx *= width; perpy *= width; - perp2x = -(p2y - p3y); - perp2y = p2x - p3x; - - dist = Math.sqrt((perp2x * perp2x) + (perp2y * perp2y)); + dist = len(perp2x, perp2y); perp2x /= dist; perp2y /= dist; perp2x *= width; perp2y *= width; - const a1 = (-perpy + p1y) - (-perpy + p2y); - const b1 = (-perpx + p2x) - (-perpx + p1x); - const c1 = ((-perpx + p1x) * (-perpy + p2y)) - ((-perpx + p2x) * (-perpy + p1y)); - const a2 = (-perp2y + p3y) - (-perp2y + p2y); - const b2 = (-perp2x + p2x) - (-perp2x + p3x); - const c2 = ((-perp2x + p3x) * (-perp2y + p2y)) - ((-perp2x + p2x) * (-perp2y + p3y)); + const a1 = p1y - p2y; + const b1 = p2x - p1x; + const a2 = p3y - p2y; + const b2 = p2x - p3x; - let denom = (a1 * b2) - (a2 * b1); + const denom = (a1 * b2) - (a2 * b1); + const join = style.lineJoin; - if (Math.abs(denom) < 0.1) + let px; + let py; + let pdist; + + // parallel or almost parallel ~0 or ~180 deg + if (Math.abs(denom) < TOLERANCE) { - denom += 10.1; - verts.push( - p2x - (perpx * r1), - p2y - (perpy * r1)); + // bevel, miter or round ~0deg + if (join !== LINE_JOIN.ROUND || Math.abs(angleDiff(perpx, perpy, perp2x, perp2y)) < TOLERANCE) + { + verts.push( + p2x - (perpx * r1), + p2y - (perpy * r1) + ); - verts.push( - p2x + (perpx * r2), - p2y + (perpy * r2)); + verts.push( + p2x + (perpx * r2), + p2y + (perpy * r2) + ); - continue; - } - - const px = ((b1 * c2) - (b2 * c1)) / denom; - const py = ((a2 * c1) - (a1 * c2)) / denom; - const pdist = ((px - p2x) * (px - p2x)) + ((py - p2y) * (py - p2y)); - - if (pdist > (196 * width * width)) - { - perp3x = perpx - perp2x; - perp3y = perpy - perp2y; - - dist = Math.sqrt((perp3x * perp3x) + (perp3y * perp3y)); - perp3x /= dist; - perp3y /= dist; - perp3x *= width; - perp3y *= width; - - verts.push(p2x - (perp3x * r1), p2y - (perp3y * r1)); - - verts.push(p2x + (perp3x * r2), p2y + (perp3y * r2)); - - verts.push(p2x - (perp3x * r2 * r1), p2y - (perp3y * r1)); - - indexCount++; + continue; + } + else // round ~180deg + { + px = p2x; + py = p2y; + pdist = 0; + } } else { + const c1 = ((-perpx + p1x) * (-perpy + p2y)) - ((-perpx + p2x) * (-perpy + p1y)); + const c2 = ((-perp2x + p3x) * (-perp2y + p2y)) - ((-perp2x + p2x) * (-perp2y + p3y)); + + px = ((b1 * c2) - (b2 * c1)) / denom; + py = ((a2 * c1) - (a1 * c2)) / denom; + pdist = ((px - p2x) * (px - p2x)) + ((py - p2y) * (py - p2y)); + } + + // funky comparison to have backwards compat which will fall back by default to miter + // TODO: introduce miterLimit + if (join !== LINE_JOIN.BEVEL && join !== LINE_JOIN.ROUND && pdist <= (196 * width * width)) + { verts.push(p2x + ((px - p2x) * r1), p2y + ((py - p2y) * r1)); verts.push(p2x - ((px - p2x) * r2), p2y - ((py - p2y) * r2)); } + else + { + const flip = shouldFlip(p1x, p1y, p2x, p2y, p3x, p3y); + + dist12 = len(p2x - p1x, p2y - p1y); + dist23 = len(p3x - p2x, p3y - p2y); + minDist = Math.min(dist12, dist23); + + if (flip) + { + perpx = -perpx; + perpy = -perpy; + perp2x = -perp2x; + perp2y = -perp2y; + + midx = (px - p2x) * r1; + midy = (py - p2y) * r1; + distMid = len(midx, midy); + + if (minDist < distMid) + { + midx /= distMid; + midy /= distMid; + midx *= minDist; + midy *= minDist; + } + + midx = p2x - midx; + midy = p2y - midy; + } + else + { + midx = (px - p2x) * r2; + midy = (py - p2y) * r2; + distMid = len(midx, midy); + + if (minDist < distMid) + { + midx /= distMid; + midy /= distMid; + midx *= minDist; + midy *= minDist; + } + + midx += p2x; + midy += p2y; + } + + if (join === LINE_JOIN.ROUND) + { + const rad = flip ? r1 : r2; + + // eslint-disable-next-line max-params + indexCount += buildRoundCap(midx, midy, + p2x + (perpx * rad), p2y + (perpy * rad), + p2x + (perp2x * rad), p2y + (perp2y * rad), + p3x, p3y, + verts, + flip); + } + else if (join === LINE_JOIN.BEVEL || pdist > (196 * width * width)) // TODO: introduce miterLimit + { + if (flip) + { + verts.push(p2x + (perpx * r2), p2y + (perpy * r2)); + verts.push(midx, midy); + + verts.push(p2x + (perp2x * r2), p2y + (perp2y * r2)); + verts.push(midx, midy); + } + else + { + verts.push(midx, midy); + verts.push(p2x + (perpx * r1), p2y + (perpy * r1)); + + verts.push(midx, midy); + verts.push(p2x + (perp2x * r1), p2y + (perp2y * r1)); + } + + indexCount += 2; + } + } } p1x = points[(length - 2) * 2]; @@ -233,6 +329,136 @@ } } +function len(x, y) +{ + return Math.sqrt((x * x) + (y * y)); +} + +/** + * Check turn direction. If counterclockwise, we must invert prep vectors, otherwise they point 'inwards' the angle, + * resulting in funky looking lines. + * + * @ignore + * @private + * @param {number} p0x - x of 1st point + * @param {number} p0y - y of 1st point + * @param {number} p1x - x of 2nd point + * @param {number} p1y - y of 2nd point + * @param {number} p2x - x of 3rd point + * @param {number} p2y - y of 3rd point + * + * @returns {boolean} true if perpendicular vectors should be flipped, otherwise false + */ +function shouldFlip(p0x, p0y, p1x, p1y, p2x, p2y) +{ + return ((p1x - p0x) * (p2y - p0y)) - ((p2x - p0x) * (p1y - p0y)) < 0; +} + +function angleDiff(p0x, p0y, p1x, p1y) +{ + const angle1 = Math.atan2(p0x, p0y); + const angle2 = Math.atan2(p1x, p1y); + + if (angle2 > angle1) + { + if ((angle2 - angle1) >= PI_LBOUND) + { + return angle2 - PI_2 - angle1; + } + } + else if ((angle1 - angle2) >= PI_LBOUND) + { + return angle2 - (angle1 - PI_2); + } + + return angle2 - angle1; +} + +// eslint-disable-next-line max-params +function buildRoundCap(cx, cy, p1x, p1y, p2x, p2y, nxtPx, nxtPy, verts, flipped) +{ + const cx2p0x = p1x - cx; + const cy2p0y = p1y - cy; + + let angle0 = Math.atan2(cx2p0x, cy2p0y); + let angle1 = Math.atan2(p2x - cx, p2y - cy); + + let startAngle = angle0; + + if (angle1 > angle0) + { + if ((angle1 - angle0) >= PI_LBOUND) + { + angle1 = angle1 - PI_2; + } + } + else if ((angle0 - angle1) >= PI_LBOUND) + { + angle0 = angle0 - PI_2; + } + + let angleDiff = angle1 - angle0; + const absAngleDiff = Math.abs(angleDiff); + + if (absAngleDiff >= PI_LBOUND && absAngleDiff <= PI_UBOUND) + { + const r1x = cx - nxtPx; + const r1y = cy - nxtPy; + + if (r1x === 0) + { + if (r1y > 0) + { + angleDiff = -angleDiff; + } + } + else if (r1x >= -TOLERANCE) + { + angleDiff = -angleDiff; + } + } + + const radius = len(cx2p0x, cy2p0y); + const segCount = ((15 * absAngleDiff * Math.sqrt(radius) / Math.PI) >> 0) + 1; + const angleInc = angleDiff / segCount; + + startAngle += angleInc; + + if (flipped) + { + verts.push(p1x, p1y); + verts.push(cx, cy); + + for (let i = 1, angle = startAngle; i < segCount; i++, angle += angleInc) + { + verts.push(cx + ((Math.sin(angle) * radius)), + cy + ((Math.cos(angle) * radius))); + verts.push(cx, cy); + } + + verts.push(p2x, p2y); + verts.push(cx, cy); + } + else + { + verts.push(cx, cy); + verts.push(p1x, p1y); + + for (let i = 1, angle = startAngle; i < segCount; i++, angle += angleInc) + { + verts.push(cx, cy); + verts.push(cx + ((Math.sin(angle) * radius)), + cy + ((Math.cos(angle) * radius))); + } + + verts.push(cx, cy); + verts.push(cx + ((Math.sin(angle1) * radius)), + cy + ((Math.cos(angle1) * radius))); + } + + return segCount * 2; +} + /** * Builds a line to draw using the gl.drawArrays(gl.LINES) method * diff --git a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js index 15ee89b..95d8109 100644 --- a/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js +++ b/packages/canvas/canvas-graphics/src/CanvasGraphicsRenderer.js @@ -73,6 +73,7 @@ const lineColor = lineStyle.color;// data._lineTint; context.lineWidth = lineStyle.width; + context.lineJoin = lineStyle.lineJoin; if (data.type === SHAPES.POLY) { diff --git a/packages/constants/src/index.js b/packages/constants/src/index.js index 971ef53..21280d1 100644 --- a/packages/constants/src/index.js +++ b/packages/constants/src/index.js @@ -282,3 +282,22 @@ MEDIUM: 'mediump', HIGH: 'highp', }; + +/** + * Determines the shape used to join two line segments where they meet when drawn with use of {@link PIXI.Graphics}. + * + * IMPORTANT - The WebGL renderer only (at least for now) + * + * @static + * @constant + * @name LINE_JOIN + * @memberof PIXI + * @property {string} MITER - connected segments are joined by extending their outside edges to connect at a single point + * @property {string} BEVEL - fills an additional triangular area between the common endpoint of connected segments + * @property {string} ROUND - rounds off the corners of a shape by filling an additional sector of disc + */ +export const LINE_JOIN = { + MITER: 'miter', + BEVEL: 'bevel', + ROUND: 'round', +}; diff --git a/packages/graphics/src/Graphics.js b/packages/graphics/src/Graphics.js index 638276d..abe1bbd 100644 --- a/packages/graphics/src/Graphics.js +++ b/packages/graphics/src/Graphics.js @@ -23,6 +23,7 @@ import Star from './utils/Star'; import { BLEND_MODES } from '@pixi/constants'; import { Container } from '@pixi/display'; +import { LINE_JOIN } from '../../constants/src'; const temp = new Float32Array(3); @@ -243,11 +244,12 @@ * @param {number} [alpha=1] - alpha of the line to draw, will update the objects stored style * @param {number} [alignment=1] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ - lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false) + lineStyle(width = 0, color = 0, alpha = 1, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { - this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native); + this.lineTextureStyle(width, Texture.WHITE, color, alpha, null, alignment, native, lineJoin); return this; } @@ -262,10 +264,11 @@ * @param {PIXI.Matrix} [matrix=null] Texture matrix to transform texture * @param {number} [alignment=0.5] - alignment of the line to draw, (0 = inner, 0.5 = middle, 1 = outter) * @param {boolean} [native=false] - If true the lines will be draw using LINES instead of TRIANGLE_STRIP + * @param {string} [lineJoin='miter'] - shape used to join two line segments where they meet * @return {PIXI.Graphics} This Graphics object. Good for chaining method calls */ lineTextureStyle(width = 0, texture = Texture.WHITE, color = 0xFFFFFF, alpha = 1, - matrix = null, alignment = 0.5, native = false) + matrix = null, alignment = 0.5, native = false, lineJoin = LINE_JOIN.MITER) { if (this.currentPath) { @@ -295,6 +298,7 @@ alignment, native, visible, + lineJoin, }); } diff --git a/packages/graphics/src/styles/LineStyle.js b/packages/graphics/src/styles/LineStyle.js index b746069..21f089f 100644 --- a/packages/graphics/src/styles/LineStyle.js +++ b/packages/graphics/src/styles/LineStyle.js @@ -1,4 +1,5 @@ import FillStyle from './FillStyle'; +import { LINE_JOIN } from '@pixi/constants'; /** * Represents the line style for Graphics. @@ -25,6 +26,7 @@ obj.width = this.width; obj.alignment = this.alignment; obj.native = this.native; + obj.lineJoin = this.lineJoin; return obj; } @@ -61,5 +63,13 @@ * @default false */ this.native = false; + + /** + * Shape used to join two line segments where they meet. + * + * @member {string} + * @default 'miter' + */ + this.lineJoin = LINE_JOIN.MITER; } } diff --git a/packages/graphics/src/utils/buildLine.js b/packages/graphics/src/utils/buildLine.js index 5ae3117..c1e0b2d 100644 --- a/packages/graphics/src/utils/buildLine.js +++ b/packages/graphics/src/utils/buildLine.js @@ -1,4 +1,9 @@ -import { Point } from '@pixi/math'; +import { Point, PI_2 } from '@pixi/math'; +import { LINE_JOIN } from '@pixi/constants'; + +const TOLERANCE = 0.0001; +const PI_LBOUND = Math.PI - TOLERANCE; +const PI_UBOUND = Math.PI + TOLERANCE; /** * Builds a line to draw @@ -96,8 +101,14 @@ let perpy = p1x - p2x; let perp2x = 0; let perp2y = 0; - let perp3x = 0; - let perp3y = 0; + + let midx = 0; + let midy = 0; + + let dist12 = 0; + let dist23 = 0; + let distMid = 0; + let minDist = 0; let dist = Math.sqrt((perpx * perpx) + (perpy * perpy)); @@ -133,73 +144,158 @@ perpx = -(p1y - p2y); perpy = p1x - p2x; - dist = Math.sqrt((perpx * perpx) + (perpy * perpy)); + perp2x = -(p2y - p3y); + perp2y = p2x - p3x; + + dist = len(perpx, perpy); perpx /= dist; perpy /= dist; perpx *= width; perpy *= width; - perp2x = -(p2y - p3y); - perp2y = p2x - p3x; - - dist = Math.sqrt((perp2x * perp2x) + (perp2y * perp2y)); + dist = len(perp2x, perp2y); perp2x /= dist; perp2y /= dist; perp2x *= width; perp2y *= width; - const a1 = (-perpy + p1y) - (-perpy + p2y); - const b1 = (-perpx + p2x) - (-perpx + p1x); - const c1 = ((-perpx + p1x) * (-perpy + p2y)) - ((-perpx + p2x) * (-perpy + p1y)); - const a2 = (-perp2y + p3y) - (-perp2y + p2y); - const b2 = (-perp2x + p2x) - (-perp2x + p3x); - const c2 = ((-perp2x + p3x) * (-perp2y + p2y)) - ((-perp2x + p2x) * (-perp2y + p3y)); + const a1 = p1y - p2y; + const b1 = p2x - p1x; + const a2 = p3y - p2y; + const b2 = p2x - p3x; - let denom = (a1 * b2) - (a2 * b1); + const denom = (a1 * b2) - (a2 * b1); + const join = style.lineJoin; - if (Math.abs(denom) < 0.1) + let px; + let py; + let pdist; + + // parallel or almost parallel ~0 or ~180 deg + if (Math.abs(denom) < TOLERANCE) { - denom += 10.1; - verts.push( - p2x - (perpx * r1), - p2y - (perpy * r1)); + // bevel, miter or round ~0deg + if (join !== LINE_JOIN.ROUND || Math.abs(angleDiff(perpx, perpy, perp2x, perp2y)) < TOLERANCE) + { + verts.push( + p2x - (perpx * r1), + p2y - (perpy * r1) + ); - verts.push( - p2x + (perpx * r2), - p2y + (perpy * r2)); + verts.push( + p2x + (perpx * r2), + p2y + (perpy * r2) + ); - continue; - } - - const px = ((b1 * c2) - (b2 * c1)) / denom; - const py = ((a2 * c1) - (a1 * c2)) / denom; - const pdist = ((px - p2x) * (px - p2x)) + ((py - p2y) * (py - p2y)); - - if (pdist > (196 * width * width)) - { - perp3x = perpx - perp2x; - perp3y = perpy - perp2y; - - dist = Math.sqrt((perp3x * perp3x) + (perp3y * perp3y)); - perp3x /= dist; - perp3y /= dist; - perp3x *= width; - perp3y *= width; - - verts.push(p2x - (perp3x * r1), p2y - (perp3y * r1)); - - verts.push(p2x + (perp3x * r2), p2y + (perp3y * r2)); - - verts.push(p2x - (perp3x * r2 * r1), p2y - (perp3y * r1)); - - indexCount++; + continue; + } + else // round ~180deg + { + px = p2x; + py = p2y; + pdist = 0; + } } else { + const c1 = ((-perpx + p1x) * (-perpy + p2y)) - ((-perpx + p2x) * (-perpy + p1y)); + const c2 = ((-perp2x + p3x) * (-perp2y + p2y)) - ((-perp2x + p2x) * (-perp2y + p3y)); + + px = ((b1 * c2) - (b2 * c1)) / denom; + py = ((a2 * c1) - (a1 * c2)) / denom; + pdist = ((px - p2x) * (px - p2x)) + ((py - p2y) * (py - p2y)); + } + + // funky comparison to have backwards compat which will fall back by default to miter + // TODO: introduce miterLimit + if (join !== LINE_JOIN.BEVEL && join !== LINE_JOIN.ROUND && pdist <= (196 * width * width)) + { verts.push(p2x + ((px - p2x) * r1), p2y + ((py - p2y) * r1)); verts.push(p2x - ((px - p2x) * r2), p2y - ((py - p2y) * r2)); } + else + { + const flip = shouldFlip(p1x, p1y, p2x, p2y, p3x, p3y); + + dist12 = len(p2x - p1x, p2y - p1y); + dist23 = len(p3x - p2x, p3y - p2y); + minDist = Math.min(dist12, dist23); + + if (flip) + { + perpx = -perpx; + perpy = -perpy; + perp2x = -perp2x; + perp2y = -perp2y; + + midx = (px - p2x) * r1; + midy = (py - p2y) * r1; + distMid = len(midx, midy); + + if (minDist < distMid) + { + midx /= distMid; + midy /= distMid; + midx *= minDist; + midy *= minDist; + } + + midx = p2x - midx; + midy = p2y - midy; + } + else + { + midx = (px - p2x) * r2; + midy = (py - p2y) * r2; + distMid = len(midx, midy); + + if (minDist < distMid) + { + midx /= distMid; + midy /= distMid; + midx *= minDist; + midy *= minDist; + } + + midx += p2x; + midy += p2y; + } + + if (join === LINE_JOIN.ROUND) + { + const rad = flip ? r1 : r2; + + // eslint-disable-next-line max-params + indexCount += buildRoundCap(midx, midy, + p2x + (perpx * rad), p2y + (perpy * rad), + p2x + (perp2x * rad), p2y + (perp2y * rad), + p3x, p3y, + verts, + flip); + } + else if (join === LINE_JOIN.BEVEL || pdist > (196 * width * width)) // TODO: introduce miterLimit + { + if (flip) + { + verts.push(p2x + (perpx * r2), p2y + (perpy * r2)); + verts.push(midx, midy); + + verts.push(p2x + (perp2x * r2), p2y + (perp2y * r2)); + verts.push(midx, midy); + } + else + { + verts.push(midx, midy); + verts.push(p2x + (perpx * r1), p2y + (perpy * r1)); + + verts.push(midx, midy); + verts.push(p2x + (perp2x * r1), p2y + (perp2y * r1)); + } + + indexCount += 2; + } + } } p1x = points[(length - 2) * 2]; @@ -233,6 +329,136 @@ } } +function len(x, y) +{ + return Math.sqrt((x * x) + (y * y)); +} + +/** + * Check turn direction. If counterclockwise, we must invert prep vectors, otherwise they point 'inwards' the angle, + * resulting in funky looking lines. + * + * @ignore + * @private + * @param {number} p0x - x of 1st point + * @param {number} p0y - y of 1st point + * @param {number} p1x - x of 2nd point + * @param {number} p1y - y of 2nd point + * @param {number} p2x - x of 3rd point + * @param {number} p2y - y of 3rd point + * + * @returns {boolean} true if perpendicular vectors should be flipped, otherwise false + */ +function shouldFlip(p0x, p0y, p1x, p1y, p2x, p2y) +{ + return ((p1x - p0x) * (p2y - p0y)) - ((p2x - p0x) * (p1y - p0y)) < 0; +} + +function angleDiff(p0x, p0y, p1x, p1y) +{ + const angle1 = Math.atan2(p0x, p0y); + const angle2 = Math.atan2(p1x, p1y); + + if (angle2 > angle1) + { + if ((angle2 - angle1) >= PI_LBOUND) + { + return angle2 - PI_2 - angle1; + } + } + else if ((angle1 - angle2) >= PI_LBOUND) + { + return angle2 - (angle1 - PI_2); + } + + return angle2 - angle1; +} + +// eslint-disable-next-line max-params +function buildRoundCap(cx, cy, p1x, p1y, p2x, p2y, nxtPx, nxtPy, verts, flipped) +{ + const cx2p0x = p1x - cx; + const cy2p0y = p1y - cy; + + let angle0 = Math.atan2(cx2p0x, cy2p0y); + let angle1 = Math.atan2(p2x - cx, p2y - cy); + + let startAngle = angle0; + + if (angle1 > angle0) + { + if ((angle1 - angle0) >= PI_LBOUND) + { + angle1 = angle1 - PI_2; + } + } + else if ((angle0 - angle1) >= PI_LBOUND) + { + angle0 = angle0 - PI_2; + } + + let angleDiff = angle1 - angle0; + const absAngleDiff = Math.abs(angleDiff); + + if (absAngleDiff >= PI_LBOUND && absAngleDiff <= PI_UBOUND) + { + const r1x = cx - nxtPx; + const r1y = cy - nxtPy; + + if (r1x === 0) + { + if (r1y > 0) + { + angleDiff = -angleDiff; + } + } + else if (r1x >= -TOLERANCE) + { + angleDiff = -angleDiff; + } + } + + const radius = len(cx2p0x, cy2p0y); + const segCount = ((15 * absAngleDiff * Math.sqrt(radius) / Math.PI) >> 0) + 1; + const angleInc = angleDiff / segCount; + + startAngle += angleInc; + + if (flipped) + { + verts.push(p1x, p1y); + verts.push(cx, cy); + + for (let i = 1, angle = startAngle; i < segCount; i++, angle += angleInc) + { + verts.push(cx + ((Math.sin(angle) * radius)), + cy + ((Math.cos(angle) * radius))); + verts.push(cx, cy); + } + + verts.push(p2x, p2y); + verts.push(cx, cy); + } + else + { + verts.push(cx, cy); + verts.push(p1x, p1y); + + for (let i = 1, angle = startAngle; i < segCount; i++, angle += angleInc) + { + verts.push(cx, cy); + verts.push(cx + ((Math.sin(angle) * radius)), + cy + ((Math.cos(angle) * radius))); + } + + verts.push(cx, cy); + verts.push(cx + ((Math.sin(angle1) * radius)), + cy + ((Math.cos(angle1) * radius))); + } + + return segCount * 2; +} + /** * Builds a line to draw using the gl.drawArrays(gl.LINES) method * diff --git a/packages/graphics/test/index.js b/packages/graphics/test/index.js index 474ae3c..ae2eaea 100644 --- a/packages/graphics/test/index.js +++ b/packages/graphics/test/index.js @@ -14,7 +14,7 @@ return !process.env.DISABLE_WEBGL ? (fn || true) : undefined; } -describe('PIXI.Graphics', function () +describe('Graphics', function () { describe('constructor', function () { @@ -129,6 +129,850 @@ expect(graphics.currentPath.points).to.deep.equal([0, 0, 10, 0]); }); + + describe('lineJoin', function () + { + describe('miter', function () + { + it('is miter by default (backwards compatible)', function () + { + // given + const graphics = new Graphics(); + + // then + expect(graphics.line.lineJoin).to.be.equal('miter'); + }); + + it('clockwise miter', withGL(function () + { + // given + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'miter'; + + graphics.moveTo(0, 0); + graphics.lineTo(50, 0); + graphics.lineTo(50, 50); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 6 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(6); + + expect(points[0], 'x1').to.be.eql(0); + expect(points[1], 'y1').to.be.eql(1); + + expect(points[2], 'x2').to.be.eql(0); + expect(points[3], 'y2').to.be.eql(-1); + + expect(points[4], 'x3').to.be.eql(49); + expect(points[5], 'y3').to.be.eql(1); + + expect(points[6], 'x4').to.be.eql(51); + expect(points[7], 'y4').to.be.eql(-1); + + expect(points[8], 'x5').to.be.eql(49); + expect(points[9], 'y5').to.be.eql(50); + + expect(points[10], 'x6').to.be.eql(51); + expect(points[11], 'y6').to.be.eql(50); + })); + + it('counterclockwise miter', withGL(function () + { + // given + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'miter'; + + graphics.moveTo(0, 0); + graphics.lineTo(50, 0); + graphics.lineTo(50, -50); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 6 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(6); + + expect(points[0], 'x1').to.be.eql(0); + expect(points[1], 'y1').to.be.eql(1); + + expect(points[2], 'x2').to.be.eql(0); + expect(points[3], 'y2').to.be.eql(-1); + + expect(points[4], 'x3').to.be.eql(51); + expect(points[5], 'y3').to.be.eql(1); + + expect(points[6], 'x4').to.be.eql(49); + expect(points[7], 'y4').to.be.eql(-1); + + expect(points[8], 'x5').to.be.eql(51); + expect(points[9], 'y5').to.be.eql(-50); + + expect(points[10], 'x6').to.be.eql(49); + expect(points[11], 'y6').to.be.eql(-50); + })); + + it('flat line miter', withGL(function () + { + // given + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'miter'; + + graphics.moveTo(0, 0); + graphics.lineTo(50, 0); + graphics.lineTo(100, 0); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 6 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(6); + + expect(points[0], 'x1').to.be.eql(0); + expect(points[1], 'y1').to.be.eql(1); + + expect(points[2], 'x2').to.be.eql(0); + expect(points[3], 'y2').to.be.eql(-1); + + expect(points[4], 'x3').to.be.eql(50); + expect(points[5], 'y3').to.be.eql(1); + + expect(points[6], 'x5').to.be.eql(50); + expect(points[7], 'y5').to.be.eql(-1); + + expect(points[8], 'x7').to.be.eql(100); + expect(points[9], 'y7').to.be.eql(1); + + expect(points[10], 'x8').to.be.eql(100); + expect(points[11], 'y8').to.be.eql(-1); + })); + + it('very sharp clockwise miter falling back to bevel', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [50, 0]; + const p3 = [0, 2]; + // normalized perpendicular lines + const perp1 = [0, 0.5]; + const perp2 = [0.019984019174435787, 0.4996004793608947]; + const anchor = [24.990003996803196, 0.5]; + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(1, 0, 1, 0.5); + graphics.line.lineJoin = 'miter'; + + graphics.moveTo(p1[0], p1[1]); + graphics.lineTo(p2[0], p2[1]); + + // when + graphics.lineTo(p3[0], p3[1]); + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 8 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(8); + + expect(points[0], 'x1').to.be.eql(p1[0] + perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] + perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] - perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] - perp1[1]); + + expect(points[4], 'x3').to.be.eql(anchor[0]); + expect(points[5], 'y3').to.be.eql(anchor[1]); + + expect(points[6], 'x4').to.be.eql(p2[0] - perp1[0]); + expect(points[7], 'y4').to.be.eql(p2[1] - perp1[1]); + + expect(points[8], 'x5').to.be.eql(anchor[0]); + expect(points[9], 'y5').to.be.eql(anchor[1]); + + expect(points[10], 'x6').to.be.eql(p2[0] + perp2[0]); + expect(points[11], 'y6').to.be.eql(p2[1] + perp2[1]); + + expect(points[12], 'x7').to.be.eql(p3[0] - perp2[0]); + expect(points[13], 'y7').to.be.eql(p3[1] - perp2[1]); + + expect(points[14], 'x8').to.be.eql(p3[0] + perp2[0]); + expect(points[15], 'y8').to.be.eql(p3[1] + perp2[1]); + })); + + it('very sharp counterclockwise miter falling back to bevel', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [50, 0]; + const p3 = [0, -2]; + // normalized perpendicular vectors + const perp1 = [0, 0.5]; + const perp2 = [0.019984019174435787, -0.4996004793608947]; + const anchor = [24.990003996803196, -0.5]; + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(1, 0, 1, 0.5); + graphics.line.lineJoin = 'miter'; + + graphics.moveTo(p1[0], p1[1]); + graphics.lineTo(p2[0], p2[1]); + + // when + graphics.lineTo(p3[0], p3[1]); + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 8 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(8); + + expect(points[0], 'x1').to.be.eql(p1[0] + perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] + perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] - perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] - perp1[1]); + + expect(points[4], 'x3').to.be.eql(p2[0] + perp1[0]); + expect(points[5], 'y3').to.be.eql(p2[1] + perp1[1]); + + expect(points[6], 'x4').to.be.eql(anchor[0]); + expect(points[7], 'y4').to.be.eql(anchor[1]); + + expect(points[8], 'x5').to.be.eql(p2[0] + perp2[0]); + expect(points[9], 'y5').to.be.eql(p2[1] + perp2[1]); + + expect(points[10], 'x6').to.be.eql(anchor[0]); + expect(points[11], 'y6').to.be.eql(anchor[1]); + + expect(points[12], 'x7').to.be.eql(p3[0] + perp2[0]); + expect(points[13], 'y7').to.be.eql(p3[1] + perp2[1]); + + expect(points[14], 'x8').to.be.eql(p3[0] - perp2[0]); + expect(points[15], 'y8').to.be.eql(p3[1] - perp2[1]); + })); + + it('miter join paralel lines', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [50, 0]; + const p3 = [100, 0]; + // normalized perpendicular vectors + const perp1 = [0, 1]; + const perp2 = [0, -1]; + const renderer = new Renderer({ width: 200, height: 200 }); + const graphicsMiter = new Graphics(); + + graphicsMiter.lineStyle(2, 0, 1, 0.5); + graphicsMiter.lineJoin = 'miter'; + + graphicsMiter.moveTo(p1[0], p1[1]); + graphicsMiter.lineTo(p2[0], p2[1]); + graphicsMiter.lineTo(p3[0], p3[1]); + + // when + renderer.render(graphicsMiter); + + // then + const points = graphicsMiter.vertexData; + + // 6 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(6); + + expect(points[0], 'x1').to.be.eql(p1[0] + perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] + perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] - perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] - perp1[1]); + + expect(points[4], 'x3').to.be.eql(p2[0] + perp1[0]); + expect(points[5], 'y3').to.be.eql(p2[1] + perp1[1]); + + expect(points[6], 'x4').to.be.eql(p2[0] + perp2[0]); + expect(points[7], 'y4').to.be.eql(p2[1] + perp2[1]); + + expect(points[8], 'x5').to.be.eql(p3[0] - perp2[0]); + expect(points[9], 'y5').to.be.eql(p3[1] - perp2[1]); + + expect(points[10], 'x6').to.be.eql(p3[0] + perp2[0]); + expect(points[11], 'y6').to.be.eql(p3[1] + perp2[1]); + })); + }); + + describe('bevel', function () + { + it('clockwise bevel', withGL(function () + { + // given + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'bevel'; + + graphics.moveTo(0, 0); + graphics.lineTo(50, 0); + graphics.lineTo(50, 50); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 8 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(8); + + expect(points[0], 'x1').to.be.eql(0); + expect(points[1], 'y1').to.be.eql(1); + + expect(points[2], 'x2').to.be.eql(0); + expect(points[3], 'y2').to.be.eql(-1); + + expect(points[4], 'x3').to.be.eql(49); + expect(points[5], 'y3').to.be.eql(1); + + expect(points[6], 'x4').to.be.eql(50); + expect(points[7], 'y4').to.be.eql(-1); + + expect(points[8], 'x5').to.be.eql(49); + expect(points[9], 'y5').to.be.eql(1); + + expect(points[10], 'x6').to.be.eql(51); + expect(points[11], 'y6').to.be.eql(0); + + expect(points[12], 'x7').to.be.eql(49); + expect(points[13], 'y7').to.be.eql(50); + + expect(points[14], 'x8').to.be.eql(51); + expect(points[15], 'y8').to.be.eql(50); + })); + + it('counterclockwise bevel', withGL(function () + { + // given + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'bevel'; + + graphics.moveTo(0, 0); + graphics.lineTo(50, 0); + graphics.lineTo(50, -50); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 8 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(8); + + expect(points[0], 'x1').to.be.eql(0); + expect(points[1], 'y1').to.be.eql(1); + + expect(points[2], 'x2').to.be.eql(0); + expect(points[3], 'y2').to.be.eql(-1); + + expect(points[4], 'x3').to.be.eql(50); + expect(points[5], 'y3').to.be.eql(1); + + expect(points[6], 'x4').to.be.eql(49); + expect(points[7], 'y4').to.be.eql(-1); + + expect(points[8], 'x5').to.be.eql(51); + expect(points[9], 'y5').to.be.eql(0); + + expect(points[10], 'x6').to.be.eql(49); + expect(points[11], 'y6').to.be.eql(-1); + + expect(points[12], 'x7').to.be.eql(51); + expect(points[13], 'y7').to.be.eql(-50); + + expect(points[14], 'x8').to.be.eql(49); + expect(points[15], 'y8').to.be.eql(-50); + })); + + it('bevel join paralel lines', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [50, 0]; + const p3 = [100, 0]; + // normalized perpendicular vectors + const perp1 = [0, 1]; + const perp2 = [0, -1]; + const renderer = new Renderer({ width: 200, height: 200 }); + const graphicsMiter = new Graphics(); + + graphicsMiter.lineStyle(2, 0, 1, 0.5); + graphicsMiter.line.lineJoin = 'bevel'; + + graphicsMiter.moveTo(p1[0], p1[1]); + graphicsMiter.lineTo(p2[0], p2[1]); + graphicsMiter.lineTo(p3[0], p3[1]); + + // when + renderer.render(graphicsMiter); + + // then + const points = graphicsMiter.vertexData; + + // 6 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(6); + + expect(points[0], 'x1').to.be.eql(p1[0] + perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] + perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] - perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] - perp1[1]); + + expect(points[4], 'x3').to.be.eql(p2[0] + perp1[0]); + expect(points[5], 'y3').to.be.eql(p2[1] + perp1[1]); + + expect(points[6], 'x4').to.be.eql(p2[0] + perp2[0]); + expect(points[7], 'y4').to.be.eql(p2[1] + perp2[1]); + + expect(points[8], 'x5').to.be.eql(p3[0] - perp2[0]); + expect(points[9], 'y5').to.be.eql(p3[1] - perp2[1]); + + expect(points[10], 'x6').to.be.eql(p3[0] + perp2[0]); + expect(points[11], 'y6').to.be.eql(p3[1] + perp2[1]); + })); + + it('flat line bevel', withGL(function () + { + // given + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'bevel'; + + graphics.moveTo(0, 0); + graphics.lineTo(50, 0); + graphics.lineTo(100, 0); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 6 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(6); + + expect(points[0], 'x1').to.be.eql(0); + expect(points[1], 'y1').to.be.eql(1); + + expect(points[2], 'x2').to.be.eql(0); + expect(points[3], 'y2').to.be.eql(-1); + + expect(points[4], 'x3').to.be.eql(50); + expect(points[5], 'y3').to.be.eql(1); + + expect(points[6], 'x4').to.be.eql(50); + expect(points[7], 'y4').to.be.eql(-1); + + expect(points[8], 'x5').to.be.eql(100); + expect(points[9], 'y5').to.be.eql(1); + + expect(points[10], 'x6').to.be.eql(100); + expect(points[11], 'y6').to.be.eql(-1); + })); + }); + + describe('round', function () + { + it('round join clockwise', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [50, 0]; + const p3 = [50, 50]; + // normalized perpendicular vectors + const perp1 = [0, -1]; + const perp2 = [1, 0]; + const anchor = [p2[0] - perp1[0] - perp2[0], p2[1] - perp1[1] - perp2[1]]; + // doubles cause every point is followed with center point + // 1 + 1 + 15 * absAngleDiff * Math.sqrt(radius) / Math.PI + const noOfCtlPts = 6 * 2; + const r = 2.23606797749979; // sqrt(1^2 + 2^2) + const angleIncrease = -0.12870022175865686; // anlge diff / 5 + let angle = 2.677945044588987; // Math.atan2(1, -2) + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'round'; + + graphics.moveTo(p1[0], p1[1]); + graphics.lineTo(p2[0], p2[1]); + graphics.lineTo(p3[0], p3[1]); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal((4 + noOfCtlPts)); + + expect(points[0], 'x1').to.be.eql(p1[0] - perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] - perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] + perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] + perp1[1]); + + // center + expect(points[4], 'center1 x').to.be.eql(anchor[0]); + expect(points[5], 'center1 y').to.be.eql(anchor[1]); + + expect(points[8], 'center2 x').to.be.eql(anchor[0]); + expect(points[9], 'center2 y').to.be.eql(anchor[1]); + + expect(points[12], 'center3 x').to.be.eql(anchor[0]); + expect(points[13], 'center3 y').to.be.eql(anchor[1]); + + expect(points[16], 'center4 x').to.be.eql(anchor[0]); + expect(points[17], 'center4 y').to.be.eql(anchor[1]); + + expect(points[20], 'center5 x').to.be.eql(anchor[0]); + expect(points[21], 'center5 y').to.be.eql(anchor[1]); + + expect(points[24], 'center6 x').to.be.eql(anchor[0]); + expect(points[25], 'center6 y').to.be.eql(anchor[1]); + + // peripheral pts + expect(points[6], 'peripheral1 x').to.be.eql(p2[0] + perp1[0]); + expect(points[7], 'peripheral1 y').to.be.eql(p2[1] + perp1[1]); + + angle += angleIncrease; + expect(points[10], 'peripheral2 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[11], 'peripheral2 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + angle += angleIncrease; + expect(points[14], 'peripheral3 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[15], 'peripheral3 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + angle += angleIncrease; + expect(points[18], 'peripheral4 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[19], 'peripheral4 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + angle += angleIncrease; + expect(points[22], 'peripheral5 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[23], 'peripheral5 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + expect(points[26], 'peripheral6 x').to.be.eql(p2[0] + perp2[0]); + expect(points[27], 'peripheral6 y').to.be.eql(p2[1] + perp2[1]); + + expect(points[28], 'x[last-1]').to.be.eql(p3[0] - perp2[0]); + expect(points[29], 'y[last-1]').to.be.eql(p3[1] - perp2[1]); + + expect(points[30], 'x[last]').to.be.eql(p3[0] + perp2[0]); + expect(points[31], 'y[last]').to.be.eql(p3[1] + perp2[1]); + })); + + it('round join counterclockwise', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [50, 0]; + const p3 = [50, -50]; + // normalized perpendicular vectors + const perp1 = [0, 1]; + const perp2 = [1, 0]; + const anchor = [p2[0] - perp1[0] - perp2[0], p2[1] - perp1[1] - perp2[1]]; + // doubles cause every point is followed with center point + // 1 + 1 + 15 * absAngleDiff * Math.sqrt(radius) / Math.PI + const noOfCtlPts = 6 * 2; + const r = 2.23606797749979; // sqrt(1^2 + 2^2) + const angleIncrease = 0.12870022175865686; // anlge diff / 5 + let angle = 0.4636476090008061; // Math.atan2(1, -2) + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'round'; + + graphics.moveTo(p1[0], p1[1]); + graphics.lineTo(p2[0], p2[1]); + graphics.lineTo(p3[0], p3[1]); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal((4 + noOfCtlPts)); + + expect(points[0], 'x1').to.be.eql(p1[0] + perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] + perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] - perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] - perp1[1]); + + // center + expect(points[6], 'center1 x').to.be.eql(anchor[0]); + expect(points[7], 'center1 y').to.be.eql(anchor[1]); + + expect(points[10], 'center2 x').to.be.eql(anchor[0]); + expect(points[11], 'center2 y').to.be.eql(anchor[1]); + + expect(points[14], 'center3 x').to.be.eql(anchor[0]); + expect(points[15], 'center3 y').to.be.eql(anchor[1]); + + expect(points[18], 'center4 x').to.be.eql(anchor[0]); + expect(points[19], 'center4 y').to.be.eql(anchor[1]); + + expect(points[22], 'center5 x').to.be.eql(anchor[0]); + expect(points[23], 'center5 y').to.be.eql(anchor[1]); + + expect(points[26], 'center6 x').to.be.eql(anchor[0]); + expect(points[27], 'center6 y').to.be.eql(anchor[1]); + + // peripheral pts + expect(points[4], 'peripheral1 x').to.be.eql(p2[0] + perp1[0]); + expect(points[5], 'peripheral1 y').to.be.eql(p2[1] + perp1[1]); + + angle += angleIncrease; + expect(points[8], 'peripheral2 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[9], 'peripheral2 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + angle += angleIncrease; + expect(points[12], 'peripheral3 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[13], 'peripheral3 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + angle += angleIncrease; + expect(points[16], 'peripheral4 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[17], 'peripheral4 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + angle += angleIncrease; + expect(points[20], 'peripheral5 x').to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[21], 'peripheral5 y').to.be.eql(anchor[1] + (Math.cos(angle) * r)); + + expect(points[24], 'peripheral6 x').to.be.eql(p2[0] + perp2[0]); + expect(points[25], 'peripheral6 y').to.be.eql(p2[1] + perp2[1]); + + expect(points[28], 'x[last-1]').to.be.eql(p3[0] + perp2[0]); + expect(points[29], 'y[last-1]').to.be.eql(p3[1] + perp2[1]); + + expect(points[30], 'x[last]').to.be.eql(p3[0] - perp2[0]); + expect(points[31], 'y[last]').to.be.eql(p3[1] - perp2[1]); + })); + + it('round join back and forth', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [50, 0]; + const p3 = [10, 0]; + // normalized perpendicular vectors + const perp1 = [0, -1]; + const perp2 = [0, 1]; + const anchor = [p2[0], p2[1]]; + // doubles cause every point is followed with center point + // 1 + 1 + 15 * absAngleDiff * Math.sqrt(radius) / Math.PI + const noOfCtlPts = 16 * 2; + const r = 1; + const angleIncrease = -0.20943951023931953; + let angle = 3.141592653589793; + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'round'; + + graphics.moveTo(p1[0], p1[1]); + graphics.lineTo(p2[0], p2[1]); + graphics.lineTo(p3[0], p3[1]); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + const len = points.length; + + // control points, xy each + expect(len / 2, 'number of control points is not right').to.be.equal((4 + noOfCtlPts)); + + expect(points[0], 'x1').to.be.eql(p1[0] - perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] - perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] + perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] + perp1[1]); + + // center + for (let i = 4, j = 1; j <= 16; i += 4, j++) + { + expect(points[i], `center${j} x`).to.be.eql(p2[0]); + expect(points[i + 1], `center${j} y`).to.be.eql(p2[1]); + } + + // peripheral pts + + expect(points[6], 'peripheral1 x').to.be.eql(p2[0] + perp1[0]); + expect(points[7], 'peripheral1 y').to.be.eql(p2[1] + perp1[1]); + + for (let i = 10, j = 2; j < 16; i += 4, j++) + { + angle += angleIncrease; + expect(points[i], `peripheral${j} x`).to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[i + 1], `peripheral${j} y`).to.be.eql(anchor[1] + (Math.cos(angle) * r)); + } + + expect(points[len - 6], 'peripheral16 x').to.be.eql(p2[0] + perp2[0]); + expect(points[len - 5], 'peripheral16 y').to.be.eql(p2[1] + perp2[1]); + + expect(points[len - 4], 'x[last-1]').to.be.eql(p3[0] - perp2[0]); + expect(points[len - 3], 'y[last-1]').to.be.eql(p3[1] - perp2[1]); + + expect(points[len - 2], 'x[last]').to.be.eql(p3[0] + perp2[0]); + expect(points[len - 1], 'y[last]').to.be.eql(p3[1] + perp2[1]); + })); + + it('round join back and forth other way around', withGL(function () + { + // given + const p1 = [0, 0]; + const p2 = [-50, 0]; + const p3 = [10, 0]; + // normalized perpendicular vectors + const perp1 = [0, 1]; + const perp2 = [0, -1]; + const anchor = [p2[0], p2[1]]; + // doubles cause every point is followed with center point + // 1 + 1 + 15 * absAngleDiff * Math.sqrt(radius) / Math.PI + const noOfCtlPts = 16 * 2; + const r = 1; + const angleIncrease = -0.20943951023931953; + let angle = 0; + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'round'; + + graphics.moveTo(p1[0], p1[1]); + graphics.lineTo(p2[0], p2[1]); + graphics.lineTo(p3[0], p3[1]); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + const len = points.length; + + // control points, xy each + expect(len / 2, 'number of control points is not right').to.be.equal((4 + noOfCtlPts)); + + expect(points[0], 'x1').to.be.eql(p1[0] - perp1[0]); + expect(points[1], 'y1').to.be.eql(p1[1] - perp1[1]); + + expect(points[2], 'x2').to.be.eql(p1[0] + perp1[0]); + expect(points[3], 'y2').to.be.eql(p1[1] + perp1[1]); + + // center + for (let i = 4, j = 1; j <= 16; i += 4, j++) + { + expect(points[i], `center${j} x`).to.be.eql(p2[0]); + expect(points[i + 1], `center${j} y`).to.be.eql(p2[1]); + } + + // peripheral pts + + expect(points[6], 'peripheral1 x').to.be.eql(p2[0] + perp1[0]); + expect(points[7], 'peripheral1 y').to.be.eql(p2[1] + perp1[1]); + + for (let i = 10, j = 2; j < 16; i += 4, j++) + { + angle += angleIncrease; + expect(points[i], `peripheral${j} x`).to.be.eql(anchor[0] + (Math.sin(angle) * r)); + expect(points[i + 1], `peripheral${j} y`).to.be.eql(anchor[1] + (Math.cos(angle) * r)); + } + + expect(points[len - 6], 'peripheral16 x').to.be.eql(p2[0] + perp2[0]); + expect(points[len - 5], 'peripheral16 y').to.be.eql(p2[1] + perp2[1]); + + expect(points[len - 4], 'x[last-1]').to.be.eql(p3[0] - perp2[0]); + expect(points[len - 3], 'y[last-1]').to.be.eql(p3[1] - perp2[1]); + + expect(points[len - 2], 'x[last]').to.be.eql(p3[0] + perp2[0]); + expect(points[len - 1], 'y[last]').to.be.eql(p3[1] + perp2[1]); + })); + + it('flat line round', withGL(function () + { + // given + const renderer = new Renderer({ width: 200, height: 200 }); + const graphics = new Graphics(); + + graphics.lineStyle(2, 0, 1, 0.5); + graphics.line.lineJoin = 'round'; + + graphics.moveTo(0, 0); + graphics.lineTo(50, 0); + graphics.lineTo(100, 0); + + // when + renderer.render(graphics); + + // then + const points = graphics.geometry.points; + + // 6 control points, xy each + expect(points.length / 2, 'number of control points is not right').to.be.equal(6); + + expect(points[0], 'x1').to.be.eql(0); + expect(points[1], 'y1').to.be.eql(1); + + expect(points[2], 'x2').to.be.eql(0); + expect(points[3], 'y2').to.be.eql(-1); + + expect(points[4], 'x3').to.be.eql(50); + expect(points[5], 'y3').to.be.eql(1); + + expect(points[6], 'x4').to.be.eql(50); + expect(points[7], 'y4').to.be.eql(-1); + + expect(points[8], 'x5').to.be.eql(100); + expect(points[9], 'y5').to.be.eql(1); + + expect(points[10], 'x6').to.be.eql(100); + expect(points[11], 'y6').to.be.eql(-1); + })); + }); + }); }); describe('containsPoint', function () @@ -301,7 +1145,7 @@ { it('should have no gaps in line border', withGL(function () { - const renderer = new Renderer(200, 200, {}); + const renderer = new Renderer({ width: 200, height: 200 }); try {