Implemented layer scaling

This commit is contained in:
Igor Zinken
2021-01-16 12:21:45 +01:00
parent 4b32f7a0da
commit a07e4e7c46
17 changed files with 241 additions and 21 deletions

View File

@@ -143,6 +143,7 @@ further benchmarking and tweaking.
# TODO / Roadmap
* 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
@@ -150,7 +151,5 @@ further benchmarking and tweaking.
* Restored base64 images should be treated as binary once more (see layer-factory)
* Zoom set original size isn't that accurate (check also on mobile views), needs calculateMaxScaling ?
* Unload Blobs when images are no longer used in document (see sprite-factory disposeSprite, keep instance count of usages)
* Implement layer sorting and opacity
* Implement layer scaling
* Implement merged layer selection
* Scale logic should move from zoomable-canvas into zCanvas (as handleInteraction needs to transform offsets by zoom ratio, see LayerSprite!)

View File

@@ -86,6 +86,8 @@ export default {
return () => import( "./tool-options-clone/tool-options-clone" );
case ToolTypes.ROTATE:
return () => import( "./tool-options-rotate/tool-options-rotate" );
case ToolTypes.SCALE:
return () => import( "./tool-options-scale/tool-options-scale" );
case ToolTypes.MIRROR:
return () => import( "./tool-options-mirror/tool-options-mirror" );
case ToolTypes.TEXT:

View File

@@ -1,5 +1,6 @@
{
"en-US": {
"rotation": "Rotation"
"rotation": "Rotation",
"reset": "Reset"
}
}

View File

@@ -1,7 +1,7 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020 - https://www.igorski.nl
* 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
@@ -29,6 +29,14 @@
:max="max"
:tooltip="'none'"
/>
<div class="actions">
<button
v-t="'reset'"
type="button"
class="button button--small"
@click="reset()"
></button>
</div>
</div>
</template>
@@ -82,7 +90,10 @@ export default {
commit();
},
});
}
},
reset() {
this.rotation = 0;
},
},
};
</script>

View File

@@ -0,0 +1,6 @@
{
"en-US": {
"scale": "Scale",
"reset": "Reset"
}
}

View File

@@ -0,0 +1,101 @@
/**
* 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.
*/
<template>
<div class="tool-option">
<h3 v-t="'scale'"></h3>
<slider
v-model="scale"
:min="min"
:max="max"
:tooltip="'none'"
/>
<div class="actions">
<button
v-t="'reset'"
type="button"
class="button button--small"
@click="reset()"
></button>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
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 { scale } from "@/math/unit-math";
import messages from "./messages.json";
export default {
i18n: { messages },
components: {
Slider,
},
data: () => ({
min: MIN_ZOOM,
max: MAX_ZOOM,
}),
computed: {
...mapGetters([
"activeLayerIndex",
"activeLayerEffects",
]),
scale: {
get() {
return ( this.activeLayerEffects.scale - 1 ) * MAX_ZOOM;
},
set( value ) {
this.update( scale( value, MAX_ZOOM, 1 ) + 1 );
}
}
},
methods: {
update( scale ) {
const oldScale = this.activeLayerEffects.scale;
const index = this.activeLayerIndex;
const store = this.$store;
const commit = () => {
store.commit( "updateLayerEffects", { index, effects: { scale } });
};
commit();
enqueueState( `scale_${index}`, {
undo() {
store.commit( "updateLayerEffects", { index, effects: { scale: oldScale } });
},
redo() {
commit();
},
});
},
reset() {
this.scale = 0;
},
},
};
</script>
<style lang="scss" scoped>
@import "@/styles/options-panel";
</style>

View File

@@ -15,6 +15,7 @@
"brush": "Brush",
"eraser": "Eraser",
"cloneStamp": "Clone stamp",
"scaleLayer": "Scale layer",
"text": "Text",
"newTextLayer": "New text layer",
"color": "Color"

View File

@@ -167,6 +167,11 @@ export default {
i18n: "cloneStamp", icon: "stamp", key: "S",
disabled: !clonable, hasOptions: true
},
{
type: ToolTypes.SCALE,
i18n: "scaleLayer", icon: "resize", key: "D",
disabled: !this.activeLayer, hasOptions: true,
},
{
type: ToolTypes.EYEDROPPER,
i18n: "eyedropper", icon: "eyedropper", key: "I",

View File

@@ -25,7 +25,7 @@ import { sprite } from "zcanvas"
import { createCanvas, cloneCanvas, resizeImage, globalToLocal } from "@/utils/canvas-util";
import { renderCross, renderMasked } from "@/utils/render-util";
import { LAYER_GRAPHIC, LAYER_MASK, LAYER_TEXT } from "@/definitions/layer-types";
import { translatePointerRotation, rotatePoints } from "@/math/image-math";
import { translatePointerRotation, rotatePoints, scaleRectangle } from "@/math/image-math";
import { getRectangleForSelection, isSelectionClosed } from "@/math/selection-math";
import { renderEffectsForLayer } from "@/services/render-service";
import { flushLayerCache, clearCacheProperty } from "@/services/caches/bitmap-cache";
@@ -87,6 +87,10 @@ class LayerSprite extends sprite {
return ( this.layer.effects.rotation % 360 ) !== 0;
}
isScaled() {
return this.layer.effects.scale !== 1;
}
cacheBrush( color, radius = 30 ) {
this._radius = radius;
@@ -185,7 +189,7 @@ class LayerSprite extends sprite {
this.forceMoveListener();
this.setDraggable( true );
this._isPaintMode = true;
this.cacheBrush( this.canvas.store.getters.activeColor, toolOptions.size );
this.cacheBrush( this.canvas.store.getters.activeColor, toolOptions?.size );
// drawable tools can work alongside an existing selection
const selection = activeDocument.selection;
@@ -348,6 +352,7 @@ class LayerSprite extends sprite {
if ( width === 0 || height === 0 ) {
({ width, height } = bounds );
}
// commit change
super.setBounds( x, y, width, height );
@@ -451,6 +456,22 @@ class LayerSprite extends sprite {
}
draw( documentContext, viewport ) {
const scaleDocument = this.isScaled();
// in case Layer has scale effect, apply it here (we don't resample the
// actual Layer source to make this behaviour non-destructive, it's
// merely a visualization and thus renderer affair)
if ( scaleDocument ) {
const { scale } = this.layer.effects;
const { left, top, width, height } = this._bounds;
const xTranslation = ( left + width * 0.5 ) - viewport.left;
const yTranslation = ( top + height * 0.5 ) - viewport.top;
documentContext.save();
documentContext.translate( xTranslation, yTranslation );
documentContext.scale( scale, scale );
documentContext.translate( -xTranslation, -yTranslation );
}
// invoke base class behaviour to render bitmap
super.draw( documentContext, viewport );
@@ -484,6 +505,7 @@ class LayerSprite extends sprite {
// interactive state implies the sprite's Layer is currently active
// show a border around the Layer contents to indicate the active area
if ( this._interactive ) {
documentContext.save();
documentContext.lineWidth = 1 / this.canvas.zoomFactor;
@@ -501,6 +523,10 @@ class LayerSprite extends sprite {
documentContext.strokeRect( destX, destY, width, height );
documentContext.restore();
}
if ( scaleDocument ) {
documentContext.restore();
}
}
dispose() {
@@ -517,6 +543,13 @@ export default LayerSprite;
/* internal non-instance methods */
function scaleViewport( viewport, scale ) {
const scaled = scaleRectangle( viewport, scale );
viewport.right = viewport.left + viewport.width;
viewport.bottom = viewport.top + viewport.height;
return scaled;
}
// NOTE we use getSpriteForLayer() instead of passing the Sprite by reference
// as it is possible the Sprite originally rendering the Layer has been disposed
// and a new one has been created while traversing the change history

View File

@@ -29,6 +29,7 @@ const ToolTypes = {
DRAG : "drag", // drags layer within document
LASSO : "lasso",
SELECTION : "selection",
SCALE : "scale",
EYEDROPPER : "eyedropper",
ROTATE : "rotate",
FILL : "fill",

View File

@@ -21,8 +21,9 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const EffectFactory = {
create({ rotation = 0, mirrorX = false, mirrorY = false } = {}) {
create({ scale = 1, rotation = 0, mirrorX = false, mirrorY = false } = {}) {
return {
scale,
rotation,
mirrorX,
mirrorY,
@@ -35,6 +36,7 @@ const EffectFactory = {
*/
serialize( effects ) {
return {
s: effects.scale,
r: effects.rotation,
x: effects.mirrorX,
y: effects.mirrorY,
@@ -47,16 +49,18 @@ const EffectFactory = {
*/
deserialize( effects = {} ) {
return EffectFactory.create({
rotation: effects.r,
mirrorX: effects.x,
mirrorY: effects.y,
scale : effects.s,
rotation : effects.r,
mirrorX : effects.x,
mirrorY : effects.y,
});
}
};
export default EffectFactory;
export const isEqual = ( effects, effectsToCompare = {} ) => {
return effects.rotation === effectsToCompare.rotation &&
effects.mirrorX === effectsToCompare.mirrorX &&
return effects.scale === effectsToCompare.scale &&
effects.rotation === effectsToCompare.rotation &&
effects.mirrorX === effectsToCompare.mirrorX &&
effects.mirrorY === effectsToCompare.mirrorY;
};

View File

@@ -169,3 +169,14 @@ export const rotatePoints = ( coordinateList, rotationCenterX, rotationCenterY,
return translatePointerRotation( x, y, rotationCenterX, rotationCenterY, angleInRadians );
});
};
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
};
};

View File

@@ -29,3 +29,11 @@ export const pixelsToMm = ( pixels, dpi = 72 ) => pixelsToInch( pixels, dpi
export const inchesToPixels = ( inches, dpi = 72 ) => inches * dpi;
export const cmToPixels = ( cms, dpi = 72 ) => inchesToPixels( cms / CM_PER_INCH );
export const mmToPixels = ( mms, dpi = 72 ) => inchesToPixels( mms / MM_PER_INCH );
// convenience method to scale given value and its expected maxValue against
// an arbitrary range (defined by maxCompareValue in relation to maxValue)
export const scale = ( value, maxValue, maxCompareValue ) => {
const ratio = maxCompareValue / maxValue;
return Math.min( maxValue, value ) * ratio;
};

View File

@@ -231,6 +231,8 @@ function handleKeyDown( event ) {
if ( hasOption ) {
dispatch( "clearSelection" );
preventDefault( event ); // bookmark
} else if ( getters.activeLayer ) {
setActiveTool( ToolTypes.SCALE );
}
break;

View File

@@ -1,10 +1,9 @@
@import "_mixins";
.tool-option {
border-top: 1px dashed $color-lines;
border-bottom: 1px dashed $color-lines;
margin: $spacing-medium 0;
padding: $spacing-small 0;
margin: 0 0 $spacing-medium 0;
padding: 0 0 $spacing-medium 0;
h3 {
color: #FFF;

View File

@@ -7,7 +7,8 @@ describe( "Effects factory", () => {
expect( effects ).toEqual({
mirrorX: false,
mirrorY: false,
rotation: expect.any( Number )
rotation: expect.any( Number ),
scale: 1
});
});
@@ -15,12 +16,14 @@ describe( "Effects factory", () => {
const effects = EffectsFactory.create({
mirrorX: true,
mirrorY: true,
rotation: -90
rotation: -90,
scale: 2
});
expect( effects ).toEqual({
mirrorX: true,
mirrorY: true,
rotation: -90
rotation: -90,
scale: 2
});
});
});
@@ -30,7 +33,8 @@ describe( "Effects factory", () => {
const effects = EffectsFactory.create({
mirrorX: true,
mirrorY: true,
rotation: 270
rotation: 270,
scale: .5
});
const serialized = EffectsFactory.serialize( effects );
const deserialized = EffectsFactory.deserialize( serialized );

View File

@@ -1,5 +1,5 @@
import {
fastRound, scaleToRatio, constrain, isPortrait, isLandscape, isSquare, translatePoints,
fastRound, scaleToRatio, constrain, isPortrait, isLandscape, isSquare, translatePoints, scaleRectangle
} from "@/math/image-math";
describe( "Image math utilities", () => {
@@ -67,4 +67,36 @@ describe( "Image math utilities", () => {
{ 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 = {
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
});
});
});
});