├── LICENSE ├── README.md ├── hobby.js ├── index.html └── style.css /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Loop Space 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quick Hobby Algorithm 2 | 3 | This is a variation of John Hobby's algorithm 4 | One difficulty with Hobby's algorithm is that it works with the path as a whole. 5 | It is therefore not possible to build up a path piecewise. 6 | We therefore modify it to correct for this. 7 | Obviously, the resulting path will be less ``ideal'', but will have the property that adding new points will not affect earlier segments. 8 | The method we use is to employ Hobby's algorithm on two-segment subpaths. 9 | When applied to a two-segment subpath, the algorithm provides two cubic Bezier curves: one from the \(k\)th point to the \(k+1\)st point and the second from the \(k+1\)st to the \(k+2\)nd. 10 | Of this data, we keep the first segment and use that for the path between the \(k\)th and \(k+1\)st points. 11 | We also remember the outgoing angle of the first segment and use that as the incoming angle on the next computation (which will involve the \(k+1\)st, \(k+2\)nd, and \(k+3\)rd points). 12 | The two ends are slightly different to the middle segments. 13 | On the first segment, we might have no incoming angle. 14 | On the last segment, we render both pieces. 15 | 16 | For Mathematical details, see the documentation of the LaTeX hobby package. 17 | -------------------------------------------------------------------------------- /hobby.js: -------------------------------------------------------------------------------- 1 | var cvs; 2 | var ctx; 3 | var paths = []; 4 | var points = []; 5 | var cpath = []; 6 | var clicked; 7 | var touchid; 8 | var hobbygen; 9 | var maxWidth = 10; 10 | var minWidth = 2; 11 | maxWidth -= minWidth; 12 | 13 | function init() { 14 | cvs = document.querySelector('#hobby'); 15 | ctx = cvs.getContext("2d"); 16 | 17 | cvs.addEventListener('mousedown', doMouseDown, false); 18 | cvs.addEventListener('mouseup', doMouseUp, false); 19 | cvs.addEventListener('mouseout', doMouseOut, false); 20 | cvs.addEventListener('mousemove', doMouseMove, false); 21 | 22 | cvs.addEventListener('touchstart', doTouchStart, false); 23 | cvs.addEventListener('touchend', doTouchEnd, false); 24 | cvs.addEventListener('touchcancel', doTouchCancel, false); 25 | cvs.addEventListener('touchmove', doTouchMove, false); 26 | 27 | window.addEventListener('resize', resize, false); 28 | resize(); 29 | setInterval(draw,50); 30 | } 31 | 32 | window.onload = init; 33 | 34 | function resize() { 35 | var w = window.innerWidth; 36 | var h = window.innerHeight; 37 | cvs.setAttribute('width', w - 20); 38 | cvs.setAttribute('height', h - 20); 39 | } 40 | 41 | function draw() { 42 | clear(ctx); 43 | var pts; 44 | var sw; 45 | for (var i = 0; i < paths.length; i++) { 46 | sw = maxWidth; 47 | for (var j = 0; j < paths[i].length; j++) { 48 | pts = paths[i][j]; 49 | sw = 0.7*maxWidth + 0.3*(maxWidth*Math.exp(-vecDist(pts[3],pts[0])/10) + minWidth); 50 | ctx.beginPath(); 51 | ctx.lineWidth = sw; 52 | ctx.moveTo(pts[0].x, pts[0].y); 53 | ctx.bezierCurveTo( 54 | pts[1].x, pts[1].y, 55 | pts[2].x, pts[2].y, 56 | pts[3].x, pts[3].y 57 | ); 58 | ctx.stroke(); 59 | } 60 | } 61 | } 62 | 63 | function getRelativeCoords(event) { 64 | if (event.offsetX !== undefined && event.offsetY !== undefined) { 65 | return { x: event.offsetX, y: event.offsetY }; 66 | } 67 | if (event.layerX !== undefined && event.layerY !== undefined) { 68 | return { x: event.layerX, y: event.layerY }; 69 | } 70 | var tgt = event.target; 71 | var x = Math.floor(event.pageX - parseInt(tgt.offsetLeft,10)); 72 | var y = Math.floor(event.pageY - parseInt(tgt.offsetTop,10)); 73 | return {x: x, y: y}; 74 | } 75 | 76 | function doMouseOut(e) { 77 | e.preventDefault(); 78 | stopPath(getRelativeCoords(e)); 79 | } 80 | 81 | function doMouseMove(e) { 82 | e.preventDefault(); 83 | continuePath(getRelativeCoords(e)); 84 | } 85 | 86 | function doMouseUp(e) { 87 | e.preventDefault(); 88 | stopPath(getRelativeCoords(e)); 89 | } 90 | 91 | function doMouseDown(e) { 92 | e.preventDefault(); 93 | startPath(getRelativeCoords(e)); 94 | } 95 | 96 | function doTouchCancel(e) { 97 | e.preventDefault(); 98 | if (!touchid) {return;} 99 | for (var i = 0; i < e.changedTouches.length; i++) { 100 | if (e.changedTouches.item(i).identifier == touchid) { 101 | stopPath(getRelativeCoords(e.changedTouches.item(i))); 102 | break; 103 | } 104 | } 105 | touchid = null; 106 | } 107 | 108 | function doTouchMove(e) { 109 | e.preventDefault(); 110 | if (!touchid) {return;} 111 | for (var i = 0; i < e.changedTouches.length; i++) { 112 | if (e.changedTouches.item(i).identifier == touchid) { 113 | continuePath(getRelativeCoords(e.changedTouches.item(i))); 114 | break; 115 | } 116 | } 117 | } 118 | 119 | function doTouchEnd(e) { 120 | e.preventDefault(); 121 | if (!touchid) {return;} 122 | for (var i = 0; i < e.changedTouches.length; i++) { 123 | if (e.changedTouches.item(i).identifier == touchid) { 124 | stopPath(getRelativeCoords(e.changedTouches.item(i))); 125 | break; 126 | } 127 | } 128 | touchid = null; 129 | } 130 | 131 | function doTouchStart(e) { 132 | e.preventDefault(); 133 | touchid = e.changedTouches.item(0).identifier; 134 | startPath(getRelativeCoords(e.changedTouches.item(0))); 135 | } 136 | 137 | 138 | function startPath(p) { 139 | clicked = true; 140 | points = [p]; 141 | cpath = []; 142 | paths.push(cpath); 143 | hobbygen = false; 144 | } 145 | 146 | function continuePath(p) { 147 | if (clicked) { 148 | addPoint(p); 149 | } 150 | } 151 | 152 | function stopPath(p) { 153 | if (clicked) { 154 | addPoint(p); 155 | } 156 | clicked = false; 157 | hobbygen = false; 158 | } 159 | 160 | function addPoint(p) { 161 | points.push(p); 162 | if (!hobbygen) { 163 | hobbygen = generateHobby(points[0], points[1]); 164 | cpath[0] = [points[0],points[0],points[1],points[1]]; 165 | } else { 166 | var r = hobbygen.next(p).value; 167 | cpath[cpath.length - 1] = r[0]; 168 | cpath.push(r[1]); 169 | } 170 | } 171 | 172 | var ha = Math.sqrt(2); 173 | var hb = 1/16; 174 | var hc = (3 - Math.sqrt(5))/2; 175 | var hd = 1 - hc; 176 | 177 | function hobbySegment(a,tha, phb, b) { 178 | var c = {}; 179 | c.x = b.x - a.x; 180 | c.y = b.y - a.y; 181 | var sth = Math.sin(tha); 182 | var cth = Math.cos(tha); 183 | var sph = Math.sin(phb); 184 | var cph = Math.cos(phb); 185 | var alpha = ha * (sth - hb * sph) * (sph - hb * sth) * (cth - cph); 186 | var rho = (2 + alpha)/(1 + hd * cth + hc * cph); 187 | var sigma = (2 - alpha)/(1 + hd * cph + hc * cth); 188 | var ca = {}; 189 | ca.x = a.x + rho * (cth * c.x - sth * c.y)/3; 190 | ca.y = a.y + rho * (sth * c.x + cth * c.y)/3; 191 | var cb = {}; 192 | cb.x = b.x - sigma * (cph * c.x + sph * c.y)/3; 193 | cb.y = b.y - sigma * (-sph * c.x + cph * c.y)/3; 194 | return [a, ca, cb, b]; 195 | } 196 | 197 | function quickHobby(a,b,c,tha) { 198 | var da = vecDist(b,a); 199 | var db = vecDist(c,b); 200 | var wa = vecAng(b,a); 201 | var wb = vecAng(c,b); 202 | var psi = wb - wa; 203 | var thb, phb, phc; 204 | if (tha) { 205 | thb = -(2*psi + tha) * db / (2 * db + da); 206 | phb = - psi - thb; 207 | phc = thb; 208 | } else { 209 | thb = -psi * db / (da + db); 210 | tha = -psi - thb; 211 | phb = tha; 212 | phc = thb; 213 | } 214 | return [hobbySegment(a,tha, phb,b), hobbySegment(b,thb, phc,c), thb]; 215 | } 216 | 217 | function* generateHobby (a,b) { 218 | var th; 219 | var p,c; 220 | p = [[a,a,b,b],[a,a,b,b]]; 221 | while (true) { 222 | c = yield [p[0],p[1]]; 223 | p = quickHobby(a,b,c,th); 224 | th = p[2]; 225 | a = b; 226 | b = c; 227 | } 228 | } 229 | 230 | function vecDist(a,b) { 231 | return Math.sqrt( Math.pow(b.x - a.x,2) + Math.pow(b.y - a.y,2)); 232 | } 233 | 234 | function vecAng(a,b) { 235 | var x = b.x - a.x; 236 | var y = b.y - a.y; 237 | return Math.atan2(y,x); 238 | } 239 | 240 | function clear(c) { 241 | c.save(); 242 | c.setTransform(1,0,0,1,0,0); 243 | c.clearRect(0,0,c.canvas.width,c.canvas.height); 244 | c.restore(); 245 | } 246 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |