diff --git a/demos/textures/dice.png b/demos/textures/dice.png new file mode 100644 index 0000000..9fea9b9 Binary files /dev/null and b/demos/textures/dice.png differ diff --git a/demos/textures/index.html b/demos/textures/index.html new file mode 100644 index 0000000..a7c2ebc --- /dev/null +++ b/demos/textures/index.html @@ -0,0 +1,102 @@ + + + + + + + Textures + + + + + + +
+

Canvas

+
+
+
+

Gradient

+ +
+ +
+

Box

+ +
+ +
+

Tetrahedron

+ +
+
+ +
+

SVG

+
+
+
+

Gradient

+ +
+ +
+

Box

+ +
+ +
+

Tetrahedron

+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demos/textures/tetrahedron.png b/demos/textures/tetrahedron.png new file mode 100644 index 0000000..4c83541 Binary files /dev/null and b/demos/textures/tetrahedron.png differ diff --git a/demos/textures/textures.js b/demos/textures/textures.js new file mode 100644 index 0000000..3ffa8de --- /dev/null +++ b/demos/textures/textures.js @@ -0,0 +1,119 @@ +function loadImage(url) { + return new Promise(r => { let i = new Image(); i.onload = (() => r(i)); i.src = url; }); +} + +function createIllustration(selector) { + // ----- variables ----- // + var isSpinning = true; + + // ----- model ----- // + var illo = new Zdog.Illustration({ + element: selector, + dragRotate: true, + zoom: 1, + onDragStart: function() { + isSpinning = false; + }, + }); + + // ----- animate ----- // + function animate() { + illo.rotate.y += isSpinning ? 0.03 : 0; + illo.updateRenderGraph(); + requestAnimationFrame( animate ); + } + animate(); + return illo; +} + +function loadForBothRenderer(val, callback) { + callback("", val); + callback("_svg", val); +} + +// Coin +loadForBothRenderer(0, suffix => { + new Zdog.Cylinder({ + addTo: createIllustration("#illo_coin" + suffix), + diameter: 200, + length: 10, + color: "#000", + backface: new Zdog.Texture({ + linearGrad:[0, 0, 100, 0], + colorStops:[0, '#833ab4', .5, '#fd1d1d', 1, '#fcb045'], + dst: [-100, -100, 200, 200], + src:[0, 0, 100, 100]}), + frontFace: new Zdog.Texture({ + radialGrad:[50, 50, 0, 50, 50, 50], + colorStops:[0, '#3f5efb', .25, '#b471a3', .5, '#adb753', .75, '#5fd0d8', 1, '#fc466b'], + dst: [-100, -100, 200, 200], + src:[0, 0, 100, 100]}), + stroke: false, + }); +}); + +loadImage("dice.png").then(img => loadForBothRenderer(img, (suffix, img) => { + new Zdog.Box({ + addTo: createIllustration("#illo_box" + suffix), + width: 120, + height: 120, + depth: 120, + stroke: false, + color: '#F00', + frontFace: new Zdog.Texture({img: img, src:[0, 0, 200, 200], dst: [-60, -60, 120, 120]}), + rearFace: new Zdog.Texture({img: img, src:[0, 200, 200, 200], dst: [-60, -60, 120, 120]}), + leftFace: new Zdog.Texture({img: img, src:[200, 0, 200, 200], dst: [-60, -60, 120, 120]}), + rightFace: new Zdog.Texture({img: img, src:[200, 200, 200, 200], dst: [-60, -60, 120, 120]}), + topFace: new Zdog.Texture({img: img, src:[400, 0, 200, 200], dst: [-60, -60, 120, 120]}), + bottomFace: new Zdog.Texture({img: img, src:[400, 200, 200, 200], dst: [-60, -60, 120, 120]}), + }); +})); + + +// Tetrahedron +loadImage("tetrahedron.png").then(img => loadForBothRenderer(img, (suffix, img) => { + var tetrahedron = new Zdog.Anchor({ + addTo: createIllustration("#illo_tetrahedron" + suffix), + translate: { x: 0, y: 0 }, + }); + + var radius = 80; + var deg_120 = Zdog.TAU / 3; + + var depthFactor = radius * Math.sqrt(6) * 3 / 2; + var r = -depthFactor / 12; + + var p1 = {x: 0, y : radius, z: r}; + var p2 = {x: radius * Math.sin(deg_120), y : radius * Math.cos(deg_120), z: r}; + var p3 = {x: radius * Math.sin(2 * deg_120), y : radius * Math.cos(2 * deg_120), z: r} + var p4 = {x: 0, y: 0, z: depthFactor / 4}; + + new Zdog.Shape({ + path: [p1, p2, p3, p1], + addTo: tetrahedron, + stroke: 1, + color: new Zdog.Texture({img: img, src:[{x:10, y: 210}, {x:210, y: 210}, {x:110, y: 36.8}], dst: [p1, p2, p3]}), + fill: true, + }); + new Zdog.Shape({ + path: [p1, p2, p4, p1], + addTo: tetrahedron, + stroke: 1, + color: new Zdog.Texture({img: img, src:[{x:210, y: 210}, {x:410, y: 210}, {x:310, y: 36.8}], dst: [p1, p2, p4]}), + fill: true, + }); + new Zdog.Shape({ + path: [p1, p4, p3, p1], + addTo: tetrahedron, + stroke: 1, + color: new Zdog.Texture({img: img, src:[{x:410, y: 210}, {x:610, y: 210}, {x:510, y: 36.8}], dst: [p1, p4, p3]}), + fill: true, + }); + new Zdog.Shape({ + path: [p4, p2, p3, p4], + addTo: tetrahedron, + stroke: 1, + color: new Zdog.Texture({img: img, src:[{x:610, y: 210}, {x:810, y: 210}, {x:710, y: 36.8}], dst: [p4, p2, p3]}), + fill: true, + }); +})); diff --git a/js/boilerplate.js b/js/boilerplate.js index fd79612..c912d14 100644 --- a/js/boilerplate.js +++ b/js/boilerplate.js @@ -70,6 +70,13 @@ Zdog.easeInOut = function( alpha, power ) { return isFirstHalf ? curve : 1 - curve; }; +Zdog.isColor = function(value) { + return (typeof value == 'string') || (value && value.isTexture); +} +Zdog.cloneColor = function(value) { + return (value && value.clone) ? value.clone() : value; +} + return Zdog; } ) ); diff --git a/js/box.js b/js/box.js index d25b802..973811b 100644 --- a/js/box.js +++ b/js/box.js @@ -87,7 +87,11 @@ Box.prototype.setFace = function( faceName, value ) { } // update & add face var options = this.getFaceOptions( faceName ); - options.color = typeof value == 'string' ? value : this.color; + if (utils.isColor(value)) { + options.color = value; + } else { + options.color = utils.cloneColor(this.color); + } if ( rect ) { // update previous diff --git a/js/canvas-renderer.js b/js/canvas-renderer.js index b10ef7f..6cbbc05 100644 --- a/js/canvas-renderer.js +++ b/js/canvas-renderer.js @@ -51,17 +51,31 @@ CanvasRenderer.stroke = function( ctx, elem, isStroke, color, lineWidth ) { if ( !isStroke ) { return; } - ctx.strokeStyle = color; ctx.lineWidth = lineWidth; - ctx.stroke(); + if (color && color.getCanvasFill) { + ctx.save(); + ctx.strokeStyle = color.getCanvasFill(ctx); + ctx.stroke(); + ctx.restore(); + } else { + ctx.strokeStyle = color; + ctx.stroke(); + } }; CanvasRenderer.fill = function( ctx, elem, isFill, color ) { if ( !isFill ) { return; } - ctx.fillStyle = color; - ctx.fill(); + if (color && color.getCanvasFill) { + ctx.save(); + ctx.fillStyle = color.getCanvasFill(ctx); + ctx.fill(); + ctx.restore(); + } else { + ctx.fillStyle = color; + ctx.fill(); + } }; CanvasRenderer.end = function() {}; diff --git a/js/cylinder.js b/js/cylinder.js index f424106..c327dbb 100644 --- a/js/cylinder.js +++ b/js/cylinder.js @@ -120,14 +120,14 @@ Cylinder.prototype.create = function( /* options */) { color: this.color, stroke: this.stroke, fill: this.fill, - backface: this.frontFace || baseColor, + backface: utils.cloneColor(this.frontFace || baseColor), visible: this.visible, }); // back outside base this.rearBase = this.group.rearBase = this.frontBase.copy({ translate: { z: -baseZ }, rotate: { y: 0 }, - backface: baseColor, + backface: utils.cloneColor(baseColor), }); }; diff --git a/js/index.js b/js/index.js index 8bed0d1..7aa15cb 100644 --- a/js/index.js +++ b/js/index.js @@ -24,7 +24,8 @@ require('./hemisphere'), require('./cylinder'), require('./cone'), - require('./box') + require('./box'), + require('./texture') ); } else if ( typeof define == 'function' && define.amd ) { /* globals define */ // AMD @@ -33,7 +34,7 @@ /* eslint-disable max-params */ } )( this, function factory( Zdog, CanvasRenderer, SvgRenderer, Vector, Anchor, Dragger, Illustration, PathCommand, Shape, Group, Rect, RoundedRect, - Ellipse, Polygon, Hemisphere, Cylinder, Cone, Box ) { + Ellipse, Polygon, Hemisphere, Cylinder, Cone, Box, Texture ) { /* eslint-enable max-params */ Zdog.CanvasRenderer = CanvasRenderer; @@ -53,6 +54,7 @@ Zdog.Cylinder = Cylinder; Zdog.Cone = Cone; Zdog.Box = Box; + Zdog.Texture = Texture; return Zdog; } ); diff --git a/js/shape.js b/js/shape.js index 59dce32..a58e6a2 100644 --- a/js/shape.js +++ b/js/shape.js @@ -91,6 +91,13 @@ Shape.prototype.reset = function() { this.pathCommands.forEach( function( command ) { command.reset(); } ); + + if (this.backface && this.backface.reset) { + this.backface.reset(); + } + if (this.color && this.color.reset) { + this.color.reset(); + } }; Shape.prototype.transform = function( translation, rotation, scale ) { @@ -106,6 +113,12 @@ Shape.prototype.transform = function( translation, rotation, scale ) { this.children.forEach( function( child ) { child.transform( translation, rotation, scale ); } ); + if (this.backface && this.backface.transform) { + this.backface.transform( translation, rotation, scale ); + } + if (this.color && this.color.transform) { + this.color.transform( translation, rotation, scale ); + } }; Shape.prototype.updateSortValue = function() { @@ -153,17 +166,16 @@ Shape.prototype.render = function( ctx, renderer ) { var TAU = utils.TAU; // Safari does not render lines with no size, have to render circle instead -Shape.prototype.renderCanvasDot = function( ctx ) { +Shape.prototype.renderCanvasDot = function( ctx , renderer) { var lineWidth = this.getLineWidth(); if ( !lineWidth ) { return; } - ctx.fillStyle = this.getRenderColor(); var point = this.pathCommands[0].endRenderPoint; ctx.beginPath(); var radius = lineWidth/2; ctx.arc( point.x, point.y, radius, 0, TAU ); - ctx.fill(); + renderer.fill(ctx, null, true, this.getRenderColor() ); }; Shape.prototype.getLineWidth = function() { @@ -178,7 +190,7 @@ Shape.prototype.getLineWidth = function() { Shape.prototype.getRenderColor = function() { // use backface color if applicable - var isBackfaceColor = typeof this.backface == 'string' && this.isFacingBack; + var isBackfaceColor = utils.isColor(this.backface) && this.isFacingBack; var color = isBackfaceColor ? this.backface : this.color; return color; }; diff --git a/js/svg-renderer.js b/js/svg-renderer.js index 8a2126b..6af5f8d 100644 --- a/js/svg-renderer.js +++ b/js/svg-renderer.js @@ -62,12 +62,18 @@ SvgRenderer.stroke = function( svg, elem, isStroke, color, lineWidth ) { if ( !isStroke ) { return; } + if (color && color.getSvgFill) { + color = color.getSvgFill(svg); + } elem.setAttribute( 'stroke', color ); elem.setAttribute( 'stroke-width', lineWidth ); }; SvgRenderer.fill = function( svg, elem, isFill, color ) { var fillColor = isFill ? color : 'none'; + if (fillColor && fillColor.getSvgFill) { + fillColor = fillColor.getSvgFill(svg); + } elem.setAttribute( 'fill', fillColor ); }; diff --git a/js/texture.js b/js/texture.js new file mode 100644 index 0000000..9da0c46 --- /dev/null +++ b/js/texture.js @@ -0,0 +1,222 @@ +/** + * Texture + */ +( function( root, factory ) { + // module definition + if ( typeof module == 'object' && module.exports ) { + // CommonJS + module.exports = factory(require('./vector')); + } else { + // browser global + var Zdog = root.Zdog; + Zdog.Texture = factory(Zdog.Vector); + } + }( this, function factory(Vector) { + + /** + * Calculates the inverse of the matrix: + * | x1 x2 x3 | + * | y1 y2 y3 | + * | 1 1 1 | + */ + function inverse(x1, y1, x2, y2, x3, y3) { + let tp = [ + y2 - y3, x3 - x2, x2*y3 - x3*y2, + y3 - y1, x1 - x3, x3*y1 - x1*y3, + y1 - y2, x2 - x1, x1*y2 - x2*y1]; + let det = tp[2] + tp[5] + tp[8]; + return tp.map(function(x) { return x / det;}); + } + + function parsePointMap(size, map) { + if (!Array.isArray(map) || !map.length) { + map = [0, 0, size[0], size[1]]; + } + if (typeof(map[0]) == "number") { + if (map.length < 4) { + let tmp = map; + map = [0, 0, size[0], size[1]]; + for (let i = 0; i < tmp.length; i++) { + map[i] = tmp[i]; + } + } + return [ + new Vector({x:map[0], y:map[1], z:1}), + new Vector({x:map[0] + map[2], y:map[1], z:1}), + new Vector({x:map[0], y:map[1] + map[3], z:1}) + ]; + } else { + return [new Vector(map[0]), new Vector(map[1]), new Vector(map[2])]; + } + } + + var idCounter = 0; + + const optionKeys = [ + 'img', + 'linearGrad', + 'radialGrad', + 'colorStops', + 'src', + 'dst' + ] + + /** + * Creates a tecture map. Possible options: + * img: Image object to be used as texture + * linearGrad: [x1, y1, x2, y2] Array defining the linear gradient + * radialGrad: [x0, y0, r0, x1, y1, r1] Array defining the radial gradient + * colorStops: [offset1, color1, offset2, color2...] Array defining the color + * stops for the gradient, offset must be in range [0, 1] + * + * src: Represents the surface for the texture. Above + * gradient definition should be represented in this coordinate space + * dst: Represents the surface of the object. This allows + * keeping the texture definition independent of the surface definition + * + * Can be represented in one of the following ways: + * [x, y, width, height] => We use 3 points top-left, top-right, bottom-left + * [x, y] => image/gradient size is used for width and height with the above rule + * [vector, vector, vector] => provided points are used + */ + function Texture(options) { + this.id = idCounter++; + this.isTexture = true; + + options = options || { } + for (var key in options ) { + if (optionKeys.indexOf( key ) != -1 ) { + this[key] = options[key]; + } + } + + var size; + if (options.img) { + size = [options.img.width, options.img.height]; + } else if (options.linearGrad) { + size = [Math.abs(options.linearGrad[2] - options.linearGrad[0]), Math.abs(options.linearGrad[3] - options.linearGrad[1])]; + } else if (options.radialGrad) { + size = [Math.abs(options.radialGrad[3] - options.radialGrad[0]), Math.abs(options.radialGrad[4] - options.radialGrad[1])]; + } else { + throw "One of [img, linearGrad, radialGrad] is required"; + } + if (size[0] == 0) size[0] = size[1]; + if (size[1] == 0) size[1] = size[0]; + + this.src = parsePointMap(size, options.src); + this.dst = parsePointMap(size, options.dst); + + this.srcInverse = inverse( + this.src[0].x, this.src[0].y, + this.src[1].x, this.src[1].y, + this.src[2].x, this.src[2].y); + this.p1 = new Vector(); + this.p2 = new Vector(); + this.p3 = new Vector(); + this.matrix = [0, 0, 0, 0, 0, 0]; + }; + + Texture.prototype.getMatrix = function() { + let m = this.matrix; + let inverse = this.srcInverse; + m[0] = this.p1.x * inverse[0] + this.p2.x * inverse[3] + this.p3.x * inverse[6]; + m[1] = this.p1.y * inverse[0] + this.p2.y * inverse[3] + this.p3.y * inverse[6]; + m[2] = this.p1.x * inverse[1] + this.p2.x * inverse[4] + this.p3.x * inverse[7]; + m[3] = this.p1.y * inverse[1] + this.p2.y * inverse[4] + this.p3.y * inverse[7]; + m[4] = this.p1.x * inverse[2] + this.p2.x * inverse[5] + this.p3.x * inverse[8]; + m[5] = this.p1.y * inverse[2] + this.p2.y * inverse[5] + this.p3.y * inverse[8]; + return m; + } + + Texture.prototype.getCanvasFill = function(ctx) { + if (!this.pattern) { + if (this.img) { + this.pattern = ctx.createPattern(this.img, "repeat"); + } else { + this.pattern = this.linearGrad + ? ctx.createLinearGradient.apply(ctx, this.linearGrad) + : ctx.createRadialGradient.apply(ctx, this.radialGrad); + if (this.colorStops) { + for (var i = 0; i < this.colorStops.length; i+=2) { + this.pattern.addColorStop(this.colorStops[i], this.colorStops[i+1]); + } + } + } + } + // pattern.setTransform is not supported in IE, + // so transform the context instead + ctx.transform.apply(ctx, this.getMatrix()); + return this.pattern; + }; + + const svgURI = 'http://www.w3.org/2000/svg'; + Texture.prototype.getSvgFill = function(svg) { + if (!this.svgPattern) { + if (this.img) { + this.svgPattern = document.createElementNS( svgURI, 'pattern'); + this.svgPattern.setAttribute("width", this.img.width); + this.svgPattern.setAttribute("height", this.img.height); + this.svgPattern.setAttribute("patternUnits", "userSpaceOnUse"); + this.attrTransform = "patternTransform"; + + let img = document.createElementNS( svgURI, 'image'); + img.setAttribute("href", this.img.src); + this.svgPattern.appendChild(img); + } else { + var type, vals, keys; + if (this.linearGrad) { + type = "linearGradient"; + vals = this.linearGrad; + keys = ["x1", "y1", "x2", "y2"] + } else { + type = "radialGradient"; + vals = this.radialGrad; + keys = ["fx", "fy", "fr", "cx", "cy", "r"] + } + this.svgPattern = document.createElementNS( svgURI, type); + for (var i = 0; i < keys.length; i++) { + this.svgPattern.setAttribute(keys[i], vals[i]); + } + + if (this.colorStops) { + for (var i = 0; i < this.colorStops.length; i+=2) { + let colorStop = document.createElementNS(svgURI, 'stop' ); + colorStop.setAttribute("offset", this.colorStops[i]); + colorStop.setAttribute("style", "stop-color:" + this.colorStops[i+1]); + this.svgPattern.appendChild(colorStop); + } + } + this.svgPattern.setAttribute("gradientUnits", "userSpaceOnUse"); + this.attrTransform = "gradientTransform"; + } + this.svgPattern.setAttribute("id", "texture_" + this.id); + this._svgUrl = 'url(#texture_' + this.id + ')'; + + this.defs = document.createElementNS(svgURI, 'defs' ); + this.defs.appendChild(this.svgPattern); + } + + this.svgPattern.setAttribute(this.attrTransform, 'matrix(' + this.getMatrix().join(' ') + ')'); + svg.appendChild( this.defs ); + return this._svgUrl; + } + + // ----- update ----- // + Texture.prototype.reset = function() { + this.p1.set(this.dst[0]); + this.p2.set(this.dst[1]); + this.p3.set(this.dst[2]); + }; + + Texture.prototype.transform = function( translation, rotation, scale ) { + this.p1.transform(translation, rotation, scale); + this.p2.transform(translation, rotation, scale); + this.p3.transform(translation, rotation, scale); + }; + + Texture.prototype.clone = function() { + return new Texture(this); + }; + + return Texture; +} ) );