UNPKG

fabric

Version:

Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.

315 lines (295 loc) 11.5 kB
import type { XY } from '../../../Point'; import { Point } from '../../../Point'; import { halfPI, twoMathPi } from '../../../constants'; import type { TRadian } from '../../../typedefs'; import { degreesToRadians } from '../radiansDegreesConversion'; import { calcAngleBetweenVectors, calcVectorRotation, crossProduct, getOrthonormalVector, getUnitVector, isBetweenVectors, magnitude, rotateVector, } from '../vectors'; import { StrokeProjectionsBase } from './StrokeProjectionsBase'; import type { TProjection, TProjectStrokeOnPointsOptions } from './types'; const zeroVector = new Point(); /** * class in charge of finding projections for each type of line join * @see {@link [Closed path projections at #8344](https://github.com/fabricjs/fabric.js/pull/8344#2-closed-path)} * * - MDN: * - https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/lineJoin * - https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-linejoin * - Spec: https://svgwg.org/svg2-draft/painting.html#StrokeLinejoinProperty * - Playground to understand how the line joins works: https://hypertolosana.github.io/efficient-webgl-stroking/index.html * - View the calculated projections for each of the control points: https://codesandbox.io/s/project-stroke-points-with-context-to-trace-b8jc4j?file=/src/index.js * */ export class StrokeLineJoinProjections extends StrokeProjectionsBase { /** * The point being projected (the angle ∠BAC) */ declare A: Point; /** * The point before A */ declare B: Point; /** * The point after A */ declare C: Point; /** * The AB vector */ AB: Point; /** * The AC vector */ AC: Point; /** * The angle of A (∠BAC) */ alpha: TRadian; /** * The bisector of A (∠BAC) */ bisector: Point; static getOrthogonalRotationFactor(vector1: Point, vector2?: Point) { const angle = vector2 ? calcAngleBetweenVectors(vector1, vector2) : calcVectorRotation(vector1); return Math.abs(angle) < halfPI ? -1 : 1; } constructor(A: XY, B: XY, C: XY, options: TProjectStrokeOnPointsOptions) { super(options); this.A = new Point(A); this.B = new Point(B); this.C = new Point(C); this.AB = this.createSideVector(this.A, this.B); this.AC = this.createSideVector(this.A, this.C); this.alpha = calcAngleBetweenVectors(this.AB, this.AC); this.bisector = getUnitVector( // if AC is also the zero vector nothing will be projected // in that case the next point will handle the projection rotateVector(this.AB.eq(zeroVector) ? this.AC : this.AB, this.alpha / 2), ); } calcOrthogonalProjection( from: Point, to: Point, magnitude: number = this.strokeProjectionMagnitude, ) { const vector = this.createSideVector(from, to); const orthogonalProjection = getOrthonormalVector(vector); const correctSide = StrokeLineJoinProjections.getOrthogonalRotationFactor( orthogonalProjection, this.bisector, ); return this.scaleUnitVector(orthogonalProjection, magnitude * correctSide); } /** * BEVEL * Calculation: the projection points are formed by the vector orthogonal to the vertex. * * @see https://github.com/fabricjs/fabric.js/pull/8344#2-2-bevel */ projectBevel() { const projections: Point[] = []; // if `alpha` equals 0 or 2*PI, the projections are the same for `B` and `C` (this.alpha % twoMathPi === 0 ? [this.B] : [this.B, this.C]).forEach( (to) => { projections.push(this.projectOrthogonally(this.A, to)); projections.push( this.projectOrthogonally(this.A, to, -this.strokeProjectionMagnitude), ); }, ); return projections; } /** * MITER * Calculation: the corner is formed by extending the outer edges of the stroke * at the tangents of the path segments until they intersect. * * @see https://github.com/fabricjs/fabric.js/pull/8344#2-1-miter */ projectMiter() { const projections: Point[] = [], alpha = Math.abs(this.alpha), hypotUnitScalar = 1 / Math.sin(alpha / 2), miterVector = this.scaleUnitVector( this.bisector, -this.strokeProjectionMagnitude * hypotUnitScalar, ); // When two line segments meet at a sharp angle, it is possible for the join to extend, // far beyond the thickness of the line stroking the path. The stroke-miterlimit imposes // a limit on the extent of the line join. // MDN: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-miterlimit // When the stroke is uniform, scaling changes the arrangement of points, this changes the miter-limit const strokeMiterLimit = this.options.strokeUniform ? magnitude( this.scaleUnitVector(this.bisector, this.options.strokeMiterLimit), ) : this.options.strokeMiterLimit; if ( magnitude(miterVector) / this.strokeProjectionMagnitude <= strokeMiterLimit ) { projections.push(this.applySkew(this.A.add(miterVector))); } /* when the miter-limit is reached, the stroke line join becomes of type bevel. We always need two orthogonal projections which are basically bevel-type projections, so regardless of whether the miter-limit was reached or not, we include these projections. */ projections.push(...this.projectBevel()); return projections; } /** * ROUND (without skew) * Calculation: the projections are the two vectors parallel to X and Y axes * * @see https://github.com/fabricjs/fabric.js/pull/8344#2-3-1-round-without-skew */ private projectRoundNoSkew(startCircle: Point, endCircle: Point) { const projections: Point[] = [], // correctSide is used to only consider projecting for the outer side correctSide = new Point( StrokeLineJoinProjections.getOrthogonalRotationFactor(this.bisector), StrokeLineJoinProjections.getOrthogonalRotationFactor( new Point(this.bisector.y, this.bisector.x), ), ), radiusOnAxisX = new Point(1, 0) .scalarMultiply(this.strokeProjectionMagnitude) .multiply(this.strokeUniformScalar) .multiply(correctSide), radiusOnAxisY = new Point(0, 1) .scalarMultiply(this.strokeProjectionMagnitude) .multiply(this.strokeUniformScalar) .multiply(correctSide); [radiusOnAxisX, radiusOnAxisY].forEach((vector) => { if (isBetweenVectors(vector, startCircle, endCircle)) { projections.push(this.A.add(vector)); } }); return projections; } /** * ROUND (with skew) * Calculation: the projections are the points furthest from the vertex in * the direction of the X and Y axes after distortion. * * @see https://github.com/fabricjs/fabric.js/pull/8344#2-3-2-round-skew */ private projectRoundWithSkew(startCircle: Point, endCircle: Point) { const projections: Point[] = []; const { skewX, skewY, scaleX, scaleY, strokeUniform } = this.options, shearing = new Point( Math.tan(degreesToRadians(skewX)), Math.tan(degreesToRadians(skewY)), ); // The points furthest from the vertex in the direction of the X and Y axes after distortion const circleRadius = this.strokeProjectionMagnitude, newY = strokeUniform ? circleRadius / scaleY / Math.sqrt(1 / scaleY ** 2 + (1 / scaleX ** 2) * shearing.y ** 2) : circleRadius / Math.sqrt(1 + shearing.y ** 2), furthestY = new Point( // Safe guard due to floating point precision. In some situations the square root // was returning NaN because of a negative number close to zero. Math.sqrt(Math.max(circleRadius ** 2 - newY ** 2, 0)), newY, ), newX = strokeUniform ? circleRadius / Math.sqrt( 1 + (shearing.x ** 2 * (1 / scaleY) ** 2) / (1 / scaleX + (1 / scaleX) * shearing.x * shearing.y) ** 2, ) : circleRadius / Math.sqrt(1 + shearing.x ** 2 / (1 + shearing.x * shearing.y) ** 2), furthestX = new Point( newX, Math.sqrt(Math.max(circleRadius ** 2 - newX ** 2, 0)), ); [ furthestX, furthestX.scalarMultiply(-1), furthestY, furthestY.scalarMultiply(-1), ] // We need to skew the vector here as this information is used to check if // it is between the start and end of the circle segment .map((vector) => this.applySkew( strokeUniform ? vector.multiply(this.strokeUniformScalar) : vector, ), ) .forEach((vector) => { if (isBetweenVectors(vector, startCircle, endCircle)) { projections.push(this.applySkew(this.A).add(vector)); } }); return projections; } projectRound() { const projections: Point[] = []; /* Include the start and end points of the circle segment, so that only the projections contained within it are included */ // add the orthogonal projections (start and end points of circle segment) projections.push(...this.projectBevel()); // let's determines which one of the orthogonal projection is the beginning and end of the circle segment. // when `alpha` equals 0 or 2*PI, we have a straight line, so the way to find the start/end is different. const isStraightLine = this.alpha % twoMathPi === 0, // change the origin of the projections to point A // so that the cross product calculation is correct newOrigin = this.applySkew(this.A), proj0 = projections[isStraightLine ? 0 : 2].subtract(newOrigin), proj1 = projections[isStraightLine ? 1 : 0].subtract(newOrigin), // when `isStraightLine` === true, we compare with the vector opposite AB, otherwise we compare with the bisector. comparisonVector = isStraightLine ? this.applySkew(this.AB.scalarMultiply(-1)) : this.applySkew( this.bisector.multiply(this.strokeUniformScalar).scalarMultiply(-1), ), // the beginning of the circle segment is always to the right of the comparison vector (cross product > 0) isProj0Start = crossProduct(proj0, comparisonVector) > 0, startCircle = isProj0Start ? proj0 : proj1, endCircle = isProj0Start ? proj1 : proj0; if (!this.isSkewed()) { projections.push(...this.projectRoundNoSkew(startCircle, endCircle)); } else { projections.push(...this.projectRoundWithSkew(startCircle, endCircle)); } return projections; } /** * Project stroke width on points returning projections for each point as follows: * - `miter`: 1 point corresponding to the outer boundary. If the miter limit is exceeded, it will be 2 points (becomes bevel) * - `bevel`: 2 points corresponding to the bevel possible boundaries, orthogonal to the stroke. * - `round`: same as `bevel` when it has no skew, with skew are 4 points. */ protected projectPoints() { switch (this.options.strokeLineJoin) { case 'miter': return this.projectMiter(); case 'round': return this.projectRound(); default: return this.projectBevel(); } } public project(): TProjection[] { return this.projectPoints().map((point) => ({ originPoint: this.A, projectedPoint: point, angle: this.alpha, bisector: this.bisector, })); } }