From 846c80603491aea128655593e25f5d7bffefb032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrg=20Lehni?= Date: Tue, 13 May 2014 13:38:51 +0200 Subject: [PATCH] Implement non-scaling strokes through Style#strokeScaling. Closes #418. --- examples/JSON/Shapes.html | 39 +++++++---- examples/Scripts/Shapes.html | 45 +++++------- examples/Scripts/StrokeScaling.html | 66 ++++++++++++++++++ src/basic/Matrix.js | 2 +- src/item/Item.js | 60 ++++++++++++---- src/item/Shape.js | 102 ++++++++++++++++------------ src/path/CompoundPath.js | 4 +- src/path/Path.js | 43 ++++++------ src/path/PathFlattener.js | 4 +- src/style/Style.js | 21 +++++- src/svg/SVGExport.js | 6 +- src/svg/SVGStyles.js | 11 +++ 12 files changed, 277 insertions(+), 126 deletions(-) create mode 100644 examples/Scripts/StrokeScaling.html diff --git a/examples/JSON/Shapes.html b/examples/JSON/Shapes.html index 1314900d..6bd836a2 100644 --- a/examples/JSON/Shapes.html +++ b/examples/JSON/Shapes.html @@ -6,24 +6,39 @@ diff --git a/examples/Scripts/StrokeScaling.html b/examples/Scripts/StrokeScaling.html new file mode 100644 index 00000000..a7c79fa8 --- /dev/null +++ b/examples/Scripts/StrokeScaling.html @@ -0,0 +1,66 @@ + + + + + Shapes + + + + + + + + + diff --git a/src/basic/Matrix.js b/src/basic/Matrix.js index 0ee17058..63bf520b 100644 --- a/src/basic/Matrix.js +++ b/src/basic/Matrix.js @@ -437,7 +437,7 @@ var Matrix = Base.extend(/** @lends Matrix# */{ * as x, y value pairs * @param {Number[]} dst the array into which to store the transformed * point pairs - * @param {Number} count the number of points to tranform + * @param {Number} count the number of points to transform * @return {Number[]} the dst array, containing the transformed coordinates. */ transform: function(/* point | */ src, dst, count) { diff --git a/src/item/Item.js b/src/item/Item.js index 56ab71e4..1d97d2de 100644 --- a/src/item/Item.js +++ b/src/item/Item.js @@ -2546,8 +2546,8 @@ var Item = Base.extend(Callback, /** @lends Item# */{ */ /** - * The shape to be used at the end of open {@link Path} items, when they - * have a stroke. + * The shape to be used at the beginning and end of open {@link Path} items, + * when they have a stroke. * * @name Item#strokeCap * @property @@ -2579,7 +2579,8 @@ var Item = Base.extend(Callback, /** @lends Item# */{ */ /** - * The shape to be used at the corners of paths when they have a stroke. + * The shape to be used at the segments and corners of {@link Path} items + * when they have a stroke. * * @name Item#strokeJoin * @property @@ -2615,6 +2616,17 @@ var Item = Base.extend(Callback, /** @lends Item# */{ * @type Number */ + /** + * Specifies whether the stroke is to be drawn taking the current affine + * transformation into account (the default behavior), or whether it should + * appear as a non-scaling stroke. + * + * @name Style#strokeScaling + * @property + * @default true + * @type Boolean + */ + /** * Specifies an array containing the dash and gap lengths of the stroke. * @@ -3574,7 +3586,7 @@ var Item = Base.extend(Callback, /** @lends Item# */{ } }, - draw: function(ctx, param) { + draw: function(ctx, param, parentStrokeMatrix) { // Each time the project gets drawn, it's _updateVersion is increased. // Keep the _updateVersion of drawn items in sync, so we have an easy // way to know for which selected items we need to draw selection info. @@ -3631,7 +3643,9 @@ var Item = Base.extend(Callback, /** @lends Item# */{ // If native blending is possible, see if the item allows it || (nativeBlend || normalBlend && opacity < 1) && this._canComposite(), + pixelRatio = param.pixelRatio, mainCtx, itemOffset, prevOffset; + if (!direct) { // Apply the parent's global matrix to the calculation of correct // bounds. @@ -3648,28 +3662,48 @@ var Item = Base.extend(Callback, /** @lends Item# */{ // it, instead of the mainCtx. mainCtx = ctx; ctx = CanvasProvider.getContext( - bounds.getSize().ceil().add(new Size(1, 1)), - param.pixelRatio); + bounds.getSize().ceil().add(new Size(1, 1)), pixelRatio); } ctx.save(); + // Get the transformation matrix for non-scaling strokes. + var strokeMatrix = parentStrokeMatrix + ? parentStrokeMatrix.clone().concatenate(matrix) + : !this.getStrokeScaling() && getViewMatrix(globalMatrix), + // If we're drawing into a separate canvas and a clipItem is defined + // for the current rendering loop, we need to draw the clip item + // again. + clip = !direct && param.clipItem, + // If we're drawing with a strokeMatrix, the CTM is reset either way + // so we don't need to set it, except when we also have to draw a + // clipItem. + transform = !strokeMatrix || clip; // If drawing directly, handle opacity and native blending now, // otherwise we will do it later when the temporary canvas is composited. if (direct) { ctx.globalAlpha = opacity; if (nativeBlend) ctx.globalCompositeOperation = blendMode; - } else { + } else if (transform) { // Translate the context so the topLeft of the item is at (0, 0) // on the temporary canvas. ctx.translate(-itemOffset.x, -itemOffset.y); } // Apply globalMatrix when drawing into temporary canvas. - (direct ? matrix : getViewMatrix(globalMatrix)).applyToContext(ctx); - // If we're drawing into a separate canvas and a clipItem is defined for - // the current rendering loop, we need to draw the clip item again. - if (!direct && param.clipItem) + if (transform) + (direct ? matrix : getViewMatrix(globalMatrix)).applyToContext(ctx); + if (clip) param.clipItem.draw(ctx, param.extend({ clip: true })); - this._draw(ctx, param); + if (strokeMatrix) { + // Reset the transformation but take HiDPI pixel ratio into account. + ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + // Also offset again when drawing non-directly. + // NOTE: Don't use itemOffset since offset might be from the parent, + // e.g. CompoundPath + var offset = param.offset; + if (offset) + ctx.translate(-offset.x, -offset.y); + } + this._draw(ctx, param, strokeMatrix); ctx.restore(); matrices.pop(); if (param.clip && !param.dontFinish) @@ -3681,7 +3715,7 @@ var Item = Base.extend(Callback, /** @lends Item# */{ BlendMode.process(blendMode, ctx, mainCtx, opacity, // Calculate the pixel offset of the temporary canvas to the // main canvas. We also need to factor in the pixel-ratio. - itemOffset.subtract(prevOffset).multiply(param.pixelRatio)); + itemOffset.subtract(prevOffset).multiply(pixelRatio)); // Return the temporary context, so it can be reused CanvasProvider.release(ctx); // Restore previous offset. diff --git a/src/item/Shape.js b/src/item/Shape.js index e024bc1a..357e3bbc 100644 --- a/src/item/Shape.js +++ b/src/item/Shape.js @@ -173,55 +173,71 @@ var Shape = Item.extend(/** @lends Shape# */{ return path; }, - _draw: function(ctx, param) { + _draw: function(ctx, param, strokeMatrix) { var style = this._style, hasFill = style.hasFill(), hasStroke = style.hasStroke(), - dontPaint = param.dontFinish || param.clip; + dontPaint = param.dontFinish || param.clip, + untransformed = !strokeMatrix; if (hasFill || hasStroke || dontPaint) { - var radius = this._radius, - type = this._type; - if (!param.dontStart) - ctx.beginPath(); - if (type === 'circle') { + var type = this._type, + radius = this._radius, + isCircle = type === 'circle'; + if (untransformed && isCircle) { ctx.arc(0, 0, radius, 0, Math.PI * 2, true); } else { - var rx = radius.width, - ry = radius.height, - kappa = /*#=*/ Numerical.KAPPA; - if (type === 'ellipse') { - // Approximate ellipse with four bezier curves and KAPPA. - var cx = rx * kappa, - cy = ry * kappa; - ctx.moveTo(-rx, 0); - ctx.bezierCurveTo(-rx, -cy, -cx, -ry, 0, -ry); - ctx.bezierCurveTo(cx, -ry, rx, -cy, rx, 0); - ctx.bezierCurveTo(rx, cy, cx, ry, 0, ry); - ctx.bezierCurveTo(-cx, ry, -rx, cy, -rx, 0); - } else { // rect - var size = this._size, - width = size.width, - height = size.height; - if (rx === 0 && ry === 0) { - // straight rect - ctx.rect(-width / 2, -height / 2, width, height); - } else { - // rounded rect. Use 1 - KAPPA to calculate position of - // control points from the corners inwards. - kappa = 1 - kappa; - var x = width / 2, - y = height / 2, - cx = rx * kappa, - cy = ry * kappa; - ctx.moveTo(-x, -y + ry); - ctx.bezierCurveTo(-x, -y + cy, -x + cx, -y, -x + rx, -y); - ctx.lineTo(x - rx, -y); - ctx.bezierCurveTo(x - cx, -y, x, -y + cy, x, -y + ry); - ctx.lineTo(x, y - ry); - ctx.bezierCurveTo(x, y - cy, x - cx, y, x - rx, y); - ctx.lineTo(-x + rx, y); - ctx.bezierCurveTo(-x + cx, y, -x, y - cy, -x, y - ry); - } + var rx = isCircle ? radius : radius.width, + ry = isCircle ? radius : radius.height, + size = this._size, + width = size.width, + height = size.height; + if (untransformed && type === 'rect' && rx === 0 && ry === 0) { + // Rectangles with no rounding + ctx.rect(-width / 2, -height / 2, width, height); + } else { + // Round rectangles, ellipses, transformed circles + var x = width / 2, + y = height / 2, + // Use 1 - KAPPA to calculate position of control points + // from the corners inwards. + kappa = 1 - /*#=*/ Numerical.KAPPA, + cx = rx * kappa, + cy = ry * kappa, + // Build the coordinates list, so it can optionally be + // transformed by a matrix. + c = [ + -x, -y + ry, + -x, -y + cy, + -x + cx, -y, + -x + rx, -y, + x - rx, -y, + x - cx, -y, + x, -y + cy, + x, -y + ry, + x, y - ry, + x, y - cy, + x - cx, y, + x - rx, y, + -x + rx, y, + -x + cx, y, + -x, y - cy, + -x, y - ry + ]; + if (strokeMatrix) + strokeMatrix.transform(c, c, 32); + if (!param.dontStart) + ctx.beginPath(); + ctx.moveTo(c[0], c[1]); + ctx.bezierCurveTo(c[2], c[3], c[4], c[5], c[6], c[7]); + if (x !== rx) + ctx.lineTo(c[8], c[9]); + ctx.bezierCurveTo(c[10], c[11], c[12], c[13], c[14], c[15]); + if (y !== ry) + ctx.lineTo(c[16], c[17]); + ctx.bezierCurveTo(c[18], c[19], c[20], c[21], c[22], c[23]); + if (x !== rx) + ctx.lineTo(c[24], c[25]); + ctx.bezierCurveTo(c[26], c[27], c[28], c[29], c[30], c[31]); } } ctx.closePath(); diff --git a/src/path/CompoundPath.js b/src/path/CompoundPath.js index f5c8dc96..8f2289cf 100644 --- a/src/path/CompoundPath.js +++ b/src/path/CompoundPath.js @@ -247,7 +247,7 @@ var CompoundPath = PathItem.extend(/** @lends CompoundPath# */{ : new Base(options, { fill: false }); }, - _draw: function(ctx, param) { + _draw: function(ctx, param, strokeMatrix) { var children = this._children; // Return early if the compound path doesn't have any children: if (children.length === 0) @@ -259,7 +259,7 @@ var CompoundPath = PathItem.extend(/** @lends CompoundPath# */{ param = param.extend({ dontStart: true, dontFinish: true }); ctx.beginPath(); for (var i = 0, l = children.length; i < l; i++) - children[i].draw(ctx, param); + children[i].draw(ctx, param, strokeMatrix); this._currentPath = ctx.currentPath; } diff --git a/src/path/Path.js b/src/path/Path.js index de9390be..ed1a072f 100644 --- a/src/path/Path.js +++ b/src/path/Path.js @@ -1634,7 +1634,7 @@ var Path = PathItem.extend(/** @lends Path# */{ /** * Returns the curve location of the specified offset on the path. - * + * * @param {Number} offset the offset on the path, where {@code 0} is at * the beginning of the path and {@link Path#length} at the end. * @param {Boolean} [isParameter=false] @@ -1997,11 +1997,11 @@ var Path = PathItem.extend(/** @lends Path# */{ inX, inY, outX, outY; - function drawSegment(i) { - var segment = segments[i]; - // Optimise code when no matrix is provided by accessing semgent + function drawSegment(segment) { + // Optimise code when no matrix is provided by accessing segment // points hand handles directly, since this is the default when - // drawing paths. Matrix is only used for drawing selections. + // drawing paths. Matrix is only used for drawing selections and + // when #strokeScaling is false. if (matrix) { segment._transformCoordinates(matrix, coords, false); curX = coords[0]; @@ -2023,7 +2023,8 @@ var Path = PathItem.extend(/** @lends Path# */{ inX = curX + handle._x; inY = curY + handle._y; } - if (inX == curX && inY == curY && outX == prevX && outY == prevY) { + if (inX === curX && inY === curY + && outX === prevX && outY === prevY) { ctx.lineTo(curX, curY); } else { ctx.bezierCurveTo(outX, outY, inX, inY, curX, curY); @@ -2042,20 +2043,17 @@ var Path = PathItem.extend(/** @lends Path# */{ } for (var i = 0; i < length; i++) - drawSegment(i); + drawSegment(segments[i]); // Close path by drawing first segment again if (path._closed && length > 0) - drawSegment(0); + drawSegment(segments[0]); } return { - _draw: function(ctx, param) { + _draw: function(ctx, param, strokeMatrix) { var dontStart = param.dontStart, - dontPaint = param.dontFinish || param.clip; - if (!dontStart) - ctx.beginPath(); - - var style = this.getStyle(), + dontPaint = param.dontFinish || param.clip, + style = this.getStyle(), hasFill = style.hasFill(), hasStroke = style.hasStroke(), dashArray = style.getDashArray(), @@ -2063,18 +2061,15 @@ var Path = PathItem.extend(/** @lends Path# */{ dashLength = !paper.support.nativeDash && hasStroke && dashArray && dashArray.length; - function getOffset(i) { - // Negative modulo is necessary since we're stepping back - // in the dash sequence first. - return dashArray[((i % dashLength) + dashLength) % dashLength]; - } + if (!dontStart) + ctx.beginPath(); if (!dontStart && this._currentPath) { ctx.currentPath = this._currentPath; } else if (hasFill || hasStroke && !dashLength || dontPaint) { // Prepare the canvas path if we have any situation that // requires it to be defined. - drawSegments(ctx, this); + drawSegments(ctx, this, strokeMatrix); if (this._closed) ctx.closePath(); // CompoundPath collects its own _currentPath @@ -2082,6 +2077,12 @@ var Path = PathItem.extend(/** @lends Path# */{ this._currentPath = ctx.currentPath; } + function getOffset(i) { + // Negative modulo is necessary since we're stepping back + // in the dash sequence first. + return dashArray[((i % dashLength) + dashLength) % dashLength]; + } + if (!dontPaint && (hasFill || hasStroke)) { // If the path is part of a compound path or doesn't have a fill // or stroke, there is no need to continue. @@ -2102,7 +2103,7 @@ var Path = PathItem.extend(/** @lends Path# */{ // native dashes. if (!dontStart) ctx.beginPath(); - var flattener = new PathFlattener(this), + var flattener = new PathFlattener(this, strokeMatrix), length = flattener.length, from = -style.getDashOffset(), to, i = 0; diff --git a/src/path/PathFlattener.js b/src/path/PathFlattener.js index 81b5085e..9f36a8b1 100644 --- a/src/path/PathFlattener.js +++ b/src/path/PathFlattener.js @@ -16,7 +16,7 @@ * @private */ var PathFlattener = Base.extend({ - initialize: function(path) { + initialize: function(path, matrix) { this.curves = []; // The curve values as returned by getValues() this.parts = []; // The calculated, subdivided parts of the path this.length = 0; // The total length of the path @@ -35,7 +35,7 @@ var PathFlattener = Base.extend({ that = this; function addCurve(segment1, segment2) { - var curve = Curve.getValues(segment1, segment2); + var curve = Curve.getValues(segment1, segment2, matrix); that.curves.push(curve); that._computeParts(curve, segment1._index, 0, 1); } diff --git a/src/style/Style.js b/src/style/Style.js index 1af1a90d..f4d1f65c 100644 --- a/src/style/Style.js +++ b/src/style/Style.js @@ -77,6 +77,7 @@ var Style = Base.extend(new function() { strokeWidth: 1, strokeCap: 'butt', strokeJoin: 'miter', + strokeScaling: true, miterLimit: 10, dashOffset: 0, dashArray: [], @@ -101,6 +102,8 @@ var Style = Base.extend(new function() { strokeWidth: /*#=*/ Change.STROKE, strokeCap: /*#=*/ Change.STROKE, strokeJoin: /*#=*/ Change.STROKE, + // strokeScaling can change the coordinates of cached path items + strokeScaling: /*#=*/ Change.STROKE | Change.GEOMETRY, miterLimit: /*#=*/ Change.STROKE, fontFamily: /*#=*/ Change.GEOMETRY, fontWeight: /*#=*/ Change.GEOMETRY, @@ -369,8 +372,8 @@ var Style = Base.extend(new function() { */ /** - * The shape to be used at the end of open {@link Path} items, when they - * have a stroke. + * The shape to be used at the beginning and end of open {@link Path} items, + * when they have a stroke. * * @name Style#strokeCap * @property @@ -402,7 +405,8 @@ var Style = Base.extend(new function() { */ /** - * The shape to be used at the corners of paths when they have a stroke. + * The shape to be used at the segments and corners of {@link Path} items + * when they have a stroke. * * @name Style#strokeJoin * @property @@ -430,6 +434,17 @@ var Style = Base.extend(new function() { * path3.strokeJoin = 'bevel'; */ + /** + * Specifies whether the stroke is to be drawn taking the current affine + * transformation into account (the default behavior), or whether it should + * appear as a non-scaling stroke. + * + * @name Style#strokeScaling + * @property + * @default true + * @type Boolean + */ + /** * The dash offset of the stroke. * diff --git a/src/svg/SVGExport.js b/src/svg/SVGExport.js index 0dc32fa1..0fb3bb6f 100644 --- a/src/svg/SVGExport.js +++ b/src/svg/SVGExport.js @@ -293,8 +293,10 @@ new function() { var get = entry.get, type = entry.type, value = item[get](); - if (!parent || !Base.equals(parent[get](), value)) { - if (type === 'color' && value != null) { + if (entry.exportFilter + ? entry.exportFilter(item, value) + : !parent || !Base.equals(parent[get](), value)) { + if (type === 'color' && value !== 'none') { // Support for css-style rgba() values is not in SVG 1.1, so // separate the alpha value of colors with alpha into the // separate fill- / stroke-opacity attribute: diff --git a/src/svg/SVGStyles.js b/src/svg/SVGStyles.js index 521650fb..7e496177 100644 --- a/src/svg/SVGStyles.js +++ b/src/svg/SVGStyles.js @@ -18,6 +18,16 @@ var SVGStyles = Base.each({ strokeWidth: ['stroke-width', 'number'], strokeCap: ['stroke-linecap', 'string'], strokeJoin: ['stroke-linejoin', 'string'], + strokeScaling: ['vector-effect', 'lookup', { + true: 'none', + false: 'non-scaling-stroke' + }, function(item, value) { + // no inheritance, only applies to graphical elements + return !value // false, meaning non-scaling-stroke + && (item instanceof PathItem + || item instanceof Shape + || item instanceof TextItem); + }], miterLimit: ['stroke-miterlimit', 'number'], dashArray: ['stroke-dasharray', 'array'], dashOffset: ['stroke-dashoffset', 'number'], @@ -44,6 +54,7 @@ var SVGStyles = Base.each({ fromSVG: lookup && Base.each(lookup, function(value, name) { this[value] = name; }, {}), + exportFilter: entry[3], get: 'get' + part, set: 'set' + part };