Implement continuous brushing (#6)

Implementation of continuous brushing added
This commit is contained in:
Igor Zinken
2021-01-16 17:21:44 +01:00
committed by GitHub
parent a07e4e7c46
commit 2f9013155f
13 changed files with 322 additions and 119 deletions

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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;

79
src/math/line-math.js Normal file
View File

@@ -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;
}

66
src/math/point-math.js Normal file
View File

@@ -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 );
});
};

View File

@@ -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;

View File

@@ -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
);
};

View File

@@ -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 }
});
});
});

View File

@@ -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 = {

View File

@@ -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 }
]);
});
});