From eb05086116372fcf5f1e1f4447f6a36e5fe95aab Mon Sep 17 00:00:00 2001 From: Igor Zinken Date: Mon, 13 Sep 2021 21:10:35 +0200 Subject: [PATCH] Snap and align now also works on rotated content. Moved math utilities to appropriate namespaces --- .../tool-options-rotate.vue | 2 +- src/math/image-math.js | 70 +---------------- src/math/rectangle-math.js | 77 +++++++++++++++++++ src/math/unit-math.js | 6 ++ .../canvas-elements/guide-renderer.js | 2 +- .../canvas-elements/interaction-pane.js | 2 +- src/rendering/canvas-elements/layer-sprite.js | 2 +- .../canvas-elements/zoomable-canvas.js | 2 +- src/rendering/text.js | 2 +- src/services/render-service.js | 6 +- src/utils/document-util.js | 9 ++- tests/unit/math/image-math.spec.js | 46 +---------- tests/unit/math/rectangle-math.spec.js | 34 +++++++- tests/unit/math/unit-math.spec.js | 13 ++++ 14 files changed, 145 insertions(+), 128 deletions(-) create mode 100644 tests/unit/math/unit-math.spec.js diff --git a/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue b/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue index 6a6ea4d..36a0328 100644 --- a/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue +++ b/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue @@ -66,7 +66,7 @@ import ToolTypes, { MIN_ZOOM, MAX_ZOOM } from "@/definitions/tool-types"; import Slider from "@/components/ui/slider/slider"; import { enqueueState } from "@/factories/history-state-factory"; import messages from "./messages.json"; -import { degreesToRadians, radiansToDegrees } from "@/math/image-math"; +import { degreesToRadians, radiansToDegrees } from "@/math/unit-math"; export default { i18n: { messages }, diff --git a/src/math/image-math.js b/src/math/image-math.js index 1a29623..5099fcb 100644 --- a/src/math/image-math.js +++ b/src/math/image-math.js @@ -20,11 +20,7 @@ * 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 degreesToRadians = deg => deg * Math.PI / 180; -export const radiansToDegrees = rad => rad * 180 / Math.PI; - -// Safari greatly benefits from round numbers as subpixel content is sometimes ommitted from rendering... -export const fastRound = num => num > 0 ? ( num + .5 ) << 0 : num | 0; +import { fastRound } from "@/math/unit-math"; /** * Calculates the appropriate dimensions for fitting an image of dimensions @@ -81,67 +77,3 @@ export const constrain = ( width, height, maxMegaPixel ) => { export const isPortrait = ( width, height ) => width < height; export const isLandscape = ( width, height ) => width > height; export const isSquare = ( width, height ) => width === height; - -export const getRotationCenter = ({ left, top, width, height }, rounded = false ) => { - const x = left + width * .5; - const y = top + height * .5; - return { - x: rounded ? fastRound( x ) : x, - y: rounded ? fastRound( y ) : y, - }; -}; - -export const getRotatedSize = ({ width, height }, angleInRadians, rounded = false ) => { - const x1 = -width * .5, - x2 = width * .5, - x3 = width * .5, - x4 = -width * .5, - y1 = height * .5, - y2 = height * .5, - y3 = -height * .5, - y4 = -height * .5; - - const cos = Math.cos( angleInRadians ); - const sin = Math.sin( angleInRadians ); - - const x11 = x1 * cos + y1 * sin, - y11 = -x1 * sin + y1 * cos, - x21 = x2 * cos + y2 * sin, - y21 = -x2 * sin + y2 * cos, - x31 = x3 * cos + y3 * sin, - y31 = -x3 * sin + y3 * cos, - x41 = x4 * cos + y4 * sin, - y41 = -x4 * sin + y4 * cos; - - const xMin = Math.min( x11, x21, x31, x41 ), - xMax = Math.max( x11, x21, x31, x41 ), - yMin = Math.min( y11, y21, y31, y41 ), - yMax = Math.max( y11, y21, y31, y41 ); - - const out = { - width : xMax - xMin, - height : yMax - yMin - }; - if ( rounded ) { - out.width = fastRound( out.width ); - out.height = fastRound( out.height ); - } - return out; -}; - -export const rectangleToCoordinates = ( x, y, width, height ) => [ - { x, y }, { x: x + width, y }, // TL to TR - { x: x + width, y: y + height }, { x, y: y + height }, // BR to BL - { x, y }, // back to TL to close selection -]; - -export const scaleRectangle = ({ left, top, width, height }, scale = 1 ) => { - const scaledWidth = width * scale; - const scaledHeight = height * scale; - return { - left : left - ( scaledWidth * .5 - width * .5 ), - top : top - ( scaledHeight * .5 - height * .5 ), - width : scaledWidth, - height : scaledHeight - }; -}; diff --git a/src/math/rectangle-math.js b/src/math/rectangle-math.js index 69291c3..8eedf5f 100644 --- a/src/math/rectangle-math.js +++ b/src/math/rectangle-math.js @@ -20,6 +20,83 @@ * 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 { fastRound } from "@/math/unit-math"; + +const HALF = 0.5; + +export const getRotationCenter = ({ left, top, width, height }, rounded = false ) => { + const x = left + width * HALF; + const y = top + height * HALF; + return { + x: rounded ? fastRound( x ) : x, + y: rounded ? fastRound( y ) : y, + }; +}; + +export const rotateRectangle = ( rectangle, angleInRadians = 0, rounded = false ) => { + if ( angleInRadians === 0 ) { + return rectangle; + } + const { x, y, width, height } = rectangle; + const x1 = -width * HALF, + x2 = width * HALF, + x3 = width * HALF, + x4 = -width * HALF, + y1 = height * HALF, + y2 = height * HALF, + y3 = -height * HALF, + y4 = -height * HALF; + + const cos = Math.cos( angleInRadians ); + const sin = Math.sin( angleInRadians ); + + const x11 = x1 * cos + y1 * sin, + y11 = -x1 * sin + y1 * cos, + x21 = x2 * cos + y2 * sin, + y21 = -x2 * sin + y2 * cos, + x31 = x3 * cos + y3 * sin, + y31 = -x3 * sin + y3 * cos, + x41 = x4 * cos + y4 * sin, + y41 = -x4 * sin + y4 * cos; + + const xMin = Math.min( x11, x21, x31, x41 ), + xMax = Math.max( x11, x21, x31, x41 ), + yMin = Math.min( y11, y21, y31, y41 ), + yMax = Math.max( y11, y21, y31, y41 ); + + const out = { + width : xMax - xMin, + height : yMax - yMin + }; + out.x = x - ( out.width / 2 - width / 2 ); + out.y = y - ( out.height / 2 - height / 2 ); + + if ( rounded ) { + out.x = fastRound( out.x ); + out.y = fastRound( out.y ); + out.width = fastRound( out.width ); + out.height = fastRound( out.height ); + } + return out; +}; + +export const rectangleToCoordinates = ( x, y, width, height ) => [ + { x, y }, { x: x + width, y }, // TL to TR + { x: x + width, y: y + height }, { x, y: y + height }, // BR to BL + { x, y }, // back to TL to close selection +]; + +export const scaleRectangle = ({ left, top, width, height }, scale = 1 ) => { + const scaledWidth = width * scale; + const scaledHeight = height * scale; + return { + left : left - ( scaledWidth * HALF - width * HALF ), + top : top - ( scaledHeight * HALF - height * HALF ), + width : scaledWidth, + height : scaledHeight + }; +}; + export const areEqual = ( rect1, rect2 ) => { return ( rect1.left === rect2.left && diff --git a/src/math/unit-math.js b/src/math/unit-math.js index c175975..bc4a013 100644 --- a/src/math/unit-math.js +++ b/src/math/unit-math.js @@ -20,6 +20,12 @@ * 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 degreesToRadians = deg => deg * Math.PI / 180; +export const radiansToDegrees = rad => rad * 180 / Math.PI; + +// Safari greatly benefits from round numbers as subpixel content is sometimes ommitted from rendering... +export const fastRound = num => num > 0 ? ( num + .5 ) << 0 : num | 0; + const CM_PER_INCH = 2.54; const MM_PER_INCH = CM_PER_INCH * 10; diff --git a/src/rendering/canvas-elements/guide-renderer.js b/src/rendering/canvas-elements/guide-renderer.js index 66528fe..6a6450e 100644 --- a/src/rendering/canvas-elements/guide-renderer.js +++ b/src/rendering/canvas-elements/guide-renderer.js @@ -22,7 +22,7 @@ */ import { sprite } from "zcanvas"; import { SNAP_MARGIN } from "@/definitions/tool-types"; -import { fastRound } from "@/math/image-math"; +import { fastRound } from "@/math/unit-math"; import { isCoordinateInHorizontalRange, isCoordinateInVerticalRange } from "@/math/point-math"; import { getClosestSnappingPoints } from "@/rendering/snapping"; diff --git a/src/rendering/canvas-elements/interaction-pane.js b/src/rendering/canvas-elements/interaction-pane.js index 1b26f2a..c8e1280 100644 --- a/src/rendering/canvas-elements/interaction-pane.js +++ b/src/rendering/canvas-elements/interaction-pane.js @@ -26,7 +26,7 @@ import { isInsideTransparentArea } from "@/utils/canvas-util"; import { enqueueState } from "@/factories/history-state-factory"; import { getCanvasInstance, getSpriteForLayer } from "@/factories/sprite-factory"; import { isPointInRange, translatePoints, snapToAngle } from "@/math/point-math"; -import { rectangleToCoordinates } from "@/math/image-math"; +import { rectangleToCoordinates } from "@/math/rectangle-math"; import { isSelectionClosed, createSelectionForRectangle } from "@/math/selection-math"; import ToolTypes from "@/definitions/tool-types"; import LayerSprite from "@/rendering/canvas-elements/layer-sprite"; diff --git a/src/rendering/canvas-elements/layer-sprite.js b/src/rendering/canvas-elements/layer-sprite.js index c694624..3868277 100644 --- a/src/rendering/canvas-elements/layer-sprite.js +++ b/src/rendering/canvas-elements/layer-sprite.js @@ -28,7 +28,7 @@ import { blobToResource } from "@/utils/resource-manager"; import { LAYER_GRAPHIC, LAYER_MASK, LAYER_TEXT } from "@/definitions/layer-types"; import { getSizeForBrush } from "@/definitions/brush-types"; import { getRectangleForSelection, isSelectionClosed } from "@/math/selection-math"; -import { scaleRectangle } from "@/math/image-math"; +import { scaleRectangle } from "@/math/rectangle-math"; import { translatePointerRotation } from "@/math/point-math"; import { renderEffectsForLayer } from "@/services/render-service"; import { clipContextToSelection } from "@/rendering/clipping"; diff --git a/src/rendering/canvas-elements/zoomable-canvas.js b/src/rendering/canvas-elements/zoomable-canvas.js index dc536cb..c6d542c 100644 --- a/src/rendering/canvas-elements/zoomable-canvas.js +++ b/src/rendering/canvas-elements/zoomable-canvas.js @@ -23,7 +23,7 @@ import Vue from "vue"; import { canvas } from "zcanvas"; import InteractionPane from "@/rendering/canvas-elements/interaction-pane"; -import { fastRound } from "@/math/image-math"; +import { fastRound } from "@/math/unit-math"; import { renderState } from "@/services/render-service"; class ZoomableCanvas extends canvas { diff --git a/src/rendering/text.js b/src/rendering/text.js index ea6b6ba..fea30ec 100644 --- a/src/rendering/text.js +++ b/src/rendering/text.js @@ -20,7 +20,7 @@ * 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 { fastRound } from "@/math/image-math"; +import { fastRound } from "@/math/unit-math"; /** * Renders a Layers text Object as multi line text onto given context. diff --git a/src/services/render-service.js b/src/services/render-service.js index 4060b99..83f4cb2 100644 --- a/src/services/render-service.js +++ b/src/services/render-service.js @@ -28,7 +28,7 @@ import { hasFilters, isEqual as isFiltersEqual } from "@/factories/filters-facto import { isEqual as isTextEqual } from "@/factories/text-factory"; import { createCanvas, cloneCanvas, matchDimensions } from "@/utils/canvas-util"; import { replaceLayerSource } from "@/utils/layer-util"; -import { getRotatedSize, getRotationCenter } from "@/math/image-math"; +import { rotateRectangle, getRotationCenter } from "@/math/rectangle-math"; import { hasLayerCache, getLayerCache, setLayerCache } from "@/rendering/cache/bitmap-cache"; import { renderMultiLineText } from "@/rendering/text"; import { loadGoogleFont } from "@/services/font-service"; @@ -63,7 +63,7 @@ export const renderEffectsForLayer = async ( layer, useCaching = true ) => { ++renderState.pending; // if source is rotated, calculate the width and height for the current rotation - let { width, height } = getRotatedSize( layer, effects.rotation, true ); + let { width, height } = rotateRectangle( layer, effects.rotation, true ); const { cvs, ctx } = createCanvas( width, height ); const cached = useCaching ? getLayerCache( layer ) : null; @@ -89,7 +89,7 @@ export const renderEffectsForLayer = async ( layer, useCaching = true ) => { hasCachedFilter = false; // new contents need to be refiltered } // update dimensions as text shrinks/expands to fit - ({ width, height } = getRotatedSize( textData, effects.rotation, true )); + ({ width, height } = rotateRectangle( textData, effects.rotation, true )); matchDimensions({ width, height }, cvs ); // render text onto destination source ctx.drawImage( textData, 0, 0 ); diff --git a/src/utils/document-util.js b/src/utils/document-util.js index 58d5776..52d8af5 100644 --- a/src/utils/document-util.js +++ b/src/utils/document-util.js @@ -26,7 +26,7 @@ import { renderEffectsForLayer } from "@/services/render-service"; import { getSpriteForLayer } from "@/factories/sprite-factory"; import { createCanvas } from "@/utils/canvas-util"; import { createInverseClipping } from "@/rendering/clipping"; -import { areEqual } from "@/math/rectangle-math"; +import { rotateRectangle, areEqual } from "@/math/rectangle-math"; import { getRectangleForSelection, isSelectionRectangular } from "@/math/selection-math"; /** @@ -143,7 +143,9 @@ export const deleteSelectionContent = ( activeDocument, activeLayer ) => { export const getAlignableObjects = ( document, excludeLayer ) => { // create a rectangle describing the document boundaries - const documentBounds = { x: 0, y: 0, width: document.width, height: document.height, visible: true }; + const documentBounds = { + x: 0, y: 0, width: document.width, height: document.height, visible: true + }; // create bounding boxes for all eligible objects return [ documentBounds, ...document.layers ].reduce(( acc, object ) => { // ignore this object in case @@ -155,8 +157,7 @@ export const getAlignableObjects = ( document, excludeLayer ) => { ( excludeLayer && object.id === excludeLayer.id )) { return acc; } - // TODO take rotation into account ? - const { x, y, width, height } = object; + const { x, y, width, height } = rotateRectangle( object, object.effects?.rotation ); // 1. vertical top, center and bottom let guideWidth = document.width, guideHeight = 0; if ( y > 0 ) { diff --git a/tests/unit/math/image-math.spec.js b/tests/unit/math/image-math.spec.js index f0127d4..4ef4cb6 100644 --- a/tests/unit/math/image-math.spec.js +++ b/tests/unit/math/image-math.spec.js @@ -1,18 +1,6 @@ -import { - fastRound, scaleToRatio, constrain, isPortrait, isLandscape, isSquare, scaleRectangle -} from "@/math/image-math"; +import { scaleToRatio, constrain, isPortrait, isLandscape, isSquare } from "@/math/image-math"; describe( "Image math utilities", () => { - describe( "When rounding numbers", () => { - it( "should be able to round positive numbers", () => { - expect( fastRound( 13.67689767 )).toEqual( 14 ); - }); - - it( "should be able to round negative numbers", () => { - expect( fastRound( -12.534523 )).toEqual( -12 ); - }); - }); - describe( "When constrainting an image to the maximum supported size in megapixels", () => { it ( "should not adjust the dimensions of images below this threshold", () => { expect( constrain( 7999, 7999, 8000 * 8000 )).toEqual({ width: 7999, height: 7999 }); @@ -58,36 +46,4 @@ describe( "Image math utilities", () => { expect( isSquare( SQUARE.width, SQUARE.height )).toBe( true ); }); }); - - describe( "when scaling rectangles", () => { - it( "should be able to scale a rectangle and maintain its relative offset when scaling up", () => { - const rectangle = { - left: 50, - top: 50, - width: 200, - height: 400 - }; - expect( scaleRectangle( rectangle, 2 )).toEqual({ - left: -50, - top: -150, - width: 400, - height: 800 - }); - }); - - it( "should be able to scale a rectangle and maintain its relative offset when scaling down", () => { - const rectangle = { - left: -50, - top: -150, - width: 400, - height: 800 - }; - expect( scaleRectangle( rectangle, 0.5 )).toEqual({ - left: 50, - top: 50, - width: 200, - height: 400 - }); - }); - }); }); diff --git a/tests/unit/math/rectangle-math.spec.js b/tests/unit/math/rectangle-math.spec.js index 947efd3..2e3f31d 100644 --- a/tests/unit/math/rectangle-math.spec.js +++ b/tests/unit/math/rectangle-math.spec.js @@ -1,4 +1,4 @@ -import { areEqual } from "@/math/rectangle-math"; +import { areEqual, scaleRectangle } from "@/math/rectangle-math"; describe( "Rectangle math utilities", () => { describe( "When determining the equality of two given rectangles", () => { @@ -40,4 +40,36 @@ describe( "Rectangle math utilities", () => { expect( areEqual( rect1, rect2 )).toBe( true ); }); }); + + describe( "when scaling rectangles", () => { + it( "should be able to scale a rectangle and maintain its relative offset when scaling up", () => { + const rectangle = { + left: 50, + top: 50, + width: 200, + height: 400 + }; + expect( scaleRectangle( rectangle, 2 )).toEqual({ + left: -50, + top: -150, + width: 400, + height: 800 + }); + }); + + it( "should be able to scale a rectangle and maintain its relative offset when scaling down", () => { + const rectangle = { + left: -50, + top: -150, + width: 400, + height: 800 + }; + expect( scaleRectangle( rectangle, 0.5 )).toEqual({ + left: 50, + top: 50, + width: 200, + height: 400 + }); + }); + }); }); diff --git a/tests/unit/math/unit-math.spec.js b/tests/unit/math/unit-math.spec.js new file mode 100644 index 0000000..ccc7148 --- /dev/null +++ b/tests/unit/math/unit-math.spec.js @@ -0,0 +1,13 @@ +import { fastRound } from "@/math/unit-math"; + +describe( "Unit math utilities", () => { + describe( "When rounding numbers", () => { + it( "should be able to round positive numbers", () => { + expect( fastRound( 13.67689767 )).toEqual( 14 ); + }); + + it( "should be able to round negative numbers", () => { + expect( fastRound( -12.534523 )).toEqual( -12 ); + }); + }); +});