From 2f9013155f29288ccb1562cb38075aa4d20d2d1b Mon Sep 17 00:00:00 2001 From: Igor Zinken Date: Sat, 16 Jan 2021 17:21:44 +0100 Subject: [PATCH] Implement continuous brushing (#6) Implementation of continuous brushing added --- README.md | 1 - .../tool-options-clone/tool-options-clone.vue | 6 +- src/components/ui/zcanvas/interaction-pane.js | 3 +- src/components/ui/zcanvas/layer-sprite.js | 81 ++++++------------- src/factories/brush-factory.js | 50 ++++++++++++ src/math/image-math.js | 35 -------- src/math/line-math.js | 79 ++++++++++++++++++ src/math/point-math.js | 66 +++++++++++++++ src/services/keyboard-service.js | 4 +- src/utils/render-util.js | 58 ++++++++++--- tests/unit/factories/brush-factory.spec.js | 35 ++++++++ tests/unit/math/image-math.spec.js | 11 +-- tests/unit/math/point-math.spec.js | 12 +++ 13 files changed, 322 insertions(+), 119 deletions(-) create mode 100644 src/factories/brush-factory.js create mode 100644 src/math/line-math.js create mode 100644 src/math/point-math.js create mode 100644 tests/unit/factories/brush-factory.spec.js create mode 100644 tests/unit/math/point-math.spec.js diff --git a/README.md b/README.md index 5e2483f..52eb95e 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,6 @@ further benchmarking and tweaking. * Implement layer sorting and opacity * Layer source and mask must not be stored as Vue observables -* Implement action queue when drawing, only execute drawing on zCanvas.sprite.update()-hook * Maintain cache for source images at the display destination size (invalidate on window resize / zoom), this prevents processing large images that are never displayed at their full scale * Dragging of masks on rotated/mirror content is kinda broken * Restored base64 images should be treated as binary once more (see layer-factory) diff --git a/src/components/options-panel/tool-options-clone/tool-options-clone.vue b/src/components/options-panel/tool-options-clone/tool-options-clone.vue index 7b2941c..f47d9ac 100644 --- a/src/components/options-panel/tool-options-clone/tool-options-clone.vue +++ b/src/components/options-panel/tool-options-clone/tool-options-clone.vue @@ -83,9 +83,9 @@ export default { "cloneOptions", ]), selectableLayers() { - return this.layers.filter(({ visible }) => visible ) - .map( layer => ({ text: layer.name, value: layer.id })) - .reverse(); + return this.layers?.filter(({ visible }) => visible ) + .map( layer => ({ text: layer.name, value: layer.id })) + .reverse(); }, sourceLayer: { get() { diff --git a/src/components/ui/zcanvas/interaction-pane.js b/src/components/ui/zcanvas/interaction-pane.js index 766c78a..b1f698b 100644 --- a/src/components/ui/zcanvas/interaction-pane.js +++ b/src/components/ui/zcanvas/interaction-pane.js @@ -25,7 +25,8 @@ import { sprite } from "zcanvas"; import { isInsideTransparentArea } from "@/utils/canvas-util"; import { enqueueState } from "@/factories/history-state-factory"; import { getCanvasInstance, getSpriteForLayer } from "@/factories/sprite-factory"; -import { isPointInRange, rectangleToCoordinates } from "@/math/image-math"; +import { isPointInRange } from "@/math/point-math"; +import { rectangleToCoordinates } from "@/math/image-math"; import { isSelectionClosed } from "@/math/selection-math"; import ToolTypes from "@/definitions/tool-types"; import LayerSprite from "@/components/ui/zcanvas/layer-sprite"; diff --git a/src/components/ui/zcanvas/layer-sprite.js b/src/components/ui/zcanvas/layer-sprite.js index 8101f23..ded8b3c 100644 --- a/src/components/ui/zcanvas/layer-sprite.js +++ b/src/components/ui/zcanvas/layer-sprite.js @@ -23,12 +23,14 @@ import Vue from "vue"; import { sprite } from "zcanvas" import { createCanvas, cloneCanvas, resizeImage, globalToLocal } from "@/utils/canvas-util"; -import { renderCross, renderMasked } from "@/utils/render-util"; +import { renderCross, renderBrushStroke, renderClonedStroke } from "@/utils/render-util"; import { LAYER_GRAPHIC, LAYER_MASK, LAYER_TEXT } from "@/definitions/layer-types"; -import { translatePointerRotation, rotatePoints, scaleRectangle } from "@/math/image-math"; +import { scaleRectangle } from "@/math/image-math"; import { getRectangleForSelection, isSelectionClosed } from "@/math/selection-math"; +import { rotatePoints, translatePointerRotation } from "@/math/point-math"; import { renderEffectsForLayer } from "@/services/render-service"; import { flushLayerCache, clearCacheProperty } from "@/services/caches/bitmap-cache"; +import BrushFactory from "@/factories/brush-factory"; import { getSpriteForLayer } from "@/factories/sprite-factory"; import { enqueueState } from "@/factories/history-state-factory"; import ToolTypes, { canDrawOnSelection } from "@/definitions/tool-types"; @@ -51,13 +53,11 @@ class LayerSprite extends sprite { layer.source = cvs; } - // create brush (used for both drawing on LAYER_GRAPHIC types and to create layer masks) - const brushCanvas = createCanvas(); - this._brushCvs = brushCanvas.cvs; - this._brushCtx = brushCanvas.ctx; - this._halfRadius = 0; - this._pointerX = 0; - this._pointerY = 0; + this._pointerX = 0; + this._pointerY = 0; + + // brush properties (used for both drawing on LAYER_GRAPHIC types and to create layer masks) + this._brush = BrushFactory.create(); this.setActionTarget(); @@ -91,41 +91,12 @@ class LayerSprite extends sprite { return this.layer.effects.scale !== 1; } - cacheBrush( color, radius = 30 ) { - this._radius = radius; + cacheBrush( color = "rgba(255,0,0,1)", radius = 30 ) { + this._brush = BrushFactory.create({ color, radius, pointer: this._brush.pointer }); + } - const innerRadius = radius / 10; - const outerRadius = radius * 2; - - const x = radius; - const y = radius; - - // update brush Canvas size - this._brushCvs.width = outerRadius; - this._brushCvs.height = outerRadius; - - if ( !this._brushCtx ) { - return; // TODO: this is because in Jenkins on Linux there is no canvas mock available - } - - const gradient = this._brushCtx.createRadialGradient( x, y, innerRadius, x, y, outerRadius ); - gradient.addColorStop( 0, color ); - - let color2 = "rgba(255,255,255,0)"; -/* - if ( color.startsWith( "rgba" )) { - const [r, g, b, a] = color.split( "," ); - color2 = `${r},${g},${b},0)`; - } -*/ - gradient.addColorStop( 1, color2 ); - - this._brushCtx.clearRect( 0, 0, this._brushCvs.width, this._brushCvs.height ); - this._brushCtx.arc( x, y, radius, 0, 2 * Math.PI ); - this._brushCtx.fillStyle = gradient; - this._brushCtx.fill(); - - this._halfRadius = radius / 2; + storeBrushPointer( x, y ) { + this._brush.pointer = { x: x - this._bounds.left, y: y - this._bounds.top }; } cacheEffects() { @@ -290,17 +261,17 @@ class LayerSprite extends sprite { ctx.fillRect( 0, 0, width, height ); } } else { - // TODO: when rotated and mirrored, x and y are now in right coordinate space, but not at right point + const destination = { x, y }; if ( isCloneStamp ) { - renderMasked( - ctx, this, x, y, + renderClonedStroke( + ctx, this, getSpriteForLayer({ id: this._toolOptions.sourceLayerId }), // TODO: fugly!! - this._brushCvs, this._radius + this._brush, destination, ); } else { - ctx.drawImage( this._brushCvs, x - this._radius, y - this._radius ); + renderBrushStroke( this, this._brush, ctx, destination ); } } ctx.restore(); @@ -416,7 +387,7 @@ class LayerSprite extends sprite { return; } // for any other brush mode state, set the brush application to true (will be applied in handleMove()) - this._applyPaint = true; + this.storeBrushPointer( x, y ); } } @@ -443,13 +414,14 @@ class LayerSprite extends sprite { } // brush tool active (either draws/erases onto IMAGE_GRAPHIC layer source or on the mask bitmap) - if ( this._applyPaint ) { + if ( !!this._brush.pointer ) { this.paint( x, y ); + this.storeBrushPointer( x, y ); // update so next stroke starts from current position } } handleRelease( x, y ) { - this._applyPaint = false; + this._brush.pointer = null; if ( this._isPaintMode ) { this.forceMoveListener(); // keeps the move listener active } @@ -488,8 +460,8 @@ class LayerSprite extends sprite { ty = ( coords.y - viewport.top ) + ( this._pointerY - relSource.y ); } // when no source coordinate is set, or when applying the clone stamp, we show a cross to mark the origin - if ( !coords || this._applyPaint ) { - renderCross( documentContext, tx, ty, this._radius / this.canvas.zoomFactor ); + if ( !coords || this._brush.pointer ) { + renderCross( documentContext, tx, ty, this._brush.radius / this.canvas.zoomFactor ); } } documentContext.save(); @@ -497,7 +469,7 @@ class LayerSprite extends sprite { if ( drawBrushOutline ) { // any other brush mode state shows brush outline - documentContext.arc( this._pointerX - viewport.left, this._pointerY - viewport.top, this._radius, 0, 2 * Math.PI ); + documentContext.arc( this._pointerX - viewport.left, this._pointerY - viewport.top, this._brush.radius, 0, 2 * Math.PI ); } documentContext.stroke(); documentContext.restore(); @@ -536,7 +508,6 @@ class LayerSprite extends sprite { this._bitmap = null; this._bitmapReady = false; - this._brushCvs = null; } } export default LayerSprite; diff --git a/src/factories/brush-factory.js b/src/factories/brush-factory.js new file mode 100644 index 0000000..7033578 --- /dev/null +++ b/src/factories/brush-factory.js @@ -0,0 +1,50 @@ +/** + * The MIT License (MIT) + * + * Igor Zinken 2021 - https://www.igorski.nl + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +const BrushFactory = { + create({ radius = 10, color = "rgba(255,0,0,1)", pointer = null } = {} ) { + const [r, g, b, a] = color.split( "," ); + const colors = [ + color, + `${r},${g},${b},${parseFloat(a) * 0.5})`, + `${r},${g},${b},0)` + ]; + return { + radius, + colors, + pointer, + halfRadius : radius * 0.5, + doubleRadius : radius * 2 + }; + } +}; +export default BrushFactory; + +export const createDrawable = ( brush, ctx, x, y ) => { + const gradient = ctx.createRadialGradient( x, y, brush.halfRadius, x, y, brush.radius ); + + gradient.addColorStop( 0, brush.colors[ 0 ]); + gradient.addColorStop( 0.5, brush.colors[ 1 ]); + gradient.addColorStop( 1, brush.colors[ 2 ]); + + return gradient; +}; diff --git a/src/math/image-math.js b/src/math/image-math.js index b5d2f68..1a29623 100644 --- a/src/math/image-math.js +++ b/src/math/image-math.js @@ -82,28 +82,6 @@ export const isPortrait = ( width, height ) => width < height; export const isLandscape = ( width, height ) => width > height; export const isSquare = ( width, height ) => width === height; -export const isPointInRange = ( point1x, point1y, point2x, point2y, margin = 5 ) => { - const left = point2x - margin; - const right = point2x + margin; - const top = point2y - margin; - const bottom = point2y + margin; - - return point1x >= left && point1x <= right && point1y >= top && point1y <= bottom; -}; - -export const translatePointerRotation = ( x, y, rotationCenterX, rotationCenterY, angleInRadians ) => { - const x2 = x - rotationCenterX; - const y2 = y - rotationCenterY; - - const cos = Math.cos( -angleInRadians ); - const sin = Math.sin( -angleInRadians ); - - return { - x : x2 * cos - y2 * sin + rotationCenterX, - y : x2 * sin + y2 * cos + rotationCenterY - }; -}; - export const getRotationCenter = ({ left, top, width, height }, rounded = false ) => { const x = left + width * .5; const y = top + height * .5; @@ -157,19 +135,6 @@ export const rectangleToCoordinates = ( x, y, width, height ) => [ { x, y }, // back to TL to close selection ]; -export const translatePoints = ( coordinateList, xTranslation = 0, yTranslation = 0 ) => { - return coordinateList.map(({ x, y }) => ({ - x: x + xTranslation, - y: y + yTranslation, - })); -}; - -export const rotatePoints = ( coordinateList, rotationCenterX, rotationCenterY, angleInRadians ) => { - return coordinateList.map(({ x, y }) => { - return translatePointerRotation( x, y, rotationCenterX, rotationCenterY, angleInRadians ); - }); -}; - export const scaleRectangle = ({ left, top, width, height }, scale = 1 ) => { const scaledWidth = width * scale; const scaledHeight = height * scale; diff --git a/src/math/line-math.js b/src/math/line-math.js new file mode 100644 index 0000000..c5ec10f --- /dev/null +++ b/src/math/line-math.js @@ -0,0 +1,79 @@ +/** + * The MIT License (MIT) + * + * Igor Zinken 2020-2021 - https://www.igorski.nl + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +export const calcPathSpline = ( nodes, node, splinePts, resolution = 32 ) => { + const prev = node - 1; + const next = node + 1; + const nextNext = node + 2; + + const maxRes = resolution - 1; + + for ( let n = 0; n < resolution; ++n ) { + const pt = {}; + const p = n / maxRes; + + splinePts.push({ + x : nspline( + p, + nodes[ prev ].x, + nodes[ node ].x, + nodes[ next ].x, + nodes[ nextNext ]?.x + ), + y : nspline( + p, + nodes[ prev ].y, + nodes[ node ].y, + nodes[ next ].y, + nodes[ nextNext ]?.y + ), + }); + } +}; + +/* internal methods */ + +const CR00 = -0.5; +const CR01 = 1.5; +const CR02 = -1.5; +const CR03 = 0.5; +const CR10 = 1.0; +const CR11 = -2.5; +const CR12 = 2.0; +const CR13 = -0.5; +const CR20 = -0.5; +const CR21 = 0.0; +const CR22 = 0.5; +const CR23 = 0.0; +const CR30 = 0.0; +const CR31 = 1.0; +const CR32 = 0.0; +const CR33 = 0.0; + +function nspline( x, f0, f1, f2, f3 ) { + const c3 = CR00 * f0 + CR01 * f1 + CR02 * f2 + CR03 * f3; + const c2 = CR10 * f0 + CR11 * f1 + CR12 * f2 + CR13 * f3; + const c1 = CR20 * f0 + CR21 * f1 + CR22 * f2 + CR23 * f3; + const c0 = CR30 * f0 + CR31 * f1 + CR32 * f2 + CR33 * f3; + + return (( c3 * x + c2 ) * x + c1 ) * x + c0; +} diff --git a/src/math/point-math.js b/src/math/point-math.js new file mode 100644 index 0000000..cc91364 --- /dev/null +++ b/src/math/point-math.js @@ -0,0 +1,66 @@ +/** + * The MIT License (MIT) + * + * Igor Zinken 2020-2021 - https://www.igorski.nl + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +const { pow, sqrt, atan2 } = Math; + +export const distanceBetween = ( point1, point2 ) => { + return sqrt( pow( point2.x - point1.x, 2 ) + pow( point2.y - point1.y, 2 )); +}; + +export const angleBetween = ( point1, point2 ) => { + return atan2( point2.x - point1.x, point2.y - point1.y ); +}; + +export const isPointInRange = ( point1x, point1y, point2x, point2y, margin = 5 ) => { + const left = point2x - margin; + const right = point2x + margin; + const top = point2y - margin; + const bottom = point2y + margin; + + return point1x >= left && point1x <= right && point1y >= top && point1y <= bottom; +}; + +export const translatePointerRotation = ( x, y, rotationCenterX, rotationCenterY, angleInRadians ) => { + const x2 = x - rotationCenterX; + const y2 = y - rotationCenterY; + + const cos = Math.cos( -angleInRadians ); + const sin = Math.sin( -angleInRadians ); + + return { + x : x2 * cos - y2 * sin + rotationCenterX, + y : x2 * sin + y2 * cos + rotationCenterY + }; +}; + +export const translatePoints = ( coordinateList, xTranslation = 0, yTranslation = 0 ) => { + return coordinateList.map(({ x, y }) => ({ + x: x + xTranslation, + y: y + yTranslation, + })); +}; + +export const rotatePoints = ( coordinateList, rotationCenterX, rotationCenterY, angleInRadians ) => { + return coordinateList.map(({ x, y }) => { + return translatePointerRotation( x, y, rotationCenterX, rotationCenterY, angleInRadians ); + }); +}; diff --git a/src/services/keyboard-service.js b/src/services/keyboard-service.js index ae569c4..2f520a6 100644 --- a/src/services/keyboard-service.js +++ b/src/services/keyboard-service.js @@ -26,7 +26,7 @@ import { CREATE_DOCUMENT, ADD_LAYER, EXPORT_DOCUMENT, DROPBOX_FILE_SELECTOR, SAVE_DROPBOX_DOCUMENT } from "@/definitions/modal-windows"; import { getCanvasInstance, getSpriteForLayer } from "@/factories/sprite-factory"; -import { translatePoints } from "@/math/image-math"; +import { translatePoints } from "@/math/point-math"; let state, getters, commit, dispatch, listener, suspended = false, blockDefaults = true, optionDown = false, shiftDown = false; @@ -138,7 +138,7 @@ function handleKeyDown( event ) { switch ( keyCode ) { case 8: // backspace - if ( getters.activeDocument?.selection.length && getters.activeLayer ) { + if ( getters.activeDocument?.selection?.length && getters.activeLayer ) { dispatch( "deleteInSelection" ); } break; diff --git a/src/utils/render-util.js b/src/utils/render-util.js index b3989dd..971031b 100644 --- a/src/utils/render-util.js +++ b/src/utils/render-util.js @@ -20,8 +20,10 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { createCanvas, resizeImage } from "@/utils/canvas-util"; import { LAYER_TEXT } from "@/definitions/layer-types"; +import { createDrawable } from "@/factories/brush-factory"; +import { distanceBetween, angleBetween, translatePointerRotation } from "@/math/point-math"; +import { createCanvas, resizeImage } from "@/utils/canvas-util"; const tempCanvas = createCanvas(); @@ -36,32 +38,63 @@ export const renderCross = ( ctx, x, y, size ) => { ctx.restore(); }; +export const renderBrushStroke = ( sprite, brush, ctx, destinationPoint ) => { + const { pointer, radius, doubleRadius } = brush; + + // effects complicate proceedings https://github.com/igorski/bitmappery/issues/2 + const { rotation, mirrorX, mirrorY } = sprite.layer.effects; + if ( rotation || mirrorX || mirrorY ) { + ctx.fillStyle = createDrawable( brush, ctx, destinationPoint.x, destinationPoint.y ) + ctx.fillRect( destinationPoint.x - radius, destinationPoint.y - radius, doubleRadius, doubleRadius ); + return; + } + + ctx.save(); + ctx.lineJoin = ctx.lineCap = "round"; + + const dist = distanceBetween( pointer, destinationPoint ); + const angle = angleBetween( pointer, destinationPoint ); + + const incr = brush.radius * 0.25; + const sin = Math.sin( angle ); + const cos = Math.cos( angle ); + + let x, y; + for ( let i = 0; i < dist; i += incr ) { + x = pointer.x + ( sin * i ); + y = pointer.y + ( cos * i ); + ctx.fillStyle = createDrawable( brush, ctx, x, y ) + ctx.fillRect( x - radius, y - radius, doubleRadius, doubleRadius ); + } + ctx.restore(); +}; + /** * Masks the contents of given source using given brushCvs, and renders the result onto given destContext. * Used by clone stamp tool. * * @param {CanvasRenderingContext2D} destContext * @param {zCanvas.sprite} sprite - * @param {number} destX destination x-coordinate relative to given destContext - * @param {number} destY destination y-coordinate relative to given destContext * @param {zCanvas.sprite} sourceSprite containing the bitmap to mask (see getBitmap()) - * @param {HTMLCanvasElement} brushCvs mask image to use - * @param {number} maskRadius radius of the mask (determines mask size) + * @param {Object} brush operation to use + * @param {{ x: number, y:number }} destinationPoint coordinate relative to given destContext */ -export const renderMasked = ( destContext, sprite, destX, destY, sourceSprite, brushCvs, maskRadius ) => { +export const renderClonedStroke = ( destContext, sprite, sourceSprite, brush, destinationPoint ) => { + const maskRadius = brush.radius; + const { coords, opacity } = sprite._toolOptions; const source = sourceSprite.getBitmap(); const relSource = sprite._cloneStartCoords ?? sprite._dragStartEventCoordinates; const sourceX = ( coords.x - sourceSprite.getX()) - maskRadius; const sourceY = ( coords.y - sourceSprite.getY()) - maskRadius; - const xDelta = sprite._dragStartOffset.x + (( destX - sprite._bounds.left ) - relSource.x ); - const yDelta = sprite._dragStartOffset.y + (( destY - sprite._bounds.top ) - relSource.y ); + const xDelta = sprite._dragStartOffset.x + (( destinationPoint.x - sprite._bounds.left ) - relSource.x ); + const yDelta = sprite._dragStartOffset.y + (( destinationPoint.y - sprite._bounds.top ) - relSource.y ); // prepare temporary canvas (match size with brush) const { cvs, ctx } = tempCanvas; - cvs.width = brushCvs.width; - cvs.height = brushCvs.height; + cvs.width = brush.doubleRadius; + cvs.height = brush.doubleRadius; ctx.globalAlpha = opacity; @@ -73,12 +106,13 @@ export const renderMasked = ( destContext, sprite, destX, destY, sourceSprite, b // draw the brush above the bitmap, keeping only the overlapping area ctx.globalCompositeOperation = "destination-in"; - ctx.drawImage( brushCvs, 0, 0 ); + // note we use the brush.pointer as the destination too + renderBrushStroke( sprite, brush, ctx, brush.pointer ); // draw the masked result onto the destination canvas destContext.drawImage( cvs, 0, 0, maskRadius, maskRadius, - destX - maskRadius, destY - maskRadius, maskRadius, maskRadius + destinationPoint.x - maskRadius, destinationPoint.y - maskRadius, maskRadius, maskRadius ); }; diff --git a/tests/unit/factories/brush-factory.spec.js b/tests/unit/factories/brush-factory.spec.js new file mode 100644 index 0000000..8eea217 --- /dev/null +++ b/tests/unit/factories/brush-factory.spec.js @@ -0,0 +1,35 @@ +import BrushFactory from "@/factories/brush-factory"; + +describe( "Brush factory", () => { + it( "should be able to create a default brush when no arguments are given", () => { + expect( BrushFactory.create()).toEqual({ + radius: 10, + halfRadius: 5, + doubleRadius: 20, + colors: [ + "rgba(255,0,0,1)", + "rgba(255,0,0,0.5)", + "rgba(255,0,0,0)" + ], + pointer: null, + }); + }); + + it( "should be able to create a brush from given arguments", () => { + expect( BrushFactory.create({ + radius: 20, + color: "rgba(128,123,686,.75)", + pointer: { x: 10, y: 20 } + })).toEqual({ + radius: 20, + halfRadius: 10, + doubleRadius: 40, + colors: [ + "rgba(128,123,686,.75)", + "rgba(128,123,686,0.375)", + "rgba(128,123,686,0)" + ], + pointer: { x: 10, y: 20 } + }); + }); +}); diff --git a/tests/unit/math/image-math.spec.js b/tests/unit/math/image-math.spec.js index 11d6bbe..f0127d4 100644 --- a/tests/unit/math/image-math.spec.js +++ b/tests/unit/math/image-math.spec.js @@ -1,5 +1,5 @@ import { - fastRound, scaleToRatio, constrain, isPortrait, isLandscape, isSquare, translatePoints, scaleRectangle + fastRound, scaleToRatio, constrain, isPortrait, isLandscape, isSquare, scaleRectangle } from "@/math/image-math"; describe( "Image math utilities", () => { @@ -59,15 +59,6 @@ describe( "Image math utilities", () => { }); }); - it( "should be able to translate the coordinates within a list", () => { - const list = [ - { x: 10, y: 10 }, { x: 15, y: 15 } - ]; - expect( translatePoints( list, 5, -5 )).toEqual([ - { x: 15, y: 5 }, { x: 20, y: 10 } - ]); - }); - describe( "when scaling rectangles", () => { it( "should be able to scale a rectangle and maintain its relative offset when scaling up", () => { const rectangle = { diff --git a/tests/unit/math/point-math.spec.js b/tests/unit/math/point-math.spec.js new file mode 100644 index 0000000..951b0e5 --- /dev/null +++ b/tests/unit/math/point-math.spec.js @@ -0,0 +1,12 @@ +import { translatePoints } from "@/math/point-math"; + +describe( "Point math", () => { + it( "should be able to translate the coordinates within a list", () => { + const list = [ + { x: 10, y: 10 }, { x: 15, y: 15 } + ]; + expect( translatePoints( list, 5, -5 )).toEqual([ + { x: 15, y: 5 }, { x: 20, y: 10 } + ]); + }); +});