Flat, round, designer-friendly pseudo-3D engine for canvas & SVG
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Split Horn into Horn and Funnel.

The Horn's eccentricity calculations *mostly* only make sense for
circular endcaps, not spherical ones. I want to develop the Funnel
further and also improve the Horn's surface approximation, so I've
split them into two.

The Funnel's surface now supports strokes, respects fill settings,
and the Funnel itself now uses circular endcaps rather than
spherical ones. I think the eccentricity calculations could still
be improved but that's a lot of math for right now.

The Horn's surface no longer connects the equators of each cap, as
it's common for a larger cap to poke visibly through the surface.
Eccentricity has been removed from its surface calculations, and the
surface now fits to the tangets between the two spheres, with no
visible dicontinuities.

+327 -83
+278
js/funnel.js
··· 1 + /** 2 + * Funnel composite shape 3 + */ 4 + 5 + ( function( root, factory ) { 6 + // module definition 7 + if ( typeof module == 'object' && module.exports ) { 8 + // CommonJS 9 + module.exports = factory( require('./boilerplate'), 10 + require('./path-command'), require('./shape'), require('./group'), 11 + require('./vector') ); 12 + } else { 13 + // browser global 14 + var Zdog = root.Zdog; 15 + Zdog.Funnel = factory( Zdog, Zdog.PathCommand, Zdog.Shape, Zdog.Ellipse, 16 + Zdog.Group, Zdog.Vector ); 17 + } 18 + }( this, function factory( utils, PathCommand, Shape, Ellipse, Group, Vector ) { 19 + 20 + function noop() {} 21 + 22 + // ----- FunnelGroup ----- // 23 + 24 + var FunnelGroup = Group.subclass({ 25 + color: '#333', 26 + fill: true, 27 + stroke: true, 28 + updateSort: true, 29 + }); 30 + 31 + FunnelGroup.type = 'FunnelGroup'; 32 + 33 + FunnelGroup.prototype.create = function() { 34 + Group.prototype.create.apply( this, arguments ); 35 + 36 + // vectors used for calculation 37 + this.renderApex = new Vector(); 38 + this.tangentFrontA = new Vector(); 39 + this.tangentFrontB = new Vector(); 40 + this.tangentRearA = new Vector(); 41 + this.tangentRearB = new Vector(); 42 + 43 + this.pathCommands = [ 44 + new PathCommand( 'move', [ {} ] ), 45 + new PathCommand( 'line', [ {} ] ), 46 + new PathCommand( 'line', [ {} ] ), 47 + new PathCommand( 'line', [ {} ] ), 48 + ]; 49 + }; 50 + 51 + FunnelGroup.prototype.render = function( ctx, renderer ) { 52 + this.renderFunnelSurface( ctx, renderer ); 53 + Group.prototype.render.apply( this, arguments ); 54 + }; 55 + 56 + FunnelGroup.prototype.renderFunnelSurface = function( ctx, renderer ) { 57 + if ( !this.visible ) { 58 + return; 59 + } 60 + // render funnel surface 61 + var elem = this.getRenderElement( ctx, renderer ); 62 + var frontBase = this.frontBase; 63 + var frontDiameter = frontBase.diameter; 64 + var rearBase = this.rearBase; 65 + var rearDiameter = rearBase.diameter; 66 + var scale = frontBase.renderNormal.magnitude(); 67 + var frontRadius = frontDiameter/2 * scale; 68 + var rearRadius = rearDiameter/2 * scale; 69 + 70 + this.renderApex.set( rearBase.renderOrigin ) 71 + .subtract( frontBase.renderOrigin ); 72 + 73 + // calculate tangents. 74 + var scale = frontBase.renderNormal.magnitude(); 75 + var apexDistance = this.renderApex.magnitude2d(); 76 + var normalDistance = frontBase.renderNormal.magnitude2d(); 77 + // eccentricity 78 + var eccenAngle = Math.acos( normalDistance / scale ); 79 + var biggerRadius = (frontRadius > rearRadius) ? frontRadius : rearRadius; 80 + var eccenPercent; 81 + if (frontRadius == 0 || rearRadius == 0) { 82 + eccenPercent = 1.0; 83 + } else { 84 + eccenPercent = (Math.abs(frontRadius - rearRadius) / biggerRadius); 85 + } 86 + var eccen = Math.sin( eccenAngle ) * Math.sqrt(eccenPercent); 87 + // does apex extend beyond eclipse of face 88 + apexDistance = apexDistance + frontRadius/4 + rearRadius/4; 89 + var isApexVisible = frontRadius * eccen < apexDistance && 90 + rearRadius * eccen < apexDistance; 91 + if ( !isApexVisible ) { 92 + return; 93 + } 94 + // update tangents 95 + // TODO: try something more like horn_old.js updateSortValue() 96 + var apexAngle = Math.atan2( frontBase.renderNormal.y, frontBase.renderNormal.x ) + 97 + TAU/2; 98 + var projectFrontLength = (apexDistance + frontRadius) / eccen; 99 + var projectRearLength = (apexDistance + rearRadius) / eccen; 100 + var projectFrontAngle = Math.acos( frontRadius / projectFrontLength ); 101 + var projectRearAngle = Math.acos( rearRadius / -projectRearLength ); 102 + // set tangent points 103 + var tangentFrontA = this.tangentFrontA; 104 + var tangentFrontB = this.tangentFrontB; 105 + var tangentRearA = this.tangentRearA; 106 + var tangentRearB = this.tangentRearB; 107 + 108 + tangentFrontA.x = Math.cos( projectFrontAngle ) * frontRadius * eccen; 109 + tangentFrontA.y = Math.sin( projectFrontAngle ) * frontRadius; 110 + tangentRearA.x = Math.cos( projectRearAngle ) * rearRadius * eccen; 111 + tangentRearA.y = Math.sin( projectRearAngle ) * rearRadius; 112 + 113 + tangentFrontB.set( this.tangentFrontA ); 114 + tangentFrontB.y *= -1; 115 + tangentRearB.set( this.tangentRearA ); 116 + tangentRearB.y *= -1; 117 + 118 + tangentFrontA.rotateZ( apexAngle); 119 + tangentFrontB.rotateZ( apexAngle); 120 + tangentFrontA.add( frontBase.renderOrigin ); 121 + tangentFrontB.add( frontBase.renderOrigin ); 122 + tangentRearA.rotateZ( apexAngle + TAU/2); 123 + tangentRearB.rotateZ( apexAngle + TAU/2); 124 + tangentRearA.add( rearBase.renderOrigin ); 125 + tangentRearB.add( rearBase.renderOrigin ); 126 + 127 + 128 + // set path command render points 129 + this.pathCommands[0].renderPoints[0].set( tangentFrontA ); 130 + this.pathCommands[1].renderPoints[0].set( tangentRearB ); 131 + this.pathCommands[2].renderPoints[0].set( tangentRearA ); 132 + this.pathCommands[3].renderPoints[0].set( tangentFrontB ); 133 + 134 + if ( renderer.isCanvas ) { 135 + ctx.lineCap = 'butt'; // nice 136 + } 137 + renderer.stroke(ctx, elem, this.stroke, this.color, Shape.prototype.getLineWidth.apply(this)); 138 + renderer.renderPath( ctx, elem, this.pathCommands ); 139 + //renderer.stroke( ctx, elem, true, '#333', 0.1 ); // remove once testing is done. 140 + renderer.fill( ctx, elem, this.fill, this.color ); 141 + renderer.end( ctx, elem ); 142 + 143 + if ( renderer.isCanvas ) { 144 + ctx.lineCap = 'round'; // reset 145 + } 146 + }; 147 + 148 + var svgURI = 'http://www.w3.org/2000/svg'; 149 + 150 + FunnelGroup.prototype.getRenderElement = function( ctx, renderer ) { 151 + if ( !renderer.isSvg ) { 152 + return; 153 + } 154 + if ( !this.svgElement ) { 155 + // create svgElement 156 + this.svgElement = document.createElementNS( svgURI, 'path'); 157 + this.svgElement.setAttribute( 'stroke-linecap', 'round' ); 158 + this.svgElement.setAttribute( 'stroke-linejoin', 'round' ); 159 + } 160 + return this.svgElement; 161 + }; 162 + 163 + // prevent double-creation in parent.copyGraph() 164 + // only create in Funnel.create() 165 + FunnelGroup.prototype.copyGraph = noop; 166 + 167 + // ----- FunnelCap ----- // 168 + 169 + var FunnelCap = Ellipse.subclass(); 170 + 171 + FunnelCap.type = 'FunnelCap'; 172 + 173 + FunnelCap.prototype.copyGraph = noop; 174 + 175 + // ----- Funnel ----- // 176 + 177 + var Funnel = Shape.subclass({ 178 + frontDiameter: 1, 179 + rearDiameter: 1, 180 + length: 1, 181 + frontFace: undefined, 182 + fill: true, 183 + }); 184 + 185 + Funnel.type = 'Funnel'; 186 + 187 + var TAU = utils.TAU; 188 + 189 + Funnel.prototype.create = function(/* options */) { 190 + // call super 191 + Shape.prototype.create.apply( this, arguments ); 192 + // composite shape, create child shapes 193 + // FunnelGroup to render funnel surface then bases 194 + this.group = new FunnelGroup({ 195 + addTo: this, 196 + color: this.color, 197 + fill: this.fill, 198 + stroke: this.stroke, 199 + visible: this.visible, 200 + }); 201 + var baseZ = this.length/2; 202 + var baseColor = this.backface || true; 203 + // front outside base 204 + this.frontBase = this.group.frontBase = new FunnelCap({ 205 + addTo: this.group, 206 + translate: { z: (baseZ - this.frontDiameter/2) }, 207 + rotate: { y: TAU/2 }, 208 + color: this.color, 209 + diameter: this.frontDiameter, 210 + fill: this.fill, 211 + stroke: this.stroke, 212 + backface: this.frontFace || baseColor, 213 + visible: this.visible, 214 + }); 215 + // back outside base 216 + this.rearBase = this.group.rearBase = new FunnelCap({ 217 + addTo: this.group, 218 + translate: { z: (-baseZ + this.rearDiameter/2) }, 219 + rotate: { y: 0 }, 220 + color: this.color, 221 + diameter: this.rearDiameter, 222 + fill: this.fill, 223 + stroke: this.stroke, 224 + backface: baseColor, 225 + visible: this.visible, 226 + }); 227 + 228 + }; 229 + 230 + Funnel.prototype.updateFrontCapDiameter = function(size) { 231 + this.frontBase.diameter = size; 232 + var baseZ = this.length/2; 233 + this.frontBase.translate.z = (baseZ - size/2); 234 + } 235 + 236 + Funnel.prototype.updateRearCapDiameter = function(size) { 237 + this.rearBase.diameter = size; 238 + var baseZ = this.length/2; 239 + this.rearBase.translate.z = (-baseZ + size/2); 240 + } 241 + 242 + // Funnel shape does not render anything 243 + Funnel.prototype.render = function() {}; 244 + 245 + // ----- set child properties ----- // 246 + 247 + var childProperties = [ 'stroke', 'fill', 'color', 'visible', 248 + 'frontDiameter', 'rearDiameter' ]; 249 + childProperties.forEach( function( property ) { 250 + // use proxy property for custom getter & setter 251 + var _prop = '_' + property; 252 + Object.defineProperty( Funnel.prototype, property, { 253 + get: function() { 254 + return this[ _prop ]; 255 + }, 256 + set: function( value ) { 257 + this[ _prop ] = value; 258 + // set property on children 259 + if ( this.frontBase ) { 260 + if (property === 'frontDiameter') { 261 + this.updateFrontCapDiameter(value); 262 + } 263 + if (property === 'rearDiameter') { 264 + this.updateRearCapDiameter(value); 265 + } 266 + this.frontBase[ property ] = value; 267 + this.rearBase[ property ] = value; 268 + this.group[ property ] = value; 269 + } 270 + }, 271 + }); 272 + }); 273 + 274 + // TODO child property setter for backface, frontBaseColor, & rearBaseColor 275 + 276 + return Funnel; 277 + 278 + }));
+44 -80
js/horn.js
··· 23 23 24 24 var HornGroup = Group.subclass({ 25 25 color: '#333', 26 + fill: true, 27 + stroke: true, 26 28 updateSort: true, 27 29 }); 28 30 ··· 30 32 31 33 HornGroup.prototype.create = function() { 32 34 Group.prototype.create.apply( this, arguments ); 33 - 35 + 34 36 // vectors used for calculation 35 37 this.renderApex = new Vector(); 36 - this.tangentFrontA = new Vector(); 37 - this.tangentFrontB = new Vector(); 38 - this.tangentRearA = new Vector(); 39 - this.tangentRearB = new Vector(); 40 - 38 + 41 39 this.pathCommands = [ 42 40 new PathCommand( 'move', [ {} ] ), 43 41 new PathCommand( 'line', [ {} ] ), 44 - new PathCommand( 'line', [ {} ] ), 45 - new PathCommand( 'line', [ {} ] ), 42 + new PathCommand( 'line', [ {} ] ), 43 + new PathCommand( 'line', [ {} ] ), 46 44 ]; 47 45 }; 48 46 ··· 57 55 } 58 56 // render horn surface 59 57 var elem = this.getRenderElement( ctx, renderer ); 60 - var frontBase = this.frontBase; 61 - var frontDiameter = frontBase.stroke; 62 - var rearBase = this.rearBase; 63 - var rearDiameter = rearBase.stroke; 64 - var scale = frontBase.renderNormal.magnitude(); 65 - var frontRadius = frontDiameter/2 * scale; 66 - var rearRadius = rearDiameter/2 * scale; 67 - 68 - this.renderApex.set( rearBase.renderOrigin ) 69 - .subtract( frontBase.renderOrigin ); 70 - 71 - // calculate tangents. 72 - var scale = frontBase.renderNormal.magnitude(); 58 + var scale = this.addTo.renderNormal.magnitude(); 59 + var frontRadius = this.addTo.frontDiameter/2 * scale; 60 + var rearRadius = this.addTo.rearDiameter/2 * scale; 61 + this.renderApex.set( this.rearBase.renderOrigin ) 62 + .subtract( this.frontBase.renderOrigin ); 63 + 73 64 var apexDistance = this.renderApex.magnitude2d(); 74 - var normalDistance = frontBase.renderNormal.magnitude2d(); 75 - // eccentricity 76 - var eccenAngle = Math.acos( normalDistance / scale ); 77 - var biggerRadius = (frontRadius > rearRadius) ? frontRadius : rearRadius; 78 - var eccenPercent; 79 - if (frontRadius == 0 || rearRadius == 0) { 80 - eccenPercent = 1.0; 81 - } else { 82 - eccenPercent = (Math.abs(frontRadius - rearRadius) / biggerRadius); 83 - } 84 - var eccen = Math.sin( eccenAngle ) * Math.sqrt(eccenPercent); 85 - // does apex extend beyond eclipse of face 86 - apexDistance = apexDistance + frontRadius/4 + rearRadius/4; 87 - var isApexVisible = frontRadius * eccen < apexDistance && 88 - rearRadius * eccen < apexDistance; 89 - if ( !isApexVisible ) { 65 + if ( apexDistance <= Math.abs( rearRadius - frontRadius ) ) { 90 66 return; 91 67 } 92 - // update tangents 93 - // TODO: try something more like horn_old.js updateSortValue() 94 - var apexAngle = Math.atan2( frontBase.renderNormal.y, frontBase.renderNormal.x ) + 95 - TAU/2; 96 - var projectFrontLength = (apexDistance + frontRadius) / eccen; 97 - var projectRearLength = (apexDistance + rearRadius) / eccen; 98 - var projectFrontAngle = Math.acos( frontRadius / projectFrontLength ); 99 - var projectRearAngle = Math.acos( rearRadius / -projectRearLength ); 100 - // set tangent points 101 - var tangentFrontA = this.tangentFrontA; 102 - var tangentFrontB = this.tangentFrontB; 103 - var tangentRearA = this.tangentRearA; 104 - var tangentRearB = this.tangentRearB; 105 68 106 - tangentFrontA.x = Math.cos( projectFrontAngle ) * frontRadius * eccen; 107 - tangentFrontA.y = Math.sin( projectFrontAngle ) * frontRadius; 108 - tangentRearA.x = Math.cos( projectRearAngle ) * rearRadius * eccen; 109 - tangentRearA.y = Math.sin( projectRearAngle ) * rearRadius; 69 + var angle = Math.atan2( this.renderApex.y, this.renderApex.x ); 70 + var angle2 = Math.acos((frontRadius - rearRadius) / apexDistance); 110 71 111 - tangentFrontB.set( this.tangentFrontA ); 112 - tangentFrontB.y *= -1; 113 - tangentRearB.set( this.tangentRearA ); 114 - tangentRearB.y *= -1; 72 + var frontTangentA = this.pathCommands[0].renderPoints[0]; 73 + var rearTangentA = this.pathCommands[1].renderPoints[0]; 74 + var rearTangentB = this.pathCommands[2].renderPoints[0]; 75 + var frontTangentB = this.pathCommands[3].renderPoints[0]; 115 76 116 - tangentFrontA.rotateZ( apexAngle); 117 - tangentFrontB.rotateZ( apexAngle); 118 - tangentFrontA.add( frontBase.renderOrigin ); 119 - tangentFrontB.add( frontBase.renderOrigin ); 120 - tangentRearA.rotateZ( apexAngle + TAU/2); 121 - tangentRearB.rotateZ( apexAngle + TAU/2); 122 - tangentRearA.add( rearBase.renderOrigin ); 123 - tangentRearB.add( rearBase.renderOrigin ); 124 - 125 - 126 - // set path command render points 127 - this.pathCommands[0].renderPoints[0].set( tangentFrontA ); 128 - this.pathCommands[1].renderPoints[0].set( tangentRearB ); 129 - this.pathCommands[2].renderPoints[0].set( tangentRearA ); 130 - this.pathCommands[3].renderPoints[0].set( tangentFrontB ); 77 + frontTangentA.x = Math.cos( angle + angle2 ) * frontRadius; 78 + frontTangentA.y = Math.sin( angle + angle2 ) * frontRadius; 79 + rearTangentA.x = Math.cos( angle + angle2 ) * rearRadius; 80 + rearTangentA.y = Math.sin( angle + angle2 ) * rearRadius; 81 + 82 + frontTangentB.x = Math.cos( angle - angle2 ) * frontRadius; 83 + frontTangentB.y = Math.sin( angle - angle2 ) * frontRadius; 84 + rearTangentB.x = Math.cos( angle - angle2 ) * rearRadius; 85 + rearTangentB.y = Math.sin( angle - angle2 ) * rearRadius; 86 + 87 + frontTangentA.add( this.frontBase.renderOrigin ); 88 + frontTangentB.add( this.frontBase.renderOrigin ); 89 + rearTangentA.add( this.rearBase.renderOrigin ); 90 + rearTangentB.add( this.rearBase.renderOrigin ); 131 91 132 92 if ( renderer.isCanvas ) { 133 93 ctx.lineCap = 'butt'; // nice 134 94 } 135 95 renderer.renderPath( ctx, elem, this.pathCommands ); 136 - //renderer.stroke( ctx, elem, true, '#333', 0.1 ); // remove once testing is done. 137 - renderer.fill( ctx, elem, true, this.color ); 96 + renderer.stroke( ctx, elem, this.stroke, this.color, Shape.prototype.getLineWidth.apply(this) ); 97 + renderer.fill( ctx, elem, this.fill, this.color ); 138 98 renderer.end( ctx, elem ); 139 99 140 100 if ( renderer.isCanvas ) { ··· 151 111 if ( !this.svgElement ) { 152 112 // create svgElement 153 113 this.svgElement = document.createElementNS( svgURI, 'path'); 114 + this.svgElement.setAttribute( 'stroke-linecap', 'round' ); 115 + this.svgElement.setAttribute( 'stroke-linejoin', 'round' ); 154 116 } 155 117 return this.svgElement; 156 118 }; ··· 189 151 this.group = new HornGroup({ 190 152 addTo: this, 191 153 color: this.color, 154 + fill: this.fill, 155 + stroke: this.stroke, 192 156 visible: this.visible, 193 157 }); 194 158 var baseZ = this.length/2; ··· 199 163 translate: { z: (baseZ - this.frontDiameter/2) }, 200 164 rotate: { y: TAU/2 }, 201 165 color: this.color, 202 - stroke: this.frontDiameter, 166 + stroke: this.frontDiameter + this.stroke, 203 167 fill: this.fill, 204 168 backface: this.frontFace || baseColor, 205 169 visible: this.visible, ··· 210 174 translate: { z: (-baseZ + this.rearDiameter/2) }, 211 175 rotate: { y: 0 }, 212 176 color: this.color, 213 - stroke: this.rearDiameter, 177 + stroke: this.rearDiameter + this.stroke, 214 178 fill: this.fill, 215 179 backface: baseColor, 216 180 visible: this.visible, 217 181 }); 218 - 182 + 219 183 }; 220 184 221 185 Horn.prototype.updateFrontCapDiameter = function(size) { 222 - this.frontBase.stroke = size; 186 + this.frontBase.stroke = size + this.stroke; 223 187 var baseZ = this.length/2; 224 188 this.frontBase.translate.z = (baseZ - size/2); 225 189 } 226 190 227 191 Horn.prototype.updateRearCapDiameter = function(size) { 228 - this.rearBase.stroke = size; 192 + this.rearBase.stroke = size + this.stroke; 229 193 var baseZ = this.length/2; 230 194 this.rearBase.translate.z = (-baseZ + size/2); 231 195 }
+5 -3
js/index.js
··· 24 24 require('./hemisphere'), 25 25 require('./cylinder'), 26 26 require('./cone'), 27 - require('./horn'), 27 + require('./horn'), 28 + require('./funnel'), 28 29 require('./box'), 29 30 require('./texture') 30 31 ); ··· 35 36 /* eslint-disable max-params */ 36 37 } )( this, function factory( Zdog, CanvasRenderer, SvgRenderer, Vector, Anchor, 37 38 Dragger, Illustration, PathCommand, Shape, Group, Rect, RoundedRect, 38 - Ellipse, Polygon, Hemisphere, Cylinder, Cone, Horn, Box, Texture ) { 39 + Ellipse, Polygon, Hemisphere, Cylinder, Cone, Horn, Funnel, Box, Texture ) { 39 40 /* eslint-enable max-params */ 40 41 41 42 Zdog.CanvasRenderer = CanvasRenderer; ··· 54 55 Zdog.Hemisphere = Hemisphere; 55 56 Zdog.Cylinder = Cylinder; 56 57 Zdog.Cone = Cone; 57 - Zdog.Horn = Horn; 58 + Zdog.Horn = Horn; 59 + Zdog.Funnel = Funnel; 58 60 Zdog.Box = Box; 59 61 Zdog.Texture = Texture; 60 62