├── .editorconfig ├── .eslintrc ├── .gitignore ├── README.md ├── dist ├── sketch-pane.browser.js ├── sketch-pane.common.js └── vendor.browser.js ├── example ├── app.js ├── brushes │ ├── brush3.png │ ├── brushcharcoal.png │ ├── brushclouds.png │ ├── brushes.json │ ├── brushhard.png │ ├── brushmedium.png │ ├── brushmediumoval.png │ ├── brushmediumovalhollow.png │ ├── brushpencil.png │ ├── brushsoft.png │ ├── brushwatercolor.png │ ├── efficiency.png │ ├── flatbrush.png │ ├── grain1.png │ ├── graincanvas.png │ ├── graincanvas2.png │ ├── graincheckerboard.png │ ├── grainclouds.png │ ├── graingrid.png │ ├── graingrunge.png │ ├── grainpaper1.png │ ├── grainpaper2.png │ ├── grainpaper3.png │ ├── grainpaper4.png │ ├── grainscratchedmetal.png │ ├── grainslate.png │ ├── grainsolid.png │ ├── grainwatercolor1.png │ ├── grainwatercolor2.png │ ├── grainwood1.png │ ├── grainwood2.png │ ├── hardwood.png │ └── teardrop.png ├── img │ └── layers │ │ ├── grid.png │ │ ├── layer01.png │ │ ├── layer02.png │ │ └── layer03.png └── vendor │ ├── dat.gui.min.js │ └── stats.min.js ├── index.html ├── package-lock.json ├── package.json ├── src └── ts │ ├── index.ts │ └── sketch-pane │ ├── brush │ ├── brush-node-filter.ts │ ├── brush.ts │ └── brushnode.frag │ ├── cursor.ts │ ├── layer.ts │ ├── layers-collection.ts │ ├── selected-area.ts │ ├── sketch-pane.ts │ └── util.ts ├── test └── resize │ └── index.html ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | indent_style = space 7 | indent_size = 2 8 | 9 | [{package.json}] 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-undef": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # jetBrains IDE ignores 9 | .idea 10 | 11 | # VSCode ignores 12 | .vscode 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | dist/ 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alchemancy Mark III 2 | 3 | ## Wonder Unit’s drawing system 4 | 5 | ### Development 6 | 7 | Start a test server at `localhost:8000`. This watches for changes and serves compiled JavaScript from memory. If files exist in `dist/` they will be served statically, so run an `npm run clean` first to clear them. 8 | 9 | npm start 10 | 11 | Run a watcher. This watches for changes and writes compiled JavaScript to `dist/`. 12 | 13 | npm run watch 14 | 15 | Write compiled JavaScript to `dist/` 16 | 17 | npm run build 18 | 19 | Publish to `gh-pages` 20 | 21 | git status # make sure everything is checked in 22 | git pull # grab the latest master 23 | 24 | git co gh-pages 25 | git rebase master 26 | 27 | npm run clean 28 | npm run build 29 | git add -f dist/sketch-pane.js 30 | git commit -m "update dist/sketch-pane.js" 31 | git push origin gh-pages 32 | npm run clean # cleanup 33 | 34 | git co master # done! 35 | 36 | ### License 37 | 38 | © 2018 Wonder Unit. All Rights Reserved. 39 | -------------------------------------------------------------------------------- /dist/sketch-pane.browser.js: -------------------------------------------------------------------------------- 1 | var SketchLib=function(t){function e(e){for(var r,a,o=e[0],h=e[1],l=e[2],u=0,c=[];u1?1:i)},t.arrayPostDivide=function(t){for(var e=0;e0&&n[n.length-1])&&(6===s[0]||2===s[0])){a=0;continue}if(3===s[0]&&(!n||s[1]>n[0]&&s[1]r.width/r.height?r.width*i.height/r.height:i.width;this.cursor.lastPointer||(this.cursor.lastPointer=new n.Point(this.app.renderer.width/2+this.viewClientRect.left,this.app.renderer.height/2+this.viewClientRect.top)),this.centerContainer(),this.sketchPaneContainer.scale.set(Math.floor(s)/Math.floor(r.width)*this.zoom),this.sketchPaneContainer.width=Math.ceil(this.sketchPaneContainer.width),this.sketchPaneContainer.height=Math.ceil(this.sketchPaneContainer.height)},t.prototype.loadBrushes=function(t){return S(this,void 0,void 0,function(){var e,i,r,s,a,h,l,p,u,c,d,f,y,g;return m(this,function(v){switch(v.label){case 0:for(e=t.brushes,i=t.brushImagePath,this.brushes=e.reduce(function(t,e){return t[e.name]=new o(e),t},{}),r=Array.from(new Set([].concat.apply([],Object.values(this.brushes).map(function(t){return[t.settings.brushImage,t.settings.efficiencyBrushImage]})).filter(Boolean))),s=Array.from(new Set(Object.values(this.brushes).map(function(t){return t.settings.grainImage}))),a=[],h=0,l=[[r,this.images.brush],[s,this.images.grain]];h>16&255)/255,this.strokeState.isErasing?0:(this.strokeState.color>>8&255)/255,this.strokeState.isErasing?0:(255&this.strokeState.color)/255,this.strokeState.size,this.strokeState.nodeOpacityScale,d.x,d.y,y,g,v,this.brush,this.strokeState.grainOffset.x,this.strokeState.grainOffset.y]),u=p}return this.strokeState.lastSpacing=l-u,i},t.prototype.addStrokeNodes=function(t,e,i){for(var r=0,n=this.getInterpolatedStrokeInput(t,e);r0?.5:0:t.pressure,n="mouse"===t.pointerType?{angle:-90,tilt:37}:s.calcTiltAngle(t.tiltY,t.tiltX);this.strokeState.points.push({x:e.x,y:e.y,pressure:i,tiltAngle:n.angle,tilt:n.tilt}),this.strokeState.lastStaticIndex=Math.max(0,this.strokeState.lastStaticIndex-1),this.strokeState.points=this.strokeState.points.slice(Math.max(0,this.strokeState.lastStaticIndex-1),this.strokeState.points.length),this.strokeState.path=new r.Path(this.strokeState.points),this.strokeState.points.length>1&&this.strokeState.path.smooth({type:"catmull-rom",factor:.5})},t.prototype.drawStroke=function(t){if(void 0===t&&(t=!1),this.strokeState.isStraightLine){this.app.renderer.render(new n.Sprite(n.Texture.EMPTY),this.strokeSprite.texture,!0);var e=(new n.Graphics).beginFill(16711680,1).drawRect(0,0,this.width,this.height).endFill();this.app.renderer.render(e,this.eraseMask.texture,!0);var i=this.strokeState.origin,s=this.strokeState.points[this.strokeState.points.length-1];if(s.pressure=i.pressure=this.strokeState.straightLinePressure,this.strokeState.shouldSnap){var a=Math.atan2(s.y-i.y,s.x-i.x),o=Math.hypot(s.x-i.x,s.y-i.y),h=(11.25*Math.round((180*a/Math.PI+180)/11.25)-180)*Math.PI/180;s.x=i.x+Math.cos(h)*o,s.y=i.y+Math.sin(h)*o}this.strokeState.points=[i,s,s],this.strokeState.lastStaticIndex=0,this.strokeState.path=new r.Path(this.strokeState.points)}var l=this.strokeState.points.length;if(t){var p=this.strokeState.lastStaticIndex,u=this.strokeState.points.length-1;return this.addStrokeNodes(this.strokeState.points.slice(p,u+1),new r.Path(this.strokeState.path.segments.slice(p,u+1)),this.segmentContainer),this.app.renderer.render(this.segmentContainer,this.strokeSprite.texture,!1),this.strokeState.isErasing?this.updateMask(this.segmentContainer,!0):(this.strokeSprite.alpha=this.strokeState.strokeOpacityScale,this.stampStroke(this.strokeSprite,this.layers.getCurrentLayer()),this.strokeState.strokeOpacityScale<1?this.strokeSprite.alpha=this.strokeState.strokeOpacityScale:this.strokeSprite.alpha=this.strokeState.layerOpacity),this.disposeContainer(this.segmentContainer),this.offscreenContainer.removeChildren(),this.disposeContainer(this.liveContainer),this.disposeContainer(this.strokeSprite),void this.app.renderer.render(new n.Sprite(n.Texture.EMPTY),this.strokeSprite.texture,!0)}if(l>=3){p=(c=this.strokeState.points.length-1)-2,u=c-1;this.addStrokeNodes(this.strokeState.points.slice(p,u+1),new r.Path(this.strokeState.path.segments.slice(p,u+1)),this.segmentContainer),this.strokeState.isErasing?this.updateMask(this.segmentContainer):this.app.renderer.render(this.segmentContainer,this.strokeSprite.texture,!1),this.disposeContainer(this.segmentContainer),this.offscreenContainer.removeChildren(),this.strokeState.lastStaticIndex=u}if(l>=2){this.disposeContainer(this.liveContainer);var c;p=(c=this.strokeState.points.length-1)-1,u=c;if(this.strokeState.isErasing);else{var d=this.strokeState.lastSpacing;this.addStrokeNodes(this.strokeState.points.slice(p,u+1),new r.Path(this.strokeState.path.segments.slice(p,u+1)),this.liveContainer),this.strokeState.lastSpacing=d}}},t.prototype.updateMask=function(t,e){var i=this;void 0===e&&(e=!1);if(!this.strokeState.layerIndices.map(function(t){return i.layers[t]}).sort(function(t,e){return function(t,e){return e-t}(t.sprite.parent.getChildIndex(t.sprite),e.sprite.parent.getChildIndex(e.sprite))})[0].sprite.mask){this.layersContainer.addChild(this.eraseMask);var r=(new n.Graphics).beginFill(16711680,1).drawRect(0,0,this.width,this.height).endFill();this.app.renderer.render(r,this.eraseMask.texture,!0);for(var s=0,a=this.strokeState.layerIndices;s ({ 7 | name: basename, 8 | filepath: './example/img/layers/' + basename + '.png' 9 | })) 10 | 11 | const loadLayers = (sketchPane, layersData) => { 12 | return new Promise(resolve => { 13 | layersData.forEach(data => PIXI.loader.add(data.filepath, data.filepath)) 14 | PIXI.loader.load((loader, resources) => { 15 | for (let data of layersData) { 16 | let layer = sketchPane.newLayer({ name: data.name }) 17 | sketchPane.replaceLayer(layer.index, resources[data.filepath].texture) 18 | } 19 | resolve() 20 | }) 21 | }) 22 | } 23 | 24 | // via https://stackoverflow.com/a/16348977 25 | const intToHexColorString = number => { 26 | let color = '#' 27 | let i = 3 28 | while (i--) { 29 | let value = (number >> (i * 8)) & 0xFF 30 | color += value.toString(16).padStart(2, '0') 31 | } 32 | return color 33 | } 34 | 35 | if (!SketchLib.SketchPane.canInitialize()) { 36 | alert('SketchPane is not supported on this device.') 37 | } 38 | 39 | const sketchPane = new SketchLib.SketchPane({ 40 | imageWidth: 1200, 41 | imageHeight: 900, 42 | 43 | onWebGLContextLost: event => { 44 | alert('WebGL context was lost.') 45 | } 46 | }) 47 | sketchPane.resize(document.body.offsetWidth, document.body.offsetHeight) 48 | sketchPane.anchor = new PIXI.Point( 49 | sketchPane.sketchPaneContainer.position.x, 50 | sketchPane.sketchPaneContainer.position.y 51 | ) 52 | 53 | const forceClear = () => { 54 | sketchPane.app.renderer.render( 55 | sketchPane.strokeContainer, 56 | sketchPane.layers[sketchPane.getCurrentLayerIndex()].sprite.texture, 57 | true 58 | ) 59 | } 60 | 61 | window.fetch('./example/brushes/brushes.json') 62 | .then(response => response.json()) 63 | .then(brushes => { 64 | sketchPane 65 | .loadBrushes({ 66 | brushes, 67 | brushImagePath: './example/brushes' 68 | }) 69 | 70 | // NOTE example images are 1000 × 800 71 | .then(() => loadLayers(sketchPane, layersData)) 72 | 73 | // .then(() => sketchPane.newLayer({ name: 'layer 0' })) 74 | // .then(() => sketchPane.newLayer({ name: 'layer 1' })) 75 | // .then(() => sketchPane.newLayer({ name: 'layer 2' })) 76 | // .then(() => sketchPane.newLayer({ name: 'layer 3' })) 77 | 78 | .then(() => sketchPane.setCurrentLayerIndex(sketchPane.getNumLayers())) 79 | 80 | .then(() => { 81 | sketchPane.setLayerOpacity(sketchPane.getCurrentLayerIndex(), 0.75) 82 | }) 83 | 84 | .then(() => { 85 | console.log('ready') 86 | 87 | idleTimer = null 88 | 89 | // set default brush 90 | sketchPane.brush = sketchPane.brushes.pencil 91 | sketchPane.brushColor = 0x000000 92 | sketchPane.brushSize = 32 93 | sketchPane.nodeOpacityScale = 0.9 94 | sketchPane.strokeOpacityScale = 1.0 95 | 96 | sketchPane.onStrokeBefore = strokeState => { 97 | // console.log('onStrokeBefore: addToUndoStack', strokeState) 98 | } 99 | sketchPane.onStrokeAfter = strokeState => { 100 | // console.log('onStrokeAfter: markDirty', strokeState) 101 | 102 | let img = generateThumbnailImage() 103 | document.querySelector('#thumbnail').innerHTML = '' 104 | document.querySelector('#thumbnail').appendChild(img) 105 | } 106 | const generateThumbnailImage = () => { 107 | let thumbWidth = Math.ceil(sketchPane.width / 8) 108 | let thumbHeight = Math.ceil(sketchPane.height / 8) 109 | 110 | let img = new window.Image() 111 | img.src = SketchLib.util.pixelsToCanvas( 112 | sketchPane.extractThumbnailPixels(thumbWidth, thumbHeight, [1, 2, 3]), 113 | thumbWidth, 114 | thumbHeight 115 | ).toDataURL() 116 | return img 117 | } 118 | 119 | window.addEventListener('resize', function (e) { 120 | sketchPane.resize(document.body.offsetWidth, document.body.offsetHeight) 121 | }) 122 | 123 | window.addEventListener('pointerdown', function (e) { 124 | if (gui.domElement.contains(e.target)) return // ignore GUI pointer movement 125 | 126 | // stroke options 127 | // via https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events#Determining_button_states 128 | let options = (e.buttons === 32 || e.altKey) 129 | // + shift to erase multiple layers 130 | ? e.shiftKey 131 | ? { erase: [1, 2, 3] } 132 | : { erase: [sketchPane.getCurrentLayerIndex()] } 133 | : {} 134 | 135 | idleTimer && clearTimeout(idleTimer) 136 | idleTimer = setTimeout(onIdle, 500) 137 | 138 | sketchPane.down(e, options) 139 | }) 140 | 141 | window.addEventListener('pointermove', function (e) { 142 | if (gui.domElement.contains(e.target)) return // ignore GUI pointer movement 143 | 144 | if (sketchPane.strokeState && !sketchPane.strokeState.isStraightLine) { 145 | let prev = sketchPane.strokeState.points[sketchPane.strokeState.points.length - 1] 146 | if (prev) { 147 | let curr = sketchPane.localizePoint(e) 148 | if (Math.abs(prev.x - curr.x) > 1 || Math.abs(prev.y - curr.y) > 1) { 149 | idleTimer && clearTimeout(idleTimer) 150 | idleTimer = setTimeout(onIdle, 500) 151 | } 152 | } 153 | } 154 | 155 | // if (e.target.parentNode !== document.body) return 156 | sketchPane.move(e) 157 | }) 158 | 159 | window.addEventListener('pointerup', function (e) { 160 | if (gui.domElement.contains(e.target)) return // ignore GUI pointer movement 161 | 162 | idleTimer && clearTimeout(idleTimer) 163 | sketchPane.up(e) 164 | }) 165 | 166 | window.addEventListener('wheel', function (e) { 167 | if (!e.shiftKey) { 168 | // zoom 169 | let delta = e.deltaY / 100 170 | 171 | sketchPane.anchor = new PIXI.Point(e.x, e.y) 172 | sketchPane.zoom = Math.min(Math.max(sketchPane.zoom + delta, 0.75), 16) 173 | sketchPane.resize( 174 | document.body.offsetWidth, 175 | document.body.offsetHeight 176 | ) 177 | sketchPane.cursor.renderCursor(e) 178 | } else { 179 | // pan 180 | sketchPane.anchor.x -= e.deltaX 181 | sketchPane.anchor.y -= e.deltaY 182 | sketchPane.sketchPaneContainer.position.set(sketchPane.anchor.x, sketchPane.anchor.y) 183 | sketchPane.cursor.renderCursor(e) 184 | } 185 | }) 186 | 187 | window.addEventListener('keyup', function (e) { 188 | switch (e.key) { 189 | case '=': 190 | // if (e.metaKey) { 191 | sketchPane.anchor.x = sketchPane.cursor.lastPointer.x 192 | sketchPane.anchor.y = sketchPane.cursor.lastPointer.y 193 | sketchPane.zoom = Math.min(Math.max(sketchPane.zoom + 0.25, 0.75), 16) 194 | sketchPane.resize( 195 | document.body.offsetWidth, 196 | document.body.offsetHeight 197 | ) 198 | // } 199 | break 200 | case '-': 201 | // if (e.metaKey) { 202 | sketchPane.anchor.x = sketchPane.cursor.lastPointer.x 203 | sketchPane.anchor.y = sketchPane.cursor.lastPointer.y 204 | sketchPane.zoom = Math.min(Math.max(sketchPane.zoom - 0.25, 0.75), 16) 205 | sketchPane.resize( 206 | document.body.offsetWidth, 207 | document.body.offsetHeight 208 | ) 209 | // } 210 | break 211 | case '0': 212 | // if (e.metaKey) { 213 | 214 | sketchPane.anchor = new PIXI.Point( 215 | sketchPane.app.renderer.width / 2, 216 | sketchPane.app.renderer.height / 2 217 | ) 218 | sketchPane.zoom = 1 219 | sketchPane.resize( 220 | document.body.offsetWidth, 221 | document.body.offsetHeight 222 | ) 223 | // re-center 224 | sketchPane.sketchPaneContainer.pivot = new PIXI.Point( 225 | sketchPane.width / 2, 226 | sketchPane.height / 2 227 | ) 228 | 229 | // } 230 | break 231 | } 232 | }) 233 | 234 | const onIdle = () => { 235 | sketchPane.setIsStraightLine(true) 236 | } 237 | 238 | let stats = new Stats() 239 | stats.showPanel(0) 240 | document.body.appendChild(stats.dom) 241 | 242 | window.addEventListener('keydown', function (e) { 243 | // console.log(e) 244 | switch (e.key) { 245 | case ']': 246 | sketchPane.brushSize = Math.round(sketchPane.brushSize * 1.5) 247 | break 248 | case '[': 249 | sketchPane.brushSize = Math.round(sketchPane.brushSize / 1.5) 250 | break 251 | // case '1': 252 | // sketchPane.color = { 253 | // r: Math.random(), 254 | // g: Math.random(), 255 | // b: Math.random() 256 | // } 257 | // break 258 | case '2': 259 | sketchPane.size = 10 260 | break 261 | case '3': 262 | sketchPane.size = Math.random() * 300 263 | break 264 | case '4': 265 | sketchPane.opacity = Math.random() * 0.8 + 0.2 266 | break 267 | case '5': 268 | sketchPane.opacity = Math.random() * 0.8 + 0.2 269 | break 270 | case '6': 271 | sketchPane.brush = sketchPane.brushes.pen 272 | break 273 | case '7': 274 | sketchPane.brush = sketchPane.brushes.pencil 275 | break 276 | case 'c': 277 | sketchPane.clearLayer() 278 | break 279 | case 'f': 280 | sketchPane.flipLayers(false) 281 | break 282 | case 'F': 283 | if (e.shiftKey) sketchPane.flipLayers(true) 284 | break 285 | } 286 | }) 287 | 288 | document.getElementById('l-0').addEventListener('click', function (e) { 289 | sketchPane.setCurrentLayerIndex(0) 290 | }) 291 | 292 | document.getElementById('l-1').addEventListener('click', function (e) { 293 | sketchPane.setCurrentLayerIndex(1) 294 | }) 295 | 296 | document.getElementById('l-2').addEventListener('click', function (e) { 297 | sketchPane.setCurrentLayerIndex(2) 298 | }) 299 | 300 | document.getElementById('l-3').addEventListener('click', function (e) { 301 | sketchPane.setCurrentLayerIndex(3) 302 | }) 303 | 304 | document.getElementById('b-1').addEventListener('click', function (e) { 305 | sketchPane.brush = sketchPane.brushes.pencil 306 | sketchPane.brushSize = 4 307 | sketchPane.nodeOpacityScale = 0.8 308 | sketchPane.brushColor = 0x000000 309 | }) 310 | 311 | document.getElementById('b-2').addEventListener('click', function (e) { 312 | sketchPane.brush = sketchPane.brushes.pen 313 | sketchPane.brushSize = 4 314 | sketchPane.nodeOpacityScale = 0.9 315 | sketchPane.brushColor = 0x000000 316 | }) 317 | 318 | document.getElementById('b-copic').addEventListener('click', function (e) { 319 | sketchPane.brush = sketchPane.brushes.copic 320 | sketchPane.brushSize = 40 321 | sketchPane.nodeOpacityScale = 0.6 322 | sketchPane.brushColor = 0xccccff 323 | }) 324 | 325 | document.getElementById('b-3').addEventListener('click', function (e) { 326 | sketchPane.brush = sketchPane.brushes.charcoal 327 | sketchPane.brushSize = 50 328 | sketchPane.nodeOpacityScale = 0.6 329 | sketchPane.brushColor = 0x9999ff 330 | }) 331 | 332 | document.getElementById('b-4').addEventListener('click', function (e) { 333 | sketchPane.brush = sketchPane.brushes.watercolor 334 | sketchPane.brushSize = 100 335 | sketchPane.nodeOpacityScale = 0.4 336 | sketchPane.brushColor = 0xccccff 337 | }) 338 | 339 | document.getElementById('b-5').addEventListener('click', function (e) { 340 | sketchPane.brush = sketchPane.brushes.clouds 341 | }) 342 | 343 | document.getElementById('b-6').addEventListener('click', function (e) { 344 | sketchPane.brush = sketchPane.brushes.slate 345 | }) 346 | 347 | document.getElementById('b-7').addEventListener('click', function (e) { 348 | sketchPane.brush = sketchPane.brushes.brushpen 349 | sketchPane.brushSize = 15 350 | sketchPane.nodeOpacityScale = 1 351 | sketchPane.brushColor = 0x000000 352 | }) 353 | 354 | document.getElementById('c-1').addEventListener('click', function (e) { 355 | sketchPane.brushColor = 0x000000 356 | }) 357 | 358 | document.getElementById('c-2').addEventListener('click', function (e) { 359 | sketchPane.brushColor = 0x000033 360 | }) 361 | 362 | document.getElementById('c-3').addEventListener('click', function (e) { 363 | sketchPane.brushColor = 0x4d4d99 364 | }) 365 | 366 | document.getElementById('c-4').addEventListener('click', function (e) { 367 | sketchPane.brushColor = 0xb3b3cc 368 | }) 369 | 370 | document.getElementById('c-5').addEventListener('click', function (e) { 371 | sketchPane.brushColor = 0xccccff 372 | }) 373 | 374 | document.getElementById('c-6').addEventListener('click', function (e) { 375 | sketchPane.brushColor = 0xffff4d 376 | }) 377 | 378 | document.getElementById('c-7').addEventListener('click', function (e) { 379 | sketchPane.brushColor = 0xffffff 380 | }) 381 | 382 | document.getElementById('s-1').addEventListener('click', function (e) { 383 | sketchPane.brushSize = 3 384 | }) 385 | 386 | document.getElementById('s-2').addEventListener('click', function (e) { 387 | sketchPane.brushSize = 6 388 | }) 389 | 390 | document.getElementById('s-3').addEventListener('click', function (e) { 391 | sketchPane.brushSize = 40 392 | }) 393 | 394 | document.getElementById('s-4').addEventListener('click', function (e) { 395 | sketchPane.brushSize = 100 396 | }) 397 | 398 | document.getElementById('o-1').addEventListener('click', function (e) { 399 | sketchPane.nodeOpacityScale = 0.1 400 | }) 401 | 402 | document.getElementById('o-2').addEventListener('click', function (e) { 403 | sketchPane.nodeOpacityScale = 0.3 404 | }) 405 | 406 | document.getElementById('o-3').addEventListener('click', function (e) { 407 | sketchPane.nodeOpacityScale = 0.5 408 | }) 409 | 410 | document.getElementById('o-4').addEventListener('click', function (e) { 411 | sketchPane.nodeOpacityScale = 0.8 412 | }) 413 | 414 | document.getElementById('o-5').addEventListener('click', function (e) { 415 | sketchPane.nodeOpacityScale = 1 416 | }) 417 | 418 | document.getElementById('clear').addEventListener('click', function (e) { 419 | sketchPane.clearLayer() 420 | }) 421 | 422 | document.getElementById('spin').addEventListener('click', function (e) { 423 | guiState.spin = !guiState.spin 424 | if (!guiState.spin) { 425 | sketchPane.sketchPaneContainer.rotation = 0 426 | sketchPane.sketchPaneContainer.scale.set(1) 427 | } 428 | }) 429 | 430 | document.getElementById('save').addEventListener('click', function (e) { 431 | let data = sketchPane.exportLayer() 432 | console.log('got PNG image data', data) 433 | }) 434 | 435 | const onSpacingClick = e => { 436 | sketchPane.brush.settings.spacing = parseFloat(e.target.textContent) 437 | } 438 | document 439 | .getElementById('spacing-1') 440 | .addEventListener('click', onSpacingClick) 441 | document 442 | .getElementById('spacing-2') 443 | .addEventListener('click', onSpacingClick) 444 | document 445 | .getElementById('spacing-3') 446 | .addEventListener('click', onSpacingClick) 447 | document 448 | .getElementById('spacing-4') 449 | .addEventListener('click', onSpacingClick) 450 | document 451 | .getElementById('spacing-5') 452 | .addEventListener('click', onSpacingClick) 453 | 454 | // fake some pointer movements 455 | const fakeEvent = ({ x, y, pressure = 1.0 }) => ({ 456 | x, 457 | y, 458 | pressure, 459 | tiltX: 0, 460 | tiltY: 0, 461 | target: sketchPane.app.view 462 | }) 463 | 464 | // const testTwoPointStroke = (opt = { distance: 0 }) => { 465 | // let {x, y} = sketchPane.strokeContainer.toGlobal({ 466 | // x: sketchPane.app.view.width / 2, 467 | // y: sketchPane.app.view.height / 2 468 | // }) 469 | // sketchPane.down(fakeEvent({ x: x - opt.distance, y })) 470 | // sketchPane.up(fakeEvent({ x: x + opt.distance, y })) 471 | // } 472 | // testTwoPointStroke({ distance: 0 }) 473 | 474 | const drawStrokes = () => { 475 | // sketchPane.brush = sketchPane.brushes.pen 476 | // sketchPane.brushSize = 30 477 | // sketchPane.nodeOpacityScale = 0.9 478 | // sketchPane.brushColor = { r: 0, g: 0, b: 0 } 479 | // sketchPane.brush.settings.spacing = 0.7 480 | 481 | for (let i = 0; i < Math.PI * 2 * 2; i++) { 482 | let x = 350 + i * 50 483 | let y = 400 + Math.cos(i) * 50 484 | sketchPane.addPointerEventAsPoint(fakeEvent({ x, y })) 485 | sketchPane.renderLive() 486 | } 487 | 488 | ;(async function () { 489 | // let dur = 100 490 | sketchPane.down(fakeEvent({ x: 350, y: 300 })) 491 | // await new Promise(resolve => setTimeout(resolve, dur)) 492 | sketchPane.move(fakeEvent({ x: 350 + 70, y: 305 })) 493 | // await new Promise(resolve => setTimeout(resolve, dur)) 494 | sketchPane.move(fakeEvent({ x: 350 + 70 + 70, y: 310 })) 495 | // await new Promise(resolve => setTimeout(resolve, dur)) 496 | sketchPane.move(fakeEvent({ x: 350 + 70 + 70 + 70, y: 310 })) 497 | // await new Promise(resolve => setTimeout(resolve, dur)) 498 | sketchPane.move( 499 | fakeEvent({ x: 350 + 70 + 70 + 70 + 70, y: 310 }) 500 | ) 501 | // await new Promise(resolve => setTimeout(resolve, dur)) 502 | sketchPane.move( 503 | fakeEvent({ x: 350 + 70 + 70 + 70 + 70 + 70, y: 310 }) 504 | ) 505 | // await new Promise(resolve => setTimeout(resolve, dur)) 506 | sketchPane.up(fakeEvent({ x: 700, y: 310 })) 507 | })() 508 | } 509 | 510 | const plotLines = (px = 550, py = 400) => { 511 | // sketchPane.brush = sketchPane.brushes.pen 512 | // sketchPane.brushSize = 4 513 | // sketchPane.nodeOpacityScale = 0.9 514 | // sketchPane.brushColor = { r: 0, g: 0, b: 0 } 515 | 516 | // sketchPane.brush = sketchPane.brushes.brushpen 517 | // sketchPane.brushSize = 15 518 | // sketchPane.nodeOpacityScale = 1 519 | // sketchPane.brushColor = { r: 0, g: 0, b: 0 } 520 | 521 | // sketchPane.brush = sketchPane.brushes.watercolor 522 | // sketchPane.brushSize = 50 523 | // sketchPane.nodeOpacityScale = 0.4 524 | // sketchPane.brushColor = { r: 0.8, g: 0.8, b: 1 } 525 | 526 | let angle = 0 527 | const plot = (x, y) => { 528 | angle = (angle + sketchPane.brushSize) % 360 529 | sketchPane.addStrokeNode( 530 | ((sketchPane.brushColor >> 16) & 255) / 255, 531 | ((sketchPane.brushColor >> 8) & 255) / 255, 532 | (sketchPane.brushColor & 255) / 255, 533 | sketchPane.brushSize, 534 | sketchPane.nodeOpacityScale, 535 | x, 536 | y, 537 | 1.0, // pressure 538 | angle, // angle 539 | 0, // tilt 540 | sketchPane.brush, 541 | 0, // grainOffset 542 | 0, // grainOffset 543 | sketchPane.strokeContainer 544 | ) 545 | } 546 | 547 | let origin 548 | let m 549 | let spacing 550 | 551 | // Line #1 552 | origin = [px, py] 553 | m = 3 / 400 554 | spacing = 1 555 | for (let x = 0; x <= 400; x += spacing) { 556 | let y = m * x 557 | plot(x + origin[0], y + origin[1]) 558 | } 559 | 560 | // Line #2 561 | origin = [px, py + 100] 562 | m = 3 / 400 563 | spacing = sketchPane.brushSize // 4 564 | for (let x = 0; x <= 400; x += spacing) { 565 | let y = m * x 566 | plot(x + origin[0], y + origin[1]) 567 | } 568 | 569 | // Line #3 570 | origin = [px, py + 200] 571 | m = 3 / 400 572 | spacing = 5 573 | for (let x = 0; x <= 400; x += spacing) { 574 | let y = m * x 575 | plot(x + origin[0], y + origin[1]) 576 | } 577 | 578 | // Line #4 579 | origin = [px, py + 300] 580 | m = 3 / 400 581 | spacing = 10 582 | for (let x = 0; x <= 400; x += spacing) { 583 | let y = m * x 584 | plot(x + origin[0], y + origin[1]) 585 | } 586 | } 587 | document.getElementById('plot-lines').addEventListener('click', event => { 588 | event.preventDefault() 589 | plotLines() 590 | }) 591 | 592 | document.getElementById('draw-strokes').addEventListener('click', event => { 593 | event.preventDefault() 594 | drawStrokes() 595 | }) 596 | 597 | document 598 | .getElementById('draw-pressure') 599 | .addEventListener('click', event => { 600 | event.preventDefault() 601 | drawPressureLine() 602 | }) 603 | 604 | // const drawPressureWave = (px = 350, py = 400) => { 605 | // let end = Math.PI * 2 * 4 606 | // let x 607 | // let y 608 | // let pressure 609 | // for (let i = 0; i < end; i++) { 610 | // x = px + i * (100 / Math.PI) 611 | // y = py + (Math.cos(i) * 50) 612 | // pressure = i / end 613 | // // sketchPane.addPointerEventAsPoint(fakeEvent({ x, y, pressure })) 614 | // // sketchPane.renderLive() 615 | // if (i === 0) { 616 | // sketchPane.down(fakeEvent({ x, y, pressure })) 617 | // } 618 | // sketchPane.move(fakeEvent({ x, y, pressure })) 619 | // } 620 | // sketchPane.up(fakeEvent({ x, y, pressure })) 621 | // } 622 | 623 | // a direct sprite test 624 | const drawSpriteLineTest = (px = 350.5, py = 400.5) => { 625 | let x 626 | let y 627 | // let pressure 628 | // let step = 0.1 629 | let t 630 | let max = 400 631 | let i = 0 632 | let nodeSize = 1 633 | while (i <= max) { 634 | t = i / max 635 | x = px + (i * guiState.spriteLineTest.spacing) 636 | y = py + (t * 0) 637 | // pressure = t 638 | 639 | // eslint-disable-next-line new-cap 640 | let sprite = new PIXI.Sprite.from( 641 | sketchPane.images.brush[sketchPane.brush.settings.brushImage].texture.clone() 642 | ) 643 | 644 | let iS = Math.ceil(nodeSize) 645 | x -= iS / 2 646 | y -= iS / 2 647 | sprite.x = Math.floor(x) 648 | sprite.y = Math.floor(y) 649 | // sprite.anchor.set(0.5) 650 | sprite.width = iS 651 | sprite.height = iS 652 | sketchPane.strokeContainer.addChild(sprite) 653 | 654 | let dX = x - sprite.x 655 | let dY = y - sprite.y 656 | let dS = nodeSize / sprite.width 657 | 658 | let filter = new PIXI.Filter( 659 | null, 660 | ` 661 | varying vec2 vTextureCoord; 662 | varying vec2 vFilterCoord; 663 | uniform sampler2D uSampler; 664 | uniform vec2 u_offset_px; 665 | uniform float u_node_scale; 666 | uniform vec4 filterArea; 667 | uniform vec2 dimensions; 668 | uniform vec4 filterClamp; 669 | vec2 mapCoord (vec2 coord) { 670 | coord *= filterArea.xy; 671 | return coord; 672 | } 673 | 674 | vec2 unmapCoord (vec2 coord) { 675 | coord /= filterArea.xy; 676 | return coord; 677 | } 678 | vec2 scale (vec2 v, vec2 _scale) { 679 | mat2 m = mat2(_scale.x, 0.0, 0.0, _scale.y); 680 | return m * v; 681 | } 682 | void main (void) { 683 | vec3 PINK = vec3(1., 0., 1.); 684 | 685 | vec2 coord = mapCoord(vTextureCoord) / dimensions; 686 | 687 | coord -= 0.5; 688 | coord *= u_node_scale; 689 | coord += 0.5; 690 | 691 | // translate by the subpixel 692 | coord -= u_offset_px / dimensions; 693 | 694 | coord = unmapCoord(coord) * dimensions; 695 | 696 | if (coord == clamp(coord, filterClamp.xy, filterClamp.zw)) { 697 | vec4 sample = texture2D(uSampler, coord); 698 | gl_FragColor = vec4(PINK, 1.0) * sample.r; 699 | } else { 700 | gl_FragColor = vec4(0.); 701 | } 702 | 703 | // to diagnose 704 | // vec4 sample = texture2D(uSampler, coord); 705 | // gl_FragColor = sample; 706 | } 707 | `, 708 | { 709 | u_offset_px: { type: 'vec2', value: [0.0, 0.0] }, 710 | u_node_scale: { type: '1f', value: 1.0 }, 711 | dimensions: { type: 'vec2', value: [0.0, 0.0] } 712 | }) 713 | filter.apply = (filterManager, input, output, clear) => { 714 | filter.uniforms.dimensions[0] = input.sourceFrame.width 715 | filter.uniforms.dimensions[1] = input.sourceFrame.height 716 | filterManager.applyFilter(filter, input, output, clear) 717 | } 718 | filter.padding = guiState.spriteLineTest.padding // for pixel offset 719 | filter.autoFit = false 720 | 721 | filter.uniforms.u_offset_px = [dX, dY] 722 | filter.uniforms.u_node_scale = 1.0 / dS 723 | 724 | sprite.filters = [filter] 725 | 726 | i += nodeSize 727 | nodeSize += guiState.spriteLineTest.scale 728 | } 729 | } 730 | 731 | const drawPressureLine = (px = 350, py = 400) => { 732 | let x 733 | let y 734 | let pressure 735 | let steps = 10 736 | let t 737 | for (let i = 0; i <= steps; i += 1) { 738 | t = i / steps 739 | x = t * 600 + px 740 | y = t * 1 + py 741 | pressure = t 742 | if (t === 0) { 743 | sketchPane.down(fakeEvent({ x, y, pressure })) 744 | } 745 | sketchPane.move(fakeEvent({ x, y, pressure })) 746 | if (t === 1) { 747 | sketchPane.up(fakeEvent({ x, y, pressure })) 748 | } 749 | } 750 | } 751 | 752 | const drawNodeTest = (state) => { 753 | let x = Math.floor(sketchPane.sketchPaneContainer.width / 2) 754 | let y = Math.floor(sketchPane.sketchPaneContainer.height / 2) 755 | 756 | sketchPane.addStrokeNode( 757 | ((sketchPane.brushColor >> 16) & 255) / 255, 758 | ((sketchPane.brushColor >> 8) & 255) / 255, 759 | (sketchPane.brushColor & 255) / 255, 760 | sketchPane.brushSize, 761 | sketchPane.nodeOpacityScale, 762 | x + guiState.nodeTest.offsetX, 763 | y + guiState.nodeTest.offsetY, 764 | state.pressure, // pressure 765 | state.angle, // angle 766 | 0, // tilt 767 | sketchPane.brush, 768 | 0, // grainOffset 769 | 0, // grainOffset 770 | guiState.nodeTest.container 771 | ) 772 | } 773 | 774 | const drawTexturedBackgroundTest = (state) => { 775 | let container = guiState.drawTexturedBackgroundTest.container 776 | 777 | container.removeChildren() 778 | for (let child of container.children) { 779 | child.destroy({ 780 | children: true, 781 | 782 | texture: false, 783 | baseTexture: false 784 | }) 785 | } 786 | 787 | let sprite = PIXI.Sprite.from(sketchPane.images.grain[sketchPane.brush.settings.grainImage].texture) 788 | container.addChild(sprite) 789 | } 790 | 791 | /* 792 | setTimeout(() => { 793 | // sketchPane.brushSize = 8 794 | 795 | // sketchPane.brush = sketchPane.brushes.watercolor 796 | // sketchPane.brushSize = 75 797 | // sketchPane.nodeOpacityScale = 0.4 798 | // sketchPane.brushColor = { r: 0.8, g: 0.8, b: 1 } 799 | // drawPressureWave(550, 350) 800 | 801 | // sketchPane.brush = sketchPane.brushes.watercolor 802 | // sketchPane.brushSize = 75 803 | // sketchPane.nodeOpacityScale = 0.4 804 | // sketchPane.brushColor = { r: 0.8, g: 0.8, b: 1 } 805 | // drawPressureLine(550, 350) 806 | 807 | // let p1 = sketchPane.strokeContainer.toGlobal({ 808 | // x: (sketchPane.sketchPaneContainer.width - 400) / 2, 809 | // y: (sketchPane.sketchPaneContainer.height - 400) / 2 810 | // }) 811 | // plotLines(p1.x, p1.y) 812 | 813 | // sketchPane.brush = sketchPane.brushes.watercolor 814 | // sketchPane.brushSize = 50 815 | // sketchPane.nodeOpacityScale = 0.4 816 | // sketchPane.brushColor = { r: 0.8, g: 0.8, b: 1 } 817 | // plotLines(550, 450) 818 | 819 | // draw a line from center with pressure 820 | // ;(function () { 821 | // let { x, y } = sketchPane.strokeContainer.toGlobal({ 822 | // x: sketchPane.sketchPaneContainer.parent.width / 2 - 540 / 2, 823 | // y: sketchPane.sketchPaneContainer.parent.height / 2 824 | // }) 825 | // sketchPane.brushSize = 10 826 | // sketchPane.brush.settings.spacing = 0.5 827 | // drawPressureLine(x, y) 828 | // })() 829 | }, 10) 830 | */ 831 | 832 | let onRender 833 | 834 | let guiState = { 835 | brush: sketchPane.brush.settings.name, 836 | 837 | nodeTest: { 838 | enabled: false, 839 | container: sketchPane.liveStrokeContainer, 840 | 841 | offsetX: 0, 842 | offsetY: 0, 843 | 844 | pressure: 1.0, 845 | angle: 45 846 | }, 847 | 848 | pressureLineTest: { 849 | enabled: false 850 | }, 851 | 852 | spriteLineTest: { 853 | enabled: false, 854 | spacing: 0.5, 855 | scale: 0.4, 856 | padding: 1 857 | }, 858 | 859 | plotLineTest: { 860 | enabled: false 861 | }, 862 | 863 | delayedTextureRenderTest: { 864 | enabled: false 865 | }, 866 | 867 | drawTexturedBackgroundTest: { 868 | enabled: false, 869 | container: sketchPane.sketchPaneContainer.addChild(new PIXI.Container()) 870 | }, 871 | 872 | calculated: { 873 | color: intToHexColorString(sketchPane.brushColor) 874 | }, 875 | 876 | spin: false 877 | } 878 | const initGUI = (gui) => { 879 | let counter = 0 880 | sketchPane.app.ticker.add(e => { 881 | // sketchPane.brushSize = Math.sin(counter/30)*200+300 882 | if (guiState.spin) { 883 | sketchPane.sketchPaneContainer.rotation += 0.01 884 | sketchPane.sketchPaneContainer.scale.set( 885 | Math.sin(counter / 30) * 1 + 1.8 886 | ) 887 | } 888 | counter++ 889 | }) 890 | 891 | let sketchPaneFolder = gui.addFolder('sketchPane') 892 | sketchPaneFolder.add(sketchPane, 'efficiencyMode') 893 | sketchPaneFolder.add(guiState, 'brush') 894 | .options(Object.keys(sketchPane.brushes)) 895 | .onChange(function (value) { 896 | sketchPane.brush = sketchPane.brushes[value] 897 | }) 898 | sketchPaneFolder.add(sketchPane, 'brushSize', 0.5, 256).listen() 899 | sketchPaneFolder.add(sketchPane, 'brushSize', 0.5, 16).name('brushSize (fine)').listen() 900 | sketchPaneFolder.add(sketchPane, 'nodeOpacityScale', 0, 1.0).listen() 901 | sketchPaneFolder.add(sketchPane, 'strokeOpacityScale', 0, 1.0).listen() 902 | // sketchPaneFolder.add(sketchPane.brushColor, 'r', 0, 1.0).name('brushColor (r)').listen() 903 | // sketchPaneFolder.add(sketchPane.brushColor, 'g', 0, 1.0).name('brushColor (g)').listen() 904 | // sketchPaneFolder.add(sketchPane.brushColor, 'b', 0, 1.0).name('brushColor (b)').listen() 905 | sketchPaneFolder.open() 906 | 907 | let brushSettingsFolder = gui.addFolder('brush.settings') 908 | brushSettingsFolder.add(sketchPane.brush.settings, 'pressureOpacity', 0, 1.0).listen() 909 | brushSettingsFolder.add(sketchPane.brush.settings, 'tiltOpacity', 0, 1.0).listen() 910 | 911 | brushSettingsFolder.add(sketchPane.brush.settings, 'spacing', 0.001, 8.0).listen() 912 | brushSettingsFolder.add(sketchPane.brush.settings, 'spacing', 0.001, 1.0).name('spacing (fine)').listen() 913 | brushSettingsFolder.open() 914 | 915 | let nodeTestFolder = gui.addFolder('node test') 916 | nodeTestFolder.add(guiState.nodeTest, 'enabled').onChange(function (enabled) { 917 | if (!enabled) { 918 | // clear it 919 | sketchPane.disposeContainer(guiState.nodeTest.container) 920 | } 921 | }).listen() 922 | nodeTestFolder.add(guiState.nodeTest, 'offsetX', -1, 1).step(0.001).listen() 923 | nodeTestFolder.add(guiState.nodeTest, 'offsetY', -1, 1).step(0.001).listen() 924 | nodeTestFolder.add(guiState.nodeTest, 'pressure', 0, 1.0).listen() 925 | nodeTestFolder.add(guiState.nodeTest, 'angle', 0, 360).step(15).listen() 926 | nodeTestFolder.addColor(guiState.calculated, 'color') 927 | .onChange(function (value) { 928 | sketchPane.brushColor = parseInt(value.substr(1), 16) 929 | console.log('gui color changed to', value, 'which means', sketchPane.brushColor) 930 | }) 931 | .listen() 932 | nodeTestFolder.open() 933 | 934 | let pressureLineTestFolder = gui.addFolder('pressure line test (uses paths)') 935 | pressureLineTestFolder.add(guiState.pressureLineTest, 'enabled').onChange(function (enabled) { 936 | if (!enabled) { 937 | // clear it 938 | forceClear() 939 | } 940 | }).listen() 941 | pressureLineTestFolder.open() 942 | 943 | let spriteLineTestFolder = gui.addFolder('sprite line test (no paths)') 944 | spriteLineTestFolder.add(guiState.spriteLineTest, 'enabled').onChange(function (enabled) { 945 | if (!enabled) { 946 | // clear it 947 | sketchPane.disposeContainer(sketchPane.strokeContainer) 948 | forceClear() 949 | } 950 | }).listen() 951 | spriteLineTestFolder.add(guiState.spriteLineTest, 'spacing', 0.001, 2.0).listen() 952 | spriteLineTestFolder.add(guiState.spriteLineTest, 'scale', 0.001, 2.0).listen() 953 | spriteLineTestFolder.add(guiState.spriteLineTest, 'padding', 0, 16).step(1).listen() 954 | spriteLineTestFolder.open() 955 | 956 | let plotLineTestFolder = gui.addFolder('plot line test (rotation)') 957 | plotLineTestFolder.add(guiState.plotLineTest, 'enabled').onChange(function (enabled) { 958 | if (!enabled) { 959 | // clear it 960 | sketchPane.disposeContainer(sketchPane.strokeContainer) 961 | forceClear() 962 | } 963 | }).listen() 964 | plotLineTestFolder.open() 965 | 966 | let delayedTextureRenderTestFolder = gui.addFolder('render-to-texture test (with delay)') 967 | delayedTextureRenderTestFolder.add(guiState.delayedTextureRenderTest, 'enabled').onChange(function (enabled) { 968 | if (!enabled) { 969 | // clear it 970 | sketchPane.disposeContainer(sketchPane.strokeContainer) 971 | forceClear() 972 | } 973 | }).listen() 974 | delayedTextureRenderTestFolder.open() 975 | 976 | let drawTexturedBackgroundTestFolder = gui.addFolder('grain background test') 977 | drawTexturedBackgroundTestFolder.add(guiState.drawTexturedBackgroundTest, 'enabled').onChange(function (enabled) { 978 | if (!enabled) { 979 | if (guiState.drawTexturedBackgroundTest.container) { 980 | sketchPane.disposeContainer(guiState.drawTexturedBackgroundTest.container) 981 | } 982 | } 983 | }).listen() 984 | drawTexturedBackgroundTestFolder.open() 985 | 986 | // HACK sync values every 250 msecs 987 | setInterval(() => { 988 | guiState.calculated.color = intToHexColorString(sketchPane.brushColor) 989 | }, 250) 990 | 991 | gui.width = 285 992 | gui.close() 993 | 994 | onRender = elapsed => { 995 | } 996 | 997 | setInterval(() => { 998 | if (guiState.drawTexturedBackgroundTest.enabled) { 999 | drawTexturedBackgroundTest() 1000 | } 1001 | 1002 | if (guiState.pressureLineTest.enabled) { 1003 | forceClear() 1004 | drawPressureLine() 1005 | } 1006 | 1007 | if (guiState.spriteLineTest.enabled) { 1008 | sketchPane.disposeContainer(sketchPane.strokeContainer) 1009 | // forceClear() 1010 | drawSpriteLineTest() 1011 | // setTimeout(() => { 1012 | // sketchPane.renderToLayer( 1013 | // sketchPane.strokeContainer, 1014 | // sketchPane.layers[sketchPane.layer] 1015 | // ) 1016 | // sketchPane.disposeContainer(sketchPane.strokeContainer) 1017 | // }, 500) 1018 | } 1019 | 1020 | if (guiState.plotLineTest.enabled) { 1021 | // clear and draw plot lines to sprites 1022 | sketchPane.disposeContainer(sketchPane.strokeContainer) 1023 | forceClear() 1024 | let p1 = sketchPane.strokeContainer.toGlobal({ 1025 | x: (sketchPane.sketchPaneContainer.width - 400) / 2, 1026 | y: (sketchPane.sketchPaneContainer.height - 400) / 2 1027 | }) 1028 | plotLines(p1.x, p1.y) 1029 | } 1030 | }, 100) 1031 | } 1032 | 1033 | setInterval(() => { 1034 | if (guiState.delayedTextureRenderTest.enabled) { 1035 | // clear and draw plot lines to sprites 1036 | sketchPane.disposeContainer(sketchPane.strokeContainer) 1037 | forceClear() 1038 | let p1 = sketchPane.strokeContainer.toGlobal({ 1039 | x: (sketchPane.sketchPaneContainer.width - 400) / 2, 1040 | y: (sketchPane.sketchPaneContainer.height - 400) / 2 1041 | }) 1042 | plotLines(p1.x, p1.y) 1043 | 1044 | // to detect small pixel shifts on texture render, 1045 | // wait for a bit, then render to the actual texture 1046 | setTimeout(() => { 1047 | // hacky fix to calculate vFilterCoord properly 1048 | sketchPane.strokeContainer.getLocalBounds() 1049 | sketchPane.liveStrokeContainer.getLocalBounds() 1050 | sketchPane.offscreenContainer.getLocalBounds() 1051 | 1052 | sketchPane.app.renderer.render( 1053 | sketchPane.strokeContainer, 1054 | sketchPane.layers[sketchPane.getCurrentLayerIndex()].sprite.texture 1055 | ) 1056 | sketchPane.disposeContainer(sketchPane.strokeContainer) 1057 | sketchPane.offscreenContainer.removeChildren() 1058 | }, 375) 1059 | } 1060 | 1061 | if (guiState.nodeTest.enabled) { 1062 | forceClear() 1063 | sketchPane.disposeContainer(guiState.nodeTest.container) 1064 | drawNodeTest(guiState.nodeTest) 1065 | 1066 | setTimeout(() => { 1067 | // hacky fix to calculate vFilterCoord properly 1068 | sketchPane.strokeContainer.getLocalBounds() 1069 | sketchPane.liveStrokeContainer.getLocalBounds() 1070 | sketchPane.offscreenContainer.getLocalBounds() 1071 | 1072 | sketchPane.app.renderer.render( 1073 | guiState.nodeTest.container, 1074 | sketchPane.layers[sketchPane.getCurrentLayerIndex()].sprite.texture 1075 | ) 1076 | sketchPane.disposeContainer(guiState.nodeTest.container) 1077 | sketchPane.offscreenContainer.removeChildren() 1078 | }, 250) 1079 | } 1080 | }, 750) 1081 | 1082 | let start = null 1083 | function animate (timestamp) { 1084 | if (start == null) start = timestamp 1085 | let elapsed = timestamp - start 1086 | stats.begin() 1087 | onRender && onRender(elapsed) 1088 | stats.end() 1089 | window.requestAnimationFrame(animate) 1090 | } 1091 | 1092 | initGUI(gui) 1093 | 1094 | window.sketchPane = sketchPane 1095 | document.body.appendChild(sketchPane.app.view) 1096 | 1097 | window.requestAnimationFrame(animate) 1098 | }) 1099 | .catch(err => console.error(err)) 1100 | }) 1101 | -------------------------------------------------------------------------------- /example/brushes/brush3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brush3.png -------------------------------------------------------------------------------- /example/brushes/brushcharcoal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushcharcoal.png -------------------------------------------------------------------------------- /example/brushes/brushclouds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushclouds.png -------------------------------------------------------------------------------- /example/brushes/brushes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "default", 4 | "descriptiveName": "Default Brush" 5 | }, 6 | 7 | { 8 | "name": "pencil", 9 | "descriptiveName": "Pencil", 10 | "brushImage": "brushmediumoval", 11 | "grainImage": "grainpaper4", 12 | "pressureOpacity": 0.7, 13 | "pressureSize": 0.8, 14 | "scale": 0.8, 15 | "tiltOpacity": 0.3, 16 | "tiltSize": 1, 17 | "movement": 1, 18 | "pressureBleed": 1, 19 | "spacing": 0.05, 20 | 21 | "efficiencyBrushImage": "efficiency", 22 | "efficiencySpacing": 0.05 23 | }, 24 | 25 | { 26 | "name": "brushpen", 27 | "descriptiveName": "Brush Pen Bobby", 28 | "brushImage": "teardrop", 29 | "grainImage": "hardwood", 30 | "pressureOpacity": 0.3, 31 | "scale": 0.5, 32 | "movement": 0.7, 33 | "sizecale": 0.6, 34 | 35 | "efficiencyBrushImage": "efficiency", 36 | "efficiencySpacing": 0.05 37 | }, 38 | 39 | { 40 | "name": "pen", 41 | "descriptiveName": "Pen", 42 | "brushImage": "brushhard", 43 | "grainImage": "grainpaper2", 44 | "pressureOpacity": 0.5, 45 | "pressureSize": 0.8, 46 | "sizecale": 0.8, 47 | "pressureBleed": 2, 48 | "tiltSize": 3.8, 49 | "tiltOpacity": 1, 50 | "movement": 0.9, 51 | "spacing": 0.05, 52 | 53 | "efficiencyBrushImage": "efficiency", 54 | "efficiencySpacing": 0.05 55 | }, 56 | 57 | { 58 | "name": "copic", 59 | "descriptiveName": "Copic", 60 | "brushImage": "brushmediumovalhollow", 61 | "grainImage": "grainpaper2", 62 | "pressureOpacity": 0.2, 63 | "pressureSize": 0.9, 64 | "tiltSize": 1, 65 | "tiltOpacity": 1, 66 | "movement": 0.5, 67 | 68 | "efficiencyBrushImage": "efficiency", 69 | "efficiencySpacing": 0.05 70 | }, 71 | 72 | { 73 | "name": "charcoal", 74 | "descriptiveName": "Charcoal", 75 | "brushImage": "brushcharcoal", 76 | "grainImage": "graincanvas", 77 | "pressureOpacity": 0.4, 78 | "pressureSize": 0.8, 79 | "sizecale": 1, 80 | "tiltOpacity": 0.4, 81 | "tiltSize": 1, 82 | "spacing": 0.05, 83 | "pressureBleed": 0.5, 84 | 85 | "efficiencyBrushImage": "efficiency", 86 | "efficiencySpacing": 0.05 87 | }, 88 | 89 | { 90 | "name": "watercolor", 91 | "descriptiveName": "Watercolor", 92 | "brushImage": "brushwatercolor", 93 | "grainImage": "grainwatercolor1", 94 | "pressureOpacity": 1, 95 | "pressureSize": 1, 96 | "sizecale": 1, 97 | "tiltOpacity": 1, 98 | "tiltSize": 1, 99 | "spacing": 0.05, 100 | "pressureBleed": 0.5, 101 | 102 | "efficiencyBrushImage": "efficiency", 103 | "efficiencySpacing": 0.05 104 | }, 105 | 106 | { 107 | "name": "clouds", 108 | "descriptiveName": "Clouds", 109 | "brushImage": "brushclouds", 110 | "grainImage": "grainclouds", 111 | "pressureOpacity": 1, 112 | "pressureSize": 1, 113 | "sizecale": 1, 114 | "tiltOpacity": 1, 115 | "tiltSize": 1, 116 | "spacing": 0.1, 117 | "movement": 1, 118 | 119 | "efficiencyBrushImage": "efficiency", 120 | "efficiencySpacing": 0.05 121 | }, 122 | 123 | { 124 | "name": "slate", 125 | "descriptiveName": "Clouds", 126 | "brushImage": "flatbrush", 127 | "grainImage": "grainslate", 128 | "pressureOpacity": 1, 129 | "pressureSize": 1, 130 | "sizecale": 1, 131 | "tiltOpacity": 1, 132 | "tiltSize": 1, 133 | "movement": 1, 134 | "spacing": 0.05, 135 | 136 | "efficiencyBrushImage": "efficiency", 137 | "efficiencySpacing": 0.05 138 | } 139 | ] -------------------------------------------------------------------------------- /example/brushes/brushhard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushhard.png -------------------------------------------------------------------------------- /example/brushes/brushmedium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushmedium.png -------------------------------------------------------------------------------- /example/brushes/brushmediumoval.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushmediumoval.png -------------------------------------------------------------------------------- /example/brushes/brushmediumovalhollow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushmediumovalhollow.png -------------------------------------------------------------------------------- /example/brushes/brushpencil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushpencil.png -------------------------------------------------------------------------------- /example/brushes/brushsoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushsoft.png -------------------------------------------------------------------------------- /example/brushes/brushwatercolor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/brushwatercolor.png -------------------------------------------------------------------------------- /example/brushes/efficiency.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/efficiency.png -------------------------------------------------------------------------------- /example/brushes/flatbrush.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/flatbrush.png -------------------------------------------------------------------------------- /example/brushes/grain1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grain1.png -------------------------------------------------------------------------------- /example/brushes/graincanvas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/graincanvas.png -------------------------------------------------------------------------------- /example/brushes/graincanvas2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/graincanvas2.png -------------------------------------------------------------------------------- /example/brushes/graincheckerboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/graincheckerboard.png -------------------------------------------------------------------------------- /example/brushes/grainclouds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainclouds.png -------------------------------------------------------------------------------- /example/brushes/graingrid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/graingrid.png -------------------------------------------------------------------------------- /example/brushes/graingrunge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/graingrunge.png -------------------------------------------------------------------------------- /example/brushes/grainpaper1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainpaper1.png -------------------------------------------------------------------------------- /example/brushes/grainpaper2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainpaper2.png -------------------------------------------------------------------------------- /example/brushes/grainpaper3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainpaper3.png -------------------------------------------------------------------------------- /example/brushes/grainpaper4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainpaper4.png -------------------------------------------------------------------------------- /example/brushes/grainscratchedmetal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainscratchedmetal.png -------------------------------------------------------------------------------- /example/brushes/grainslate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainslate.png -------------------------------------------------------------------------------- /example/brushes/grainsolid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainsolid.png -------------------------------------------------------------------------------- /example/brushes/grainwatercolor1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainwatercolor1.png -------------------------------------------------------------------------------- /example/brushes/grainwatercolor2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainwatercolor2.png -------------------------------------------------------------------------------- /example/brushes/grainwood1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainwood1.png -------------------------------------------------------------------------------- /example/brushes/grainwood2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/grainwood2.png -------------------------------------------------------------------------------- /example/brushes/hardwood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/hardwood.png -------------------------------------------------------------------------------- /example/brushes/teardrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/brushes/teardrop.png -------------------------------------------------------------------------------- /example/img/layers/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/img/layers/grid.png -------------------------------------------------------------------------------- /example/img/layers/layer01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/img/layers/layer01.png -------------------------------------------------------------------------------- /example/img/layers/layer02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/img/layers/layer02.png -------------------------------------------------------------------------------- /example/img/layers/layer03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wonderunit/alchemancy/38c4670d21d33e0e56cbb63d04e53c2678d93c20/example/img/layers/layer03.png -------------------------------------------------------------------------------- /example/vendor/stats.min.js: -------------------------------------------------------------------------------- 1 | // stats.js - http://github.com/mrdoob/stats.js 2 | (function(f,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"===typeof define&&define.amd?define(e):f.Stats=e()})(this,function(){var f=function(){function e(a){c.appendChild(a.dom);return a}function u(a){for(var d=0;dg+1E3&&(r.update(1E3*a/(c-g),100),g=c,a=0,t)){var d=performance.memory;t.update(d.usedJSHeapSize/ 4 | 1048576,d.jsHeapSizeLimit/1048576)}return c},update:function(){k=this.end()},domElement:c,setMode:u}};f.Panel=function(e,f,l){var c=Infinity,k=0,g=Math.round,a=g(window.devicePixelRatio||1),r=80*a,h=48*a,t=3*a,v=2*a,d=3*a,m=15*a,n=74*a,p=30*a,q=document.createElement("canvas");q.width=r;q.height=h;q.style.cssText="width:80px;height:48px";var b=q.getContext("2d");b.font="bold "+9*a+"px Helvetica,Arial,sans-serif";b.textBaseline="top";b.fillStyle=l;b.fillRect(0,0,r,h);b.fillStyle=f;b.fillText(e,t,v); 5 | b.fillRect(d,m,n,p);b.fillStyle=l;b.globalAlpha=.9;b.fillRect(d,m,n,p);return{dom:q,update:function(h,w){c=Math.min(c,h);k=Math.max(k,h);b.fillStyle=l;b.globalAlpha=1;b.fillRect(0,0,r,m);b.fillStyle=f;b.fillText(g(h)+" "+e+" ("+g(c)+"-"+g(k)+")",t,v);b.drawImage(q,d+a,m,n-a,p,d,m,n-a,p);b.fillRect(d+n-a,m,a,p);b.fillStyle=l;b.globalAlpha=.9;b.fillRect(d+n-a,m,a,g((1-h/w)*p))}}};return f}); 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Wonder Unit Alchemancy Example 6 | 90 | 91 | 92 |
93 |
94 | c
clear
95 | [
smaller brush
96 | ]
larger brush
97 | alt/option
hold to erase
98 | f
flip horz
99 |
100 |
101 |
Layer 0
102 |
Layer 1
103 |
Layer 2
104 |
Layer 3
105 |
Clear Layer
106 |
107 |
108 |
Pencil
109 |
Pen
110 |
Copic
111 |
Charcoal
112 |
Water Color
113 |
Clouds
114 |
Slate
115 |
Brush Pen
116 |
117 |
118 |
Black
119 |
Blu-0
120 |
Blu-1
121 |
Blu-2
122 |
Blu-3
123 |
Yellow
124 |
White
125 |
126 |
127 |
Small
128 |
Medium
129 |
Large
130 |
X-Large
131 |
132 |
133 |
10%
134 |
30%
135 |
50%
136 |
80%
137 |
100%
138 |
139 |
140 |
0.05
141 |
0.5
142 |
1
143 |
2
144 |
5
145 |
146 |
147 |
Spin Canvas
148 |
Plot Lines
149 |
Draw Strokes
150 |
Pressure Line
151 |
152 |
153 |
Save
154 |
155 |
156 |
157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alchemancy", 3 | "version": "1.5.1", 4 | "description": "Alchemancy Mark III - Wonder Unit's drawing system", 5 | "main": "./dist/sketch-pane.common.js", 6 | "files": [ 7 | "dist/sketch-pane.common.js", 8 | "src/" 9 | ], 10 | "scripts": { 11 | "clean": "trash dist/*", 12 | "watch": "cross-env MODE=development webpack --watch", 13 | "start": "cross-env MODE=development webpack-serve --config webpack.config.js --port 8000 --host 0.0.0.0 --no-hot --no-clipboard", 14 | "build": "cross-env MODE=production webpack", 15 | "build:dev": "cross-env MODE=development webpack", 16 | "test": "standardx **/*.ts" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/wonderunit/alchemancy.git" 21 | }, 22 | "author": "", 23 | "private": true, 24 | "license": "UNLICENSED", 25 | "bugs": { 26 | "url": "https://github.com/wonderunit/alchemancy/issues" 27 | }, 28 | "homepage": "https://github.com/wonderunit/alchemancy#readme", 29 | "standardx": { 30 | "parser": "typescript-eslint-parser", 31 | "plugins": [ 32 | "typescript" 33 | ] 34 | }, 35 | "devDependencies": { 36 | "@types/node": "10.5.0", 37 | "@types/paper": "^0.9.15", 38 | "@types/pixi.js": "4.8.6", 39 | "cross-env": "5.1.5", 40 | "eslint": "4.19.1", 41 | "eslint-plugin-typescript": "0.12.0", 42 | "shader-loader": "1.3.1", 43 | "standardx": "2.0.0", 44 | "trash-cli": "1.4.0", 45 | "ts-loader": "4.4.1", 46 | "typescript": "2.9.2", 47 | "typescript-eslint-parser": "15.0.0", 48 | "webpack": "^4.13.0", 49 | "webpack-cli": "^3.0.8", 50 | "webpack-serve": "^1.0.0" 51 | }, 52 | "dependencies": { 53 | "paper": "0.11.5", 54 | "pixi.js": "4.8.7" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ts/index.ts: -------------------------------------------------------------------------------- 1 | import SketchPane from './sketch-pane/sketch-pane' 2 | import util from './sketch-pane/util' 3 | 4 | export { SketchPane, util } 5 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/brush/brush-node-filter.ts: -------------------------------------------------------------------------------- 1 | import * as PIXI from 'pixi.js' 2 | const fragment: string = require('./brushnode.frag') 3 | 4 | export default class BrushNodeFilter extends PIXI.Filter { 5 | grainSprite: PIXI.Sprite 6 | grainMatrix: PIXI.Matrix 7 | 8 | constructor (grainSprite: PIXI.Sprite) { 9 | super( 10 | null, 11 | fragment, 12 | { 13 | // color 14 | uRed: {type: '1f', value: 0.5}, 15 | uGreen: {type: '1f', value: 0.5}, 16 | uBlue: {type: '1f', value: 0.5}, 17 | 18 | // node 19 | uOpacity: {type: '1f', value: 1}, 20 | uRotation: {type: '1f', value: 0}, 21 | 22 | // grain 23 | uBleed: {type: '1f', value: 0}, 24 | uGrainRotation: {type: '1f', value: 0}, 25 | uGrainScale: {type: '1f', value: 1}, 26 | u_x_offset: {type: '1f', value: 0}, 27 | u_y_offset: {type: '1f', value: 0}, 28 | 29 | // brush 30 | u_offset_px: {type: 'vec2'}, 31 | u_node_scale: {type: 'vec2', value: [0.0, 0.0]}, 32 | 33 | // grain texture 34 | u_grainTex: {type: 'sampler2D', value: ''}, 35 | 36 | // environment (via PIXI and Filter) 37 | dimensions: {type: 'vec2', value: [0.0, 0.0]}, 38 | filterMatrix: {type: 'mat3'} 39 | } as any 40 | ) 41 | 42 | this.padding = 0 43 | this.blendMode = PIXI.BLEND_MODES.NORMAL 44 | 45 | // via https://github.com/pixijs/pixi.js/wiki/v4-Creating-Filters#fitting-problem 46 | this.autoFit = false 47 | 48 | let grainMatrix = new PIXI.Matrix() 49 | 50 | grainSprite.renderable = false 51 | this.grainSprite = grainSprite 52 | this.grainMatrix = grainMatrix 53 | this.uniforms.u_grainTex = grainSprite.texture 54 | this.uniforms.filterMatrix = grainMatrix 55 | } 56 | 57 | // via https://github.com/pixijs/pixi.js/wiki/v4-Creating-Filters#filter-area 58 | apply (filterManager: PIXI.FilterManager, input: PIXI.RenderTarget, output: PIXI.RenderTarget, clear: boolean) { 59 | this.uniforms.dimensions[0] = input.sourceFrame.width 60 | this.uniforms.dimensions[1] = input.sourceFrame.height 61 | 62 | this.uniforms.filterMatrix = filterManager.calculateSpriteMatrix(this.grainMatrix, this.grainSprite) 63 | 64 | filterManager.applyFilter(this, input, output, clear) 65 | 66 | // console.log('filterMatrix', this.uniforms.filterMatrix) 67 | 68 | // to log Filter-added uniforms: 69 | // let shader = this.glShaders[filterManager.renderer.CONTEXT_UID] 70 | // if (shader) { 71 | // console.log('dimensions', this.uniforms.dimensions) 72 | // console.log('filterArea', shader.uniforms.filterArea) 73 | // console.log('filterClamp', shader.uniforms.filterClamp) 74 | // console.log('vFilterCoord', shader.uniforms.vFilterCoord) 75 | // } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/brush/brush.ts: -------------------------------------------------------------------------------- 1 | export type IBrushSettings = Partial 2 | 3 | export class BrushSettings { 4 | constructor (obj?: IBrushSettings) { 5 | if (obj) { 6 | Object.assign(this, obj) 7 | } 8 | } 9 | 10 | // GENERAL 11 | name = 'default' // Name of the brush preset 12 | blendMode = 'normal' // Blend mode of the stroke (not node) on the layer 13 | sizeLimitMax = 1 // UI limit for size 14 | sizeLimitMin = 0 15 | opacityMax = 1 // UI limit for opacity 16 | opacityMin = 0 17 | 18 | // STROKE 19 | spacing = 0 // spacing in between brush nodes 20 | 21 | // TEXTURES 22 | brushImage = 'brushcharcoal' // Name alias of brush alpha 23 | brushRotation = 0 // rotation of texture (0,90,180,270) 24 | brushImageInvert = false // invert texture 25 | grainImage = 'graingrid' // Name alias of brush grain texture 26 | grainRotation = 0 27 | grainImageInvert = false 28 | 29 | // GRAIN 30 | movement = 1 // % the grain is offset as the brush moves. 0 static. 100 rolling. 100 is like paper 31 | scale = 1 // Scale of the grain texture. 0 super tiny, 100 super large 32 | zoom = 0 // % Scale of the grain texture by the brush size. 33 | rotation = 0 // % Rotation grain rotation is multiplied by rotation 34 | randomOffset = true // on strokeDown, choose a random grain offset 35 | 36 | // STYLUS 37 | azimuth = true 38 | pressureOpacity = 1 // % Pressure affects opacity 39 | pressureSize = 1 // % Pressure affects size 40 | pressureBleed = 0 // 41 | tiltAngle = 0 // % the title angle affects the below params 42 | tiltOpacity = 1 // % opacity altered by the tilt 43 | tiltGradiation = 0 // % opacity is gradiated by the tilt 44 | tiltSize = 1 // % size altered by the tilt 45 | 46 | orientToScreen = true // orient the brush shape to the rotation of the screen 47 | 48 | efficiencyBrushImage : string 49 | efficiencySpacing : number 50 | } 51 | 52 | export class Brush { 53 | constructor (settings?: IBrushSettings) { 54 | this.settings = new BrushSettings(settings) 55 | } 56 | 57 | settings: BrushSettings 58 | } 59 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/brush/brushnode.frag: -------------------------------------------------------------------------------- 1 | precision highp float; 2 | 3 | // brush texture 4 | uniform sampler2D uSampler; 5 | // grain texture 6 | uniform sampler2D u_grainTex; 7 | 8 | // color 9 | uniform float uRed; 10 | uniform float uGreen; 11 | uniform float uBlue; 12 | 13 | // node 14 | uniform float uOpacity; 15 | uniform float uRotation; 16 | 17 | // grain 18 | uniform float uBleed; 19 | uniform float uGrainRotation; 20 | uniform float uGrainScale; 21 | uniform float u_x_offset; 22 | uniform float u_y_offset; 23 | 24 | // brush 25 | uniform vec2 u_offset_px; 26 | uniform vec2 u_node_scale; 27 | 28 | // from vert shader 29 | varying vec2 vTextureCoord; 30 | varying vec2 vFilterCoord; 31 | 32 | // from PIXI 33 | uniform vec4 filterArea; 34 | uniform vec2 dimensions; 35 | uniform vec4 filterClamp; 36 | uniform mat3 filterMatrix; 37 | 38 | vec2 rotate (vec2 v, float a) { 39 | float s = sin(a); 40 | float c = cos(a); 41 | mat2 m = mat2(c, -s, s, c); 42 | return m * v; 43 | } 44 | 45 | vec2 scale (vec2 v, vec2 _scale) { 46 | mat2 m = mat2(_scale.x, 0.0, 0.0, _scale.y); 47 | return m * v; 48 | } 49 | 50 | vec2 mapCoord (vec2 coord) { 51 | coord *= filterArea.xy; 52 | return coord; 53 | } 54 | 55 | vec2 unmapCoord (vec2 coord) { 56 | coord /= filterArea.xy; 57 | return coord; 58 | } 59 | 60 | void main(void) { 61 | // user's intended brush color 62 | vec3 color = vec3(uRed, uGreen, uBlue); 63 | 64 | // 65 | // 66 | // brush 67 | // 68 | vec2 coord = mapCoord(vTextureCoord) / dimensions; 69 | 70 | // translate by the subpixel 71 | coord -= u_offset_px / dimensions; 72 | 73 | // move space from the center to the vec2(0.0) 74 | coord -= vec2(0.5); 75 | 76 | // rotate the space 77 | coord = rotate(coord, uRotation); 78 | 79 | // move it back to the original place 80 | coord += vec2(0.5); 81 | 82 | // scale 83 | coord -= 0.5; 84 | coord *= 1.0 / u_node_scale; 85 | coord += 0.5; 86 | 87 | coord = unmapCoord(coord * dimensions); 88 | 89 | // 90 | // 91 | // grain 92 | // 93 | vec2 fcoord = vFilterCoord; 94 | fcoord -= vec2(u_x_offset, u_y_offset); 95 | fcoord /= uGrainScale; 96 | vec4 grainSample = texture2D(u_grainTex, fract(fcoord)); 97 | 98 | // 99 | // 100 | // set gl_FragColor 101 | // 102 | // clamp (via https://github.com/pixijs/pixi.js/wiki/v4-Creating-Filters#bleeding-problem) 103 | if (coord == clamp(coord, filterClamp.xy, filterClamp.zw)) { 104 | // read a sample from the texture 105 | vec4 brushSample = texture2D(uSampler, coord); 106 | // tint 107 | gl_FragColor = vec4(color, 1.); 108 | gl_FragColor *= ((brushSample.r * grainSample.r * (1.0+uBleed))- uBleed ) * (1.0+ uBleed) * uOpacity; 109 | 110 | // gl_FragColor = grain; 111 | } else { 112 | // don't draw 113 | gl_FragColor = vec4(0.); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/cursor.ts: -------------------------------------------------------------------------------- 1 | import * as PIXI from 'pixi.js' 2 | 3 | export interface ICursorContainer { 4 | brushSize: number 5 | 6 | localizePoint(p: {x: number, y: number}): PIXI.Point 7 | } 8 | 9 | export class Cursor extends PIXI.Sprite { 10 | container: ICursorContainer 11 | _enabled: boolean 12 | gfx: PIXI.Graphics 13 | 14 | lastPointer: PIXI.Point 15 | 16 | constructor (container: ICursorContainer) { 17 | super() 18 | this.container = container 19 | 20 | this.name = 'cursorSprite' 21 | 22 | this.gfx = new PIXI.Graphics() 23 | // must be added as a child or the coordinates are incorrect 24 | this.addChild(this.gfx) 25 | 26 | // enabled 27 | this._enabled = true 28 | // don't show until at least one update 29 | this.visible = false 30 | 31 | this.updateSize() 32 | } 33 | 34 | renderCursor (e: {x: number, y: number}) { 35 | this.lastPointer.set(e.x, e.y) 36 | let point = this.container.localizePoint(this.lastPointer) 37 | this.position.set(point.x, point.y) 38 | this.anchor.set(0.5) 39 | 40 | // show (only when moved) 41 | if (this._enabled) { 42 | this.visible = true 43 | } 44 | } 45 | 46 | updateSize () { 47 | let resolution = 1 48 | let size = this.container.brushSize * 0.7 // optical, approx. 49 | 50 | let x = Math.ceil((size * resolution) / 2) 51 | let y = Math.ceil((size * resolution) / 2) 52 | 53 | this.gfx 54 | .clear() 55 | // pad to avoid texture clipping (hack) 56 | .lineStyle(resolution * 2, 0xffffff, 0.001) 57 | .drawCircle(x, y, Math.ceil(size * resolution) + (resolution * 2)) 58 | .closePath() 59 | // smaller white circle 60 | .lineStyle(resolution, 0xffffff) 61 | .drawCircle(x, y, Math.ceil(size * resolution) - resolution) 62 | .closePath() 63 | // actual size black circle 64 | .lineStyle(resolution, 0x000000) 65 | .drawCircle(x, y, Math.ceil(size * resolution)) 66 | .closePath() 67 | 68 | // destroy any old texture 69 | this.texture.destroy(true) 70 | // render to a canvas 71 | this.texture = this.gfx.generateCanvasTexture() 72 | // hacky fix to avoid texture clipping and resize sprite appropriately to texture 73 | this.getLocalBounds() 74 | // clear the temporary graphics 75 | this.gfx.clear() 76 | } 77 | 78 | setEnabled (value: boolean) { 79 | this._enabled = value 80 | // immediately hide when disabled, but wait for mouse move when re-enabled 81 | if (!this._enabled) this.visible = false 82 | } 83 | 84 | getEnabled () { 85 | return this._enabled 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/layer.ts: -------------------------------------------------------------------------------- 1 | import * as PIXI from 'pixi.js' 2 | import Util from './util' 3 | 4 | export default class Layer { 5 | renderer: PIXI.WebGLRenderer 6 | width: number 7 | height: number 8 | container: PIXI.Container 9 | sprite: PIXI.Sprite 10 | dirty: boolean 11 | 12 | name: string 13 | index: number 14 | 15 | constructor (params: { renderer: PIXI.WebGLRenderer, width: number, height: number, name: string }) { 16 | this.renderer = params.renderer 17 | this.width = params.width 18 | this.height = params.height 19 | this.name = params.name 20 | 21 | this.sprite = new PIXI.Sprite(PIXI.RenderTexture.create(this.width, this.height)) 22 | this.sprite.name = params.name 23 | 24 | this.container = new PIXI.Container() 25 | this.container.name = `${params.name} container` 26 | this.container.addChild(this.sprite) 27 | 28 | this.dirty = false 29 | } 30 | 31 | getOpacity () { 32 | return this.sprite.alpha 33 | } 34 | 35 | setOpacity (opacity: number) { 36 | this.sprite.alpha = opacity 37 | } 38 | 39 | pixels (postDivide = false) { 40 | // get pixels as Uint8Array 41 | // see: http://pixijs.download/release/docs/PIXI.extract.WebGLExtract.html 42 | const pixels = this.renderer.plugins.extract.pixels(this.sprite.texture) 43 | if (postDivide) { 44 | // un-premultiply 45 | Util.arrayPostDivide(pixels) 46 | } 47 | return pixels 48 | } 49 | 50 | toCanvas (postDivide = true) { 51 | let pixels = this.pixels(postDivide) 52 | 53 | return Util.pixelsToCanvas( 54 | pixels, 55 | this.width, 56 | this.height 57 | ) 58 | } 59 | 60 | // get data url in PNG format 61 | toDataURL (postDivide = true) { 62 | return this.toCanvas(postDivide).toDataURL() 63 | } 64 | 65 | // get PNG data for writing to a file 66 | export (index: number) { 67 | return Util.dataURLToFileContents( 68 | this.toDataURL() 69 | ) 70 | } 71 | 72 | // renders a DisplayObject to this layer’s texture 73 | draw (displayObject: PIXI.DisplayObject, clear = false) { 74 | this.renderer.render( 75 | displayObject, 76 | this.sprite.texture as PIXI.RenderTexture, 77 | clear 78 | ) 79 | } 80 | 81 | clear () { 82 | // FIXME why doesn't this work consistently? 83 | // clear the render texture 84 | // this.renderer.clearRenderTexture(this.sprite.texture) 85 | 86 | // HACK force clear :/ 87 | this.draw( 88 | new PIXI.Sprite(PIXI.Texture.EMPTY), 89 | true 90 | ) 91 | } 92 | 93 | // draws a (non-DisplayObject) source to a texture (usually an Image) 94 | replace (source: any, clear = true) { 95 | this.draw( 96 | PIXI.Sprite.from(source), // eslint-disable-line new-cap 97 | clear 98 | ) 99 | } 100 | 101 | // source should be an HTMLCanvasElement 102 | replaceTextureFromCanvas (canvasElement: HTMLCanvasElement) { 103 | // delete ALL cached canvas textures to ensure canvas is re-rendered 104 | PIXI.utils.clearTextureCache() 105 | // draw canvas to our sprite's RenderTexture 106 | this.replace( 107 | PIXI.Texture.from(canvasElement) 108 | ) 109 | } 110 | 111 | applyMask (mask : PIXI.Sprite) { 112 | // add child so transform is correct 113 | this.sprite.addChild(mask) 114 | this.sprite.mask = mask 115 | 116 | // stamp mask'd version of layer sprite to its own texture 117 | this.rewrite() 118 | 119 | // cleanup 120 | this.sprite.mask = null 121 | this.sprite.removeChild(mask) 122 | } 123 | 124 | // write to texture (ignoring alpha) 125 | // TODO better name for this? 126 | rewrite () { 127 | // temporarily reset the sprite alpha 128 | let alpha = this.sprite.alpha 129 | 130 | // write to the texture 131 | this.sprite.alpha = 1.0 132 | this.replaceTexture(this.sprite) 133 | 134 | // set the sprite alpha back 135 | this.sprite.alpha = alpha 136 | } 137 | 138 | // NOTE this will apply any source Sprite alpha (if present) 139 | // TODO might be a better way to do this. 140 | // would be more efficient to .render over sprite instead (with clear:true) 141 | // but attempting that resulted in a blank texture. 142 | // see also: PIXI's `generateTexture` 143 | replaceTexture (displayObject: PIXI.DisplayObject) { 144 | let rt = PIXI.RenderTexture.create(this.width, this.height) 145 | this.renderer.render( 146 | displayObject, 147 | rt, 148 | true 149 | ) 150 | this.sprite.texture = rt 151 | } 152 | 153 | // NOTE this is slow 154 | isEmpty () { 155 | let pixels = this.renderer.plugins.extract.pixels(this.sprite.texture) 156 | for (let i of pixels) { 157 | if (i !== 0) return false 158 | } 159 | return true 160 | } 161 | 162 | getDirty () { 163 | return this.dirty 164 | } 165 | 166 | setDirty (value: boolean) { 167 | this.dirty = value 168 | } 169 | 170 | setVisible (value: boolean) { 171 | this.sprite.visible = value 172 | } 173 | 174 | getVisible () { 175 | return this.sprite.visible 176 | } 177 | 178 | // 179 | // 180 | // operations 181 | // 182 | flip (vertical = false) { 183 | let sprite = new PIXI.Sprite(this.sprite.texture) 184 | sprite.anchor.set(0.5, 0.5) 185 | if (vertical) { 186 | sprite.pivot.set(-sprite.width / 2, sprite.height / 2) 187 | sprite.scale.y *= -1 188 | } else { 189 | sprite.pivot.set(sprite.width / 2, -sprite.height / 2) 190 | sprite.scale.x *= -1 191 | } 192 | this.replaceTexture(sprite) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/layers-collection.ts: -------------------------------------------------------------------------------- 1 | import Layer from './layer' 2 | import Util from './util' 3 | 4 | // see: https://github.com/wesbos/es6-articles/blob/master/54%20-%20Extending%20Arrays%20with%20Classes%20for%20Custom%20Collections.md 5 | export default class LayersCollection extends Array { 6 | currentIndex: number 7 | renderer: PIXI.WebGLRenderer 8 | width: number 9 | height: number 10 | onAdd: (x: number) => {} 11 | onSelect: (x: number) => {} 12 | 13 | [index: number]: any 14 | 15 | // via https://blog.simontest.net/extend-array-with-typescript-965cc1134b3 16 | private constructor () { 17 | super() 18 | } 19 | static create(params: { 20 | renderer: PIXI.WebGLRenderer, 21 | width: number, 22 | height: number, 23 | onAdd: (x: number) => any, 24 | onSelect: (x: number) => any 25 | }): LayersCollection { 26 | let layersCollection = Object.create(LayersCollection.prototype) 27 | layersCollection.renderer = params.renderer 28 | layersCollection.width = params.width 29 | layersCollection.height = params.height 30 | layersCollection.onAdd = params.onAdd 31 | layersCollection.onSelect = params.onSelect 32 | return layersCollection 33 | } 34 | 35 | create (options: any) : Layer { 36 | let layer = new Layer({ 37 | renderer: this.renderer, 38 | width: this.width, 39 | height: this.height, 40 | ...options 41 | }) 42 | this.add(layer) 43 | return layer 44 | } 45 | 46 | add (layer: Layer) : Layer { 47 | let index = this.length 48 | this.push(layer) 49 | layer.index = index 50 | this.onAdd && this.onAdd(layer.index) 51 | return layer 52 | } 53 | 54 | markDirty (indices: any) { 55 | for (let index of indices) { 56 | this[index].setDirty(true) 57 | } 58 | } 59 | 60 | // getActiveIndices () { 61 | // return [...this.activeIndices] 62 | // } 63 | // setActiveIndices (indices) { 64 | // this.activeIndices = [...indices] 65 | // } 66 | getCurrentIndex () { 67 | return this.currentIndex 68 | } 69 | 70 | setCurrentIndex (index: number) { 71 | this.currentIndex = index 72 | this.onSelect && this.onSelect(index) 73 | } 74 | 75 | getCurrentLayer () { 76 | return this[this.currentIndex] 77 | } 78 | 79 | // 80 | // 81 | // operations 82 | // 83 | flip (vertical = false) { 84 | for (let layer of this) { 85 | layer.flip(vertical) 86 | } 87 | } 88 | 89 | // for given layers, 90 | // with specified opacity 91 | // render a composite texture 92 | // and return as *pixels* 93 | // 94 | // NOTE intentionally transparent. we use it to generate large images as well. 95 | // 96 | // TODO sort back to front 97 | // TODO better antialiasing 98 | // TODO rename extractCompositePixels ? 99 | extractThumbnailPixels (width: number, height: number, indices: Array = []) { 100 | let rt = PIXI.RenderTexture.create(width, height) 101 | return this.renderer.plugins.extract.pixels( 102 | this.generateCompositeTexture(width, height, indices, rt) 103 | ) 104 | } 105 | 106 | generateCompositeTexture (width: number, height: number, indices: Array = [], rt: PIXI.RenderTexture) { 107 | for (let layer of this) { 108 | // if indices are specified, include only selected layers 109 | if (indices.length && indices.includes(layer.index)) { 110 | // make a new Sprite from the layer texture 111 | let sprite = new PIXI.Sprite(layer.sprite.texture) 112 | // copy the layer's alpha 113 | sprite.alpha = layer.sprite.alpha 114 | // resize 115 | sprite.scale.set(width / this.width, height / this.height) 116 | this.renderer.render( 117 | sprite, 118 | rt, 119 | false 120 | ) 121 | } 122 | } 123 | return rt 124 | } 125 | 126 | asFlattenedCanvas (width : number, height : number, indices: Array = []) { 127 | let pixels = this.extractThumbnailPixels( 128 | width, 129 | height, 130 | indices 131 | ) 132 | // un-premultiply 133 | Util.arrayPostDivide(pixels) 134 | // as a canvas 135 | let canvas = Util.pixelsToCanvas( 136 | pixels, 137 | width, 138 | height 139 | ) 140 | 141 | return canvas 142 | } 143 | 144 | findByName (name:string) : Layer { 145 | return this.find(layer => layer.name === name) 146 | } 147 | 148 | // merge 149 | // 150 | // sources is an array of layer indices, ordered back to front 151 | // destination is the index of the destination layer 152 | merge (sources: Array, destination: number) { 153 | let rt = PIXI.RenderTexture.create(this.width, this.height) 154 | 155 | rt = this.generateCompositeTexture( 156 | this.width, 157 | this.height, 158 | sources, 159 | rt 160 | ) 161 | 162 | // clear destination 163 | this[destination].clear() 164 | 165 | // stamp composite onto destination 166 | // TODO would it be better to write raw pixel data? 167 | // TODO would it be better to destroy layer texture and assign rt as layer texture? 168 | this[destination].replace(rt) 169 | 170 | // clear the source layers 171 | for (let index of sources) { 172 | if (index !== destination) { 173 | this[index].clear() 174 | } 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/selected-area.ts: -------------------------------------------------------------------------------- 1 | import * as paper from 'paper' 2 | import * as PIXI from 'pixi.js' 3 | 4 | export default class SelectedArea { 5 | cutSprite: any; 6 | outlineSprite: any; 7 | sketchPane: any 8 | areaPath?: paper.Path | paper.CompoundPath 9 | 10 | constructor (options: any) { 11 | this.sketchPane = options.sketchPane 12 | } 13 | 14 | set (areaPath : paper.Path) { 15 | this.areaPath = areaPath 16 | } 17 | 18 | unset () { 19 | this.areaPath = null 20 | } 21 | 22 | children () : Array { 23 | return this.areaPath.children 24 | ? this.areaPath.children as Array 25 | : [this.areaPath] as Array 26 | } 27 | 28 | asPolygons (translate : boolean = true) : Array { 29 | let offset = translate 30 | ? [-this.areaPath.bounds.x, -this.areaPath.bounds.y] 31 | : [0, 0] 32 | 33 | let result = [] 34 | for (let child of this.children()) { 35 | result.push( 36 | new PIXI.Polygon( 37 | child.segments.map( 38 | segment => new PIXI.Point( 39 | segment.point.x + offset[0], 40 | segment.point.y + offset[1] 41 | ) 42 | ) 43 | ) 44 | ) 45 | } 46 | return result 47 | } 48 | 49 | asMaskSprite (invert : boolean = false) { 50 | // delete ALL cached canvas textures to ensure canvas is re-rendered 51 | PIXI.utils.clearTextureCache() 52 | 53 | let polygons 54 | 55 | let canvas: HTMLCanvasElement = document.createElement('canvas') 56 | 57 | let ctx = canvas.getContext('2d') 58 | ctx.globalAlpha = 1.0 59 | 60 | if (invert) { 61 | canvas.width = this.sketchPane.width 62 | canvas.height = this.sketchPane.height 63 | 64 | // white on red 65 | ctx.fillStyle = '#f00' 66 | ctx.rect(0, 0, canvas.width, canvas.height) 67 | ctx.fill() 68 | 69 | ctx.globalCompositeOperation = 'destination-out' 70 | ctx.fillStyle = '#fff' 71 | 72 | polygons = this.asPolygons(false) 73 | } else { 74 | canvas.width = this.areaPath.bounds.width 75 | canvas.height = this.areaPath.bounds.height 76 | 77 | // red on transparent 78 | ctx.fillStyle = '#f00' 79 | 80 | polygons = this.asPolygons(true) 81 | } 82 | 83 | for (let polygon of polygons) { 84 | ctx.beginPath() 85 | ctx.moveTo(polygon.points[0], polygon.points[1]) 86 | for (let i = 2; i < polygon.points.length; i += 2) { 87 | ctx.lineTo(polygon.points[i], polygon.points[i + 1]) 88 | } 89 | ctx.closePath() 90 | ctx.fill() 91 | } 92 | 93 | return new PIXI.Sprite(PIXI.Texture.fromCanvas(canvas)) 94 | } 95 | 96 | asOutlineSprite () : PIXI.Sprite { 97 | PIXI.utils.clearTextureCache() 98 | 99 | return new PIXI.Sprite( 100 | PIXI.Texture.fromCanvas( 101 | this.asOutlineCanvas() 102 | ) 103 | ) 104 | } 105 | 106 | asFilledTexture (color : number, alpha : number = 1.0) : PIXI.RenderTexture { 107 | let mask = this.asMaskSprite(false) 108 | 109 | let rt = PIXI.RenderTexture.create( 110 | this.areaPath.bounds.width, 111 | this.areaPath.bounds.height 112 | ) 113 | 114 | let colorGraphics = new PIXI.Graphics() 115 | colorGraphics.beginFill(color) 116 | colorGraphics.drawRect(0, 0, mask.width, mask.height) 117 | colorGraphics.addChild(mask) 118 | colorGraphics.mask = mask 119 | colorGraphics.alpha = alpha 120 | 121 | this.sketchPane.app.renderer.render( 122 | colorGraphics, 123 | rt, 124 | false 125 | ) 126 | 127 | return rt 128 | } 129 | 130 | // extract transparent sprite from layers 131 | // for multi-layer preview: use opaque = false 132 | // for single-layer extraction/cut: use opaque = true 133 | asSprite (layerIndices? : Array, opaque: boolean = false) : PIXI.Sprite { 134 | // create a sprite to hold the artwork with dimensions matching the bounds of the area path 135 | let tempSprite = new PIXI.Sprite( 136 | PIXI.RenderTexture.create( 137 | this.areaPath.bounds.width, 138 | this.areaPath.bounds.height 139 | ) 140 | ) 141 | 142 | let mask = this.asMaskSprite() 143 | 144 | for (let i of layerIndices) { 145 | let layer = this.sketchPane.layers[i] 146 | 147 | let rect = new PIXI.Rectangle( 148 | this.areaPath.bounds.x, 149 | this.areaPath.bounds.y, 150 | Math.min(this.areaPath.bounds.width, layer.sprite.texture.width), 151 | Math.min(this.areaPath.bounds.height, layer.sprite.texture.height) 152 | ) 153 | 154 | let clip = new PIXI.Sprite(new PIXI.Texture(layer.sprite.texture, rect)) 155 | clip.alpha = opaque 156 | ? 1 157 | : layer.getOpacity() 158 | 159 | clip.addChild(mask) 160 | clip.mask = mask 161 | 162 | tempSprite.addChild(clip) 163 | } 164 | 165 | let sprite = new PIXI.Sprite( 166 | PIXI.RenderTexture.create( 167 | this.areaPath.bounds.width, 168 | this.areaPath.bounds.height 169 | ) 170 | ) 171 | this.sketchPane.app.renderer.render( 172 | tempSprite, 173 | sprite.texture as PIXI.RenderTexture, 174 | false 175 | ) 176 | return sprite 177 | } 178 | 179 | asOutlineCanvas () : HTMLCanvasElement { 180 | let polygons = this.asPolygons(true) 181 | 182 | let canvas: HTMLCanvasElement = document.createElement('canvas') 183 | 184 | let ctx = canvas.getContext('2d') 185 | ctx.globalAlpha = 1.0 186 | 187 | canvas.width = this.areaPath.bounds.width 188 | canvas.height = this.areaPath.bounds.height 189 | 190 | for (let polygon of polygons) { 191 | ctx.save() 192 | ctx.lineWidth = 1 193 | ctx.strokeStyle = '#fff' 194 | ctx.setLineDash([]) 195 | ctx.beginPath() 196 | ctx.moveTo(polygon.points[0], polygon.points[1]) 197 | for (let i = 2; i < polygon.points.length; i += 2) { 198 | ctx.lineTo(polygon.points[i], polygon.points[i + 1]) 199 | } 200 | ctx.closePath() 201 | ctx.stroke() 202 | 203 | ctx.lineWidth = 1 204 | ctx.strokeStyle = '#6A4DE7' 205 | ctx.setLineDash([2, 5]) 206 | ctx.beginPath() 207 | ctx.moveTo(polygon.points[0], polygon.points[1]) 208 | for (let i = 2; i < polygon.points.length; i += 2) { 209 | ctx.lineTo(polygon.points[i], polygon.points[i + 1]) 210 | } 211 | ctx.closePath() 212 | ctx.stroke() 213 | ctx.restore() 214 | } 215 | 216 | return canvas 217 | } 218 | 219 | copy (indices : Array) : Array { 220 | let result : Array = [] 221 | for (let i of indices) { 222 | let sprite = this.asSprite([i], true) 223 | result[i] = sprite 224 | } 225 | return result 226 | } 227 | 228 | erase (indices : Array) { 229 | let inverseMask = this.asMaskSprite(true) 230 | 231 | for (let i of indices) { 232 | let layer = this.sketchPane.layers[i] 233 | layer.applyMask(inverseMask) 234 | } 235 | } 236 | 237 | paste (indices : Array, sprites : Array) { 238 | for (let i of indices) { 239 | let layer = this.sketchPane.layers[i] 240 | let sprite = sprites[i] 241 | 242 | layer.sprite.addChild(sprite) 243 | layer.rewrite() 244 | layer.sprite.removeChild(sprite) 245 | } 246 | } 247 | 248 | fill (indices : Array, color : number, alpha : number = 1.0) { 249 | let mask = this.asMaskSprite(false) 250 | 251 | let colorGraphics = new PIXI.Graphics() 252 | colorGraphics.beginFill(color) 253 | colorGraphics.drawRect(0, 0, mask.width, mask.height) 254 | colorGraphics.addChild(mask) 255 | colorGraphics.mask = mask 256 | 257 | for (let i of indices) { 258 | let layer = this.sketchPane.layers[i] 259 | layer.sprite.addChild(colorGraphics) 260 | 261 | colorGraphics.x = this.areaPath.bounds.x 262 | colorGraphics.y = this.areaPath.bounds.y 263 | colorGraphics.alpha = alpha 264 | 265 | layer.rewrite() 266 | layer.sprite.removeChild(colorGraphics) 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/sketch-pane.ts: -------------------------------------------------------------------------------- 1 | import * as paper from 'paper' 2 | import * as PIXI from 'pixi.js' 3 | 4 | import Util from './util' 5 | import { Brush } from './brush/brush' 6 | import BrushNodeFilter from './brush/brush-node-filter' 7 | import { Cursor } from './cursor' 8 | 9 | import LayersCollection from './layers-collection' 10 | import Layer from './layer' 11 | import SelectedArea from './selected-area' 12 | 13 | interface IStrokePoint { 14 | x: number 15 | y: number 16 | pressure: number 17 | tiltAngle: number 18 | tilt: number 19 | } 20 | 21 | interface IStrokeSettings { 22 | erase?: Array 23 | isStraightLine?: boolean 24 | shouldSnap?: boolean 25 | straightLinePressure?: number 26 | } 27 | 28 | interface IStrokeState { 29 | isErasing?: boolean 30 | layerIndices?: Array 31 | points?: Array 32 | path?: paper.Path 33 | lastStaticIndex?: number 34 | lastSpacing?: number | undefined 35 | grainOffset?: { x: number, y: number } 36 | // snapshot brush configuration 37 | size?: number 38 | color?: number 39 | 40 | nodeOpacityScale?: number 41 | strokeOpacityScale?: number 42 | layerOpacity?: number 43 | 44 | isStraightLine: boolean 45 | origin: IStrokePoint 46 | straightLinePressure: number 47 | shouldSnap: boolean 48 | } 49 | 50 | interface IStopDrawingOptions { 51 | cancel: boolean 52 | } 53 | 54 | interface ISketchPaneOptions { 55 | backgroundColor: number, 56 | imageWidth? : number, 57 | imageHeight? : number, 58 | 59 | onStrokeBefore?: (state? : IStrokeState) => {}, 60 | onStrokeAfter?: (state?: IStrokeState) => {}, 61 | onWebGLContextLost? : (event : WebGLContextEvent) => {} 62 | } 63 | 64 | export default class SketchPane { 65 | selectedArea: any; 66 | layerMask: PIXI.Graphics 67 | layerBackground: PIXI.Graphics 68 | layers: LayersCollection 69 | images = { 70 | brush: {} as any, 71 | grain: {} as any 72 | } 73 | app: PIXI.Application 74 | 75 | viewClientRect: ClientRect 76 | containerPadding: number 77 | 78 | efficiencyMode: boolean = false 79 | 80 | zoom: number 81 | anchor: PIXI.Point 82 | 83 | onStrokeBefore: (state?: IStrokeState) => {} 84 | onStrokeAfter: (state?: IStrokeState) => {} 85 | 86 | constructor (options: ISketchPaneOptions = { backgroundColor: 0xffffff}) { 87 | this.layerMask = undefined 88 | this.layerBackground = undefined 89 | this.viewClientRect = undefined 90 | this.containerPadding = 50 91 | 92 | // callbacks 93 | this.onStrokeBefore = options.onStrokeBefore 94 | this.onStrokeAfter = options.onStrokeAfter 95 | 96 | this.setup(options) 97 | this.setImageSize(options.imageWidth, options.imageHeight) 98 | 99 | this.selectedArea = new SelectedArea({ sketchPane: this }) 100 | 101 | this.app.view.style.cursor = 'none' 102 | } 103 | 104 | static canInitialize () : boolean { 105 | return PIXI.utils.isWebGLSupported() 106 | } 107 | 108 | sketchPaneContainer: PIXI.Container 109 | layersContainer: PIXI.Container 110 | 111 | liveContainer: PIXI.Container 112 | segmentContainer: PIXI.Container 113 | strokeSprite: PIXI.Sprite 114 | 115 | alphaFilter: PIXI.filters.AlphaFilter 116 | 117 | offscreenContainer: PIXI.Container 118 | eraseMask: PIXI.Sprite 119 | cursor: Cursor 120 | 121 | setup (options: any) { 122 | // @popelyshev: paper typings are wrong 123 | paper.setup(undefined) 124 | ;(paper.view as any).setAutoUpdate(false) 125 | ;(paper.view as any).remove() 126 | 127 | // HACK 128 | // attemping to fix the bug where the first stroke is slow 129 | // first run of paper.Path appeared to be slow 130 | // so, try initializing it here instead 131 | // need to benchmark this on a few machines to see if it helps 132 | new paper.Path() 133 | 134 | PIXI.settings.FILTER_RESOLUTION = 1 135 | PIXI.settings.PRECISION_FRAGMENT = PIXI.PRECISION.HIGH 136 | PIXI.settings.MIPMAP_TEXTURES = true 137 | PIXI.settings.WRAP_MODE = PIXI.WRAP_MODES.REPEAT 138 | PIXI.utils.skipHello() 139 | 140 | this.app = new PIXI.Application({ 141 | // width: window.innerWidth, 142 | // height: window.innerHeight, 143 | 144 | // preserveDrawingBuffer: true, // for toDataUrl on the webgl context 145 | 146 | backgroundColor: options.backgroundColor, 147 | // resolution: 2, 148 | antialias: this.efficiencyMode ? true : false, 149 | // powerPreference: 'high-performance' 150 | }) 151 | 152 | this.app.renderer.roundPixels = false 153 | 154 | this.app.renderer.plugins.interaction.destroy() 155 | // this.app.renderer.transparent = true 156 | 157 | this.app.renderer.view.addEventListener('webglcontextlost', options.onWebGLContextLost) 158 | 159 | this.sketchPaneContainer = new PIXI.Container() 160 | this.sketchPaneContainer.name = 'sketchPaneContainer' 161 | 162 | // current layer 163 | this.layersContainer = new PIXI.Container() 164 | this.layersContainer.name = 'layersContainer' 165 | this.sketchPaneContainer.addChild(this.layersContainer) 166 | 167 | // setup an alpha filter 168 | this.alphaFilter = new PIXI.filters.AlphaFilter() 169 | 170 | // live stroke 171 | // - shown to user 172 | this.liveContainer = new PIXI.Container() 173 | this.liveContainer.name = 'live' 174 | 175 | // static stroke 176 | // - shown to user 177 | // - used as a temporary area to render before stamping to layer texture 178 | this.strokeSprite = new PIXI.Sprite() 179 | this.strokeSprite.name = 'static' 180 | 181 | // current segment 182 | // - not shown to user 183 | // - used as a temporary area to render before stamping to layer texture 184 | this.segmentContainer = new PIXI.Container() 185 | this.segmentContainer.name = 'segment' 186 | 187 | // off-screen container 188 | // - used for placement of grain sprites 189 | this.offscreenContainer = new PIXI.Container() 190 | this.offscreenContainer.name = 'offscreen' 191 | this.offscreenContainer.renderable = false 192 | this.layersContainer.addChild(this.offscreenContainer) 193 | 194 | // erase mask 195 | this.eraseMask = new PIXI.Sprite() 196 | this.eraseMask.name = 'eraseMask' 197 | 198 | this.cursor = new Cursor(this) 199 | this.sketchPaneContainer.addChild(this.cursor) 200 | 201 | this.app.stage.addChild(this.sketchPaneContainer) 202 | this.sketchPaneContainer.scale.set(1) 203 | 204 | this.viewClientRect = this.app.view.getBoundingClientRect() 205 | 206 | this.zoom = 1 207 | } 208 | 209 | width: number 210 | height: number 211 | 212 | setImageSize (width: number, height: number) { 213 | this.width = width 214 | this.height = height 215 | 216 | this.layerMask = new PIXI.Graphics() 217 | .beginFill(0x0, 1) 218 | .drawRect(0, 0, this.width, this.height) 219 | .endFill() 220 | this.layerMask.lineAlignment = 0 221 | this.layerMask.name = 'layerMask' 222 | this.layersContainer.mask = this.layerMask 223 | this.sketchPaneContainer.addChildAt(this.layerMask, this.sketchPaneContainer.getChildIndex(this.layersContainer) + 1) 224 | 225 | this.layerBackground = new PIXI.Graphics() 226 | .beginFill(0xffffff) 227 | .drawRect(0, 0, this.width, this.height) 228 | .endFill() 229 | this.layerBackground.lineAlignment = 0 230 | this.layerBackground.name = 'background' 231 | this.layersContainer.addChildAt(this.layerBackground, 0) 232 | 233 | this.eraseMask.texture = PIXI.RenderTexture.create(this.width, this.height) 234 | this.strokeSprite.texture = PIXI.RenderTexture.create(this.width, this.height) 235 | 236 | this.centerContainer() 237 | 238 | this.layers = LayersCollection.create({ 239 | renderer: this.app.renderer as PIXI.WebGLRenderer, 240 | width: this.width, 241 | height: this.height, 242 | onAdd: this.onLayersCollectionAdd.bind(this), 243 | onSelect: this.onLayersCollectionSelect.bind(this) 244 | }) 245 | } 246 | 247 | onLayersCollectionAdd (index: number) { 248 | let layer = this.layers[index] 249 | 250 | // layer.sprite.texture.baseTexture.premultipliedAlpha = false 251 | 252 | this.layersContainer.position.set(0, 0) 253 | this.layersContainer.addChild(layer.container) 254 | 255 | this.centerContainer() 256 | } 257 | 258 | onLayersCollectionSelect (index: number) { 259 | this.updateLayerDepths() 260 | } 261 | 262 | updateLayerDepths () { 263 | for (let layer of this.layers) { 264 | if (layer.index === this.layers.currentIndex) { 265 | layer.container.addChild(this.strokeSprite) 266 | layer.container.addChild(this.liveContainer) 267 | // layer.filters = [this.alphaFilter] 268 | } else { 269 | // layer.filters = [] 270 | } 271 | } 272 | } 273 | 274 | newLayer (options: any) { 275 | return this.layers.create(options) 276 | } 277 | 278 | centerContainer () { 279 | if (this.anchor) { 280 | // use anchor 281 | let point = this.sketchPaneContainer.toLocal( 282 | this.anchor, 283 | this.app.stage 284 | ) 285 | this.sketchPaneContainer.pivot.set(point.x, point.y) 286 | this.sketchPaneContainer.position.set( 287 | this.anchor.x, 288 | this.anchor.y 289 | ) 290 | } else { 291 | // center 292 | this.sketchPaneContainer.pivot.set(this.width / 2, this.height / 2) 293 | this.sketchPaneContainer.position.set( 294 | this.app.renderer.width / 2, 295 | this.app.renderer.height / 2 296 | ) 297 | } 298 | } 299 | 300 | // resizeToParent () { 301 | // this.resizeToElement(this.app.view.parentElement) 302 | // } 303 | // 304 | // resizeToElement (element) { 305 | // const { width, height } = element.getBoundingClientRect() 306 | // this.resize(width, height) 307 | // } 308 | 309 | resize (width: number, height: number) { 310 | // resize the canvas to fit the parent bounds 311 | this.app.renderer.resize(width, height) 312 | 313 | // update viewClientRect 314 | this.viewClientRect = this.app.view.getBoundingClientRect() 315 | 316 | // copy the canvas dimensions rectangle value 317 | // min size of 0×0 to prevent flip 318 | let dst = { 319 | width: Math.max(0, width - (this.containerPadding * 2)), 320 | height: Math.max(0, height - (this.containerPadding * 2)) 321 | } 322 | 323 | // src is image width / height 324 | const src = { 325 | width: this.width, 326 | height: this.height 327 | } 328 | 329 | // fit to aspect ratio 330 | const frameAr = dst.width / dst.height 331 | const imageAr = src.width / src.height 332 | 333 | let targetWidth = (frameAr > imageAr) 334 | ? src.width * dst.height / src.height 335 | : dst.width 336 | 337 | // if cursor has not moved yet, pretend it's in the center of the known screen 338 | if (!this.cursor.lastPointer) { 339 | this.cursor.lastPointer = new PIXI.Point( 340 | (this.app.renderer.width / 2) + this.viewClientRect.left, 341 | (this.app.renderer.height / 2) + this.viewClientRect.top 342 | ) 343 | } 344 | 345 | // center 346 | this.centerContainer() 347 | 348 | // set scale 349 | this.sketchPaneContainer.scale.set( 350 | (Math.floor(targetWidth) / Math.floor(src.width)) * this.zoom 351 | ) 352 | // force exact pixels 353 | this.sketchPaneContainer.width = Math.ceil(this.sketchPaneContainer.width) 354 | this.sketchPaneContainer.height = Math.ceil(this.sketchPaneContainer.height) 355 | } 356 | 357 | brushes: Record 358 | 359 | // per http://www.html5gamedevs.com/topic/29327-guide-to-pixi-v4-filters/ 360 | // for each brush, add a sprite with the brush and grain images, so we can get the actual transformation matrix for those image textures 361 | async loadBrushes (params: { brushes: Array, brushImagePath: string }) { 362 | let {brushes, brushImagePath} = params 363 | this.brushes = brushes.reduce((brushes: Array, brush: any) => { 364 | brushes[brush.name] = new Brush(brush) 365 | return brushes 366 | }, {}) 367 | 368 | // get unique file names 369 | let brushImageNames = Array.from( 370 | // unique 371 | new Set( 372 | // flatten 373 | [].concat( 374 | ...Object.values(this.brushes) 375 | .map(b => 376 | [b.settings.brushImage, b.settings.efficiencyBrushImage] 377 | ) 378 | // skip undefined 379 | ).filter(Boolean) 380 | ) 381 | ) 382 | let grainImageNames = Array.from(new Set(Object.values(this.brushes).map(b => b.settings.grainImage))) 383 | 384 | let promises: Array> = [] 385 | for (let [names, dict] of [[brushImageNames, this.images.brush], [grainImageNames, this.images.grain]]) { 386 | for (let name of names) { 387 | let sprite = PIXI.Sprite.fromImage(`${brushImagePath}/${name}.png`) 388 | sprite.renderable = false 389 | 390 | dict[name] = sprite 391 | 392 | let texture = sprite.texture.baseTexture 393 | if (texture.hasLoaded) { 394 | promises.push(Promise.resolve(sprite)) 395 | } else if (texture.isLoading) { 396 | promises.push( 397 | new Promise((resolve, reject) => { 398 | texture.on('loaded', (baseTexture: PIXI.BaseTexture) => { 399 | resolve(texture) 400 | }) 401 | texture.on('error', (baseTexture: PIXI.BaseTexture) => { 402 | reject(new Error(`Could not load brush from file: ${name}.png`)) 403 | }) 404 | }) 405 | ) 406 | } else { 407 | promises.push(Promise.reject(new Error(`Failed to load brush from file: ${name}.png`))) 408 | } 409 | } 410 | } 411 | await Promise.all(promises) 412 | 413 | this.cursor.updateSize() 414 | } 415 | 416 | // stamp = don't clear texture 417 | stampStroke (source: any, layer: Layer) { 418 | layer.draw(source, false) 419 | } 420 | 421 | disposeContainer (container: PIXI.Container) { 422 | for (let child of container.children) { 423 | (child as PIXI.Container).destroy({ 424 | children: true, 425 | 426 | // because we re-use the brush texture 427 | texture: false, 428 | baseTexture: false 429 | }) 430 | } 431 | container.removeChildren() 432 | } 433 | 434 | addStrokeNode ( 435 | r: number, 436 | g: number, 437 | b: number, 438 | size: number, 439 | nodeOpacityScale: number, 440 | x: number, 441 | y: number, 442 | pressure: number, 443 | angle: number, 444 | tilt: number, 445 | brush: Brush, 446 | grainOffsetX: number, 447 | grainOffsetY: number, 448 | container: PIXI.Container 449 | ) { 450 | // 451 | // 452 | // brush params 453 | // 454 | let nodeSize = size - (1 - pressure) * size * brush.settings.pressureSize 455 | let tiltSizeMultiple = (((tilt / 90.0) * brush.settings.tiltSize) * 3) + 1 456 | nodeSize *= tiltSizeMultiple 457 | // nodeSize = this.brushSize 458 | 459 | let nodeOpacity = 1 - (1 - pressure) * brush.settings.pressureOpacity 460 | let tiltOpacity = 1 - tilt / 90.0 * brush.settings.tiltOpacity 461 | nodeOpacity *= tiltOpacity * nodeOpacityScale 462 | 463 | let nodeRotation: number 464 | if (brush.settings.azimuth) { 465 | nodeRotation = angle * Math.PI / 180.0 - this.sketchPaneContainer.rotation 466 | } else { 467 | nodeRotation = 0 - this.sketchPaneContainer.rotation 468 | } 469 | 470 | let uBleed = Math.pow(1 - pressure, 1.6) * brush.settings.pressureBleed 471 | 472 | // 473 | // 474 | // brush node drawing 475 | // 476 | if (this.efficiencyMode) { 477 | // brush node with a single sprite 478 | 479 | // eslint-disable-next-line new-cap 480 | let sprite = new PIXI.Sprite( 481 | this.images.brush[brush.settings.efficiencyBrushImage].texture 482 | ) 483 | 484 | // let iS = Math.ceil(spriteSize) 485 | // x -= iS / 2 486 | // y -= iS / 2 487 | // sprite.x = Math.floor(x) 488 | // sprite.y = Math.floor(y) 489 | // sprite.width = iS 490 | // sprite.height = iS 491 | // 492 | // let dX = x - sprite.x 493 | // let dY = y - sprite.y 494 | // let dS = nodeSize / sprite.width 495 | // 496 | // let oXY = [dX, dY] 497 | // let oS = [dS, dS] 498 | 499 | // position 500 | sprite.position.set(x, y) 501 | 502 | // centering 503 | sprite.anchor.set(0.5) 504 | 505 | // color 506 | sprite.tint = PIXI.utils.rgb2hex([r, g, b]) 507 | 508 | // opacity 509 | sprite.alpha = nodeOpacity 510 | 511 | // rotation 512 | // TODO 513 | 514 | // bleed 515 | // TODO 516 | 517 | // scale 518 | sprite.scale.set(nodeSize / sprite.width) 519 | 520 | container.addChild(sprite) 521 | 522 | } else { 523 | // brush node with shaders 524 | 525 | // eslint-disable-next-line new-cap 526 | let sprite = new PIXI.Sprite( 527 | this.images.brush[brush.settings.brushImage].texture 528 | ) 529 | 530 | // sprite must fit a texture rotated by up to 45 degrees 531 | let rad = Math.PI * 45 / 180 // extreme angle in radians 532 | let spriteSize = Math.abs(nodeSize * Math.sin(rad)) + Math.abs(nodeSize * Math.cos(rad)) 533 | 534 | // the brush node 535 | // is larger than the texture size 536 | // because, although the x,y coordinates must be integers, 537 | // we still want to draw sub-pixels, 538 | // so we pad 1px 539 | // allowing us to draw a on positive x, y offset 540 | // and, although, the dimensions must be integers, 541 | // we want to have a sub-pixel texture size, 542 | // so we sometimes make the node larger than necessary 543 | // and scale the texture down to correct 544 | // to allow us to draw a rotated texture, 545 | // we increase the size to accommodate for up to 45 degrees of rotation 546 | let iS = Math.ceil(spriteSize) 547 | x -= iS / 2 548 | y -= iS / 2 549 | sprite.x = Math.floor(x) 550 | sprite.y = Math.floor(y) 551 | sprite.width = iS 552 | sprite.height = iS 553 | 554 | let dX = x - sprite.x 555 | let dY = y - sprite.y 556 | let dS = nodeSize / sprite.width 557 | 558 | let oXY = [dX, dY] 559 | let oS = [dS, dS] 560 | 561 | // filter setup 562 | // 563 | // TODO can we avoid creating a new grain sprite for each render? 564 | // used for rendering grain filter texture at correct position 565 | let grainSprite = this.images.grain[brush.settings.grainImage] 566 | this.offscreenContainer.addChild(grainSprite) 567 | // hacky fix to calculate vFilterCoord properly 568 | this.offscreenContainer.getLocalBounds() 569 | let filter = new BrushNodeFilter(grainSprite) 570 | 571 | filter.uniforms.uRed = r 572 | filter.uniforms.uGreen = g 573 | filter.uniforms.uBlue = b 574 | filter.uniforms.uOpacity = nodeOpacity 575 | 576 | filter.uniforms.uRotation = nodeRotation 577 | 578 | filter.uniforms.uBleed = uBleed 579 | 580 | filter.uniforms.uGrainScale = brush.settings.scale 581 | 582 | // DEPRECATED 583 | filter.uniforms.uGrainRotation = brush.settings.rotation 584 | 585 | filter.uniforms.u_x_offset = grainOffsetX * brush.settings.movement 586 | filter.uniforms.u_y_offset = grainOffsetY * brush.settings.movement 587 | 588 | // subpixel offset 589 | filter.uniforms.u_offset_px = oXY // TODO multiply by app.stage.scale if zoomed 590 | // console.log('iX', iX, 'iY', iY, 'u_offset_px', oXY) 591 | // subpixel scale AND padding AND rotation accomdation 592 | filter.uniforms.u_node_scale = oS // desired scale 593 | filter.padding = 1 // for filterClamp 594 | 595 | sprite.filters = [filter] 596 | // via https://github.com/pixijs/pixi.js/wiki/v4-Creating-Filters#bleeding-problem 597 | // @popelyshev this property is for Sprite, not for filter. Thans to TypeScript! 598 | // @popelyshev at the same time, the fix only makes it worse :( 599 | // sprite.filterArea = this.app.screen 600 | 601 | container.addChild(sprite) 602 | } 603 | } 604 | 605 | pointerDown = false 606 | 607 | down (e: PointerEvent, options = {}) { 608 | this.pointerDown = true 609 | this.strokeBegin(e, options) 610 | 611 | this.app.view.style.cursor = 'none' 612 | this.cursor.renderCursor(e) 613 | } 614 | 615 | move (e: PointerEvent) { 616 | if (this.pointerDown) { 617 | this.strokeContinue(e) 618 | } 619 | 620 | this.app.view.style.cursor = 'none' 621 | this.cursor.renderCursor(e) 622 | } 623 | 624 | up (e: PointerEvent) { 625 | if (this.pointerDown) { 626 | this.strokeEnd(e) 627 | } 628 | 629 | this.app.view.style.cursor = 'none' 630 | this.cursor.renderCursor(e) 631 | } 632 | 633 | strokeState: IStrokeState 634 | brushColor: number 635 | nodeOpacityScale: number 636 | strokeOpacityScale: number 637 | brush: Brush 638 | 639 | strokeBegin (e: PointerEvent, options: IStrokeSettings) { 640 | // initialize stroke state 641 | this.strokeState = { 642 | isErasing: !!options.erase, 643 | // which layers will be stamped / dirtied by this stroke? 644 | layerIndices: options.erase 645 | ? options.erase // array of layers which will be erased 646 | : [this.layers.currentIndex], // single layer dirtied 647 | points: [] as any, 648 | path: new paper.Path(), 649 | lastStaticIndex: 0, 650 | lastSpacing: undefined, 651 | grainOffset: this.brush.settings.randomOffset 652 | ? {x: Math.floor(Math.random() * 100), y: Math.floor(Math.random() * 100)} 653 | : {x: 0, y: 0}, 654 | 655 | // snapshot brush configuration 656 | size: this.brushSize, 657 | color: this.brushColor, 658 | 659 | nodeOpacityScale: this.nodeOpacityScale, 660 | strokeOpacityScale: this.strokeOpacityScale, 661 | layerOpacity: this.getLayerOpacity(this.layers.currentIndex), 662 | 663 | isStraightLine: options.isStraightLine ? true : false, 664 | origin: undefined, 665 | straightLinePressure: options.straightLinePressure, 666 | shouldSnap: options.shouldSnap ? true : false, 667 | } 668 | 669 | this.onStrokeBefore && this.onStrokeBefore(this.strokeState) 670 | 671 | this.addPointerEventAsPoint(e) 672 | this.strokeState.origin = this.strokeState.points[0] 673 | 674 | // if straightLinePressure was initialized 675 | this.strokeState.straightLinePressure = this.strokeState.straightLinePressure != null 676 | // use the existing value 677 | ? this.strokeState.straightLinePressure 678 | // otherwise, leave undefined 679 | : undefined 680 | 681 | // don't show the live container or stroke sprite while erasing 682 | if (this.strokeState.isErasing) { 683 | if (this.liveContainer.parent) { 684 | this.liveContainer.parent.removeChild(this.liveContainer) 685 | } 686 | if (this.strokeSprite.parent) { 687 | this.strokeSprite.parent.removeChild(this.strokeSprite) 688 | } 689 | } else { 690 | // NOTE 691 | // at beginning of stroke, sets liveContainer.alpha 692 | // move this code to `drawStroke` if layer opacity can ever change _during_ the stroke 693 | this.liveContainer.alpha = this.strokeState.layerOpacity * 694 | // because shaders are not composited with alpha on the live container, 695 | // we fake the effect of stroke opacity on the live shaders, which build up in intensity. 696 | // this exp value is just tweaked by eye 697 | // in the future we could relate the exp to the spacing value for better results 698 | Math.pow(this.strokeState.strokeOpacityScale, 5) 699 | 700 | // AlphaFilter only if stroke opacity < 1 701 | if (this.strokeState.strokeOpacityScale < 1) { 702 | // switch from sprite alpha to alpha filter 703 | this.setLayerOpacity(this.layers.currentIndex, 1) 704 | this.alphaFilter.alpha = this.strokeState.layerOpacity 705 | this.layers[this.layers.currentIndex].container.filters = [this.alphaFilter] 706 | 707 | this.strokeSprite.alpha = this.strokeState.strokeOpacityScale 708 | } else { 709 | // switch from alpha filter to sprite alpha 710 | this.setLayerOpacity(this.layers.currentIndex, this.strokeState.layerOpacity) 711 | this.layers[this.layers.currentIndex].container.filters = [] 712 | 713 | this.strokeSprite.alpha = this.strokeState.layerOpacity 714 | } 715 | this.updateLayerDepths() 716 | } 717 | 718 | this.drawStroke() 719 | } 720 | 721 | strokeContinue (e: PointerEvent) { 722 | this.addPointerEventAsPoint(e) 723 | this.drawStroke() 724 | } 725 | 726 | strokeEnd (e: PointerEvent) { 727 | if (!this.strokeState.isStraightLine) { 728 | this.addPointerEventAsPoint(e) 729 | } 730 | this.stopDrawing() 731 | } 732 | 733 | // public 734 | setIsStraightLine (yes: boolean) { 735 | if (!this.strokeState) return 736 | // if (this.strokeState.isErasing) return 737 | 738 | if (!yes) { 739 | this.strokeState.isStraightLine = false 740 | } 741 | 742 | if (yes && !this.strokeState.isStraightLine) { 743 | this.strokeState.isStraightLine = true 744 | 745 | // TODO could take the average of *changed* points, so idle unmoving point pressure doesn't sway result 746 | let averagePressure = 747 | this.strokeState.points.map(p => p.pressure).reduce((a, b) => a + b) / 748 | this.strokeState.points.length 749 | 750 | this.strokeState.straightLinePressure = this.strokeState.straightLinePressure != null 751 | ? this.strokeState.straightLinePressure 752 | : averagePressure 753 | this.drawStroke() 754 | } 755 | } 756 | 757 | getIsStraightLine () { 758 | return !!this.pointerDown && !!this.strokeState && this.strokeState.isStraightLine 759 | } 760 | 761 | setShouldSnap (choice: boolean) { 762 | if (!this.strokeState) return 763 | // if (this.strokeState.isErasing) return 764 | if (!this.strokeState.isStraightLine) return 765 | 766 | this.strokeState.shouldSnap = choice 767 | } 768 | 769 | // public 770 | stopDrawing (options : IStopDrawingOptions = { cancel: false }) { 771 | if (options.cancel) { 772 | // clear in-progress drawing 773 | // TODO DRY this up 774 | this.offscreenContainer.removeChildren() 775 | this.disposeContainer(this.segmentContainer) 776 | this.disposeContainer(this.liveContainer) 777 | this.disposeContainer(this.strokeSprite) 778 | this.app.renderer.render( 779 | new PIXI.Sprite(PIXI.Texture.EMPTY), 780 | this.strokeSprite.texture as PIXI.RenderTexture, 781 | true 782 | ) 783 | } else { 784 | this.drawStroke(true) // finalize 785 | } 786 | 787 | this.layers.markDirty(this.strokeState.layerIndices) 788 | 789 | // switch from alpha filter back to sprite alpha 790 | this.setLayerOpacity(this.layers.currentIndex, this.strokeState.layerOpacity) 791 | this.layers[this.layers.currentIndex].container.filters = [] 792 | this.updateLayerDepths() 793 | 794 | this.pointerDown = false 795 | 796 | this.onStrokeAfter && this.onStrokeAfter(this.strokeState) 797 | } 798 | 799 | getInterpolatedStrokeInput (strokeInput: Array, path: paper.Path) { 800 | let interpolatedStrokeInput: Array> = [] 801 | 802 | // get lookups for each segment so we know how to interpolate 803 | 804 | // for every segment, 805 | // find the segments's location on the path, 806 | // and find the offset 807 | // where 'offset' means the length from 808 | // the beginning of the path 809 | // up to the segment's location 810 | let segmentLookup: Array = [] 811 | 812 | // console.log(path.length) 813 | 814 | for (let i = 0; i < path.segments.length; i++) { 815 | if (path.segments[i].location) { 816 | segmentLookup.push(path.segments[i].location.offset) 817 | } 818 | } 819 | 820 | // console.log(segmentLookup) 821 | 822 | let currentSegment = 0 823 | 824 | // let nodeSize = this.brushSize - ((1-pressure)*this.brushSize*brush.settings.pressureSize) 825 | 826 | let spacing = Math.max(1, this.strokeState.size * 827 | (this.efficiencyMode 828 | ? this.brush.settings.efficiencySpacing 829 | : this.brush.settings.spacing) 830 | ) 831 | 832 | // console.log(spacing) 833 | 834 | if (this.strokeState.lastSpacing == null) this.strokeState.lastSpacing = spacing 835 | let start = (spacing - this.strokeState.lastSpacing) 836 | let len = path.length 837 | let i = 0 838 | // default. pushes along in-between spacing when spacing - this.strokeState.lastSpacing is > path.length 839 | let k = len + -(this.strokeState.lastSpacing + len) 840 | 841 | let singlePoint = false 842 | if (len === 0) { 843 | // single point 844 | start = 0 845 | len = spacing 846 | singlePoint = true 847 | } 848 | for (i = start; i < len; i += spacing) { 849 | let point = path.getPointAt(i) 850 | 851 | for (let z = currentSegment; z < segmentLookup.length; z++) { 852 | if (segmentLookup[z] < i) { 853 | currentSegment = z 854 | // @popelyshev : Why continue? 855 | continue 856 | } 857 | } 858 | 859 | let pressure: number 860 | let tiltAngle: number 861 | let tilt: number 862 | 863 | if (singlePoint) { 864 | pressure = strokeInput[currentSegment].pressure 865 | tiltAngle = strokeInput[currentSegment].tiltAngle 866 | tilt = strokeInput[currentSegment].tilt 867 | } else { 868 | let segmentPercent = 869 | (i - segmentLookup[currentSegment]) / 870 | (segmentLookup[currentSegment + 1] - segmentLookup[currentSegment]) 871 | 872 | pressure = Util.lerp( 873 | strokeInput[currentSegment].pressure, 874 | strokeInput[currentSegment + 1].pressure, 875 | segmentPercent 876 | ) 877 | tiltAngle = Util.lerp( 878 | strokeInput[currentSegment].tiltAngle, 879 | strokeInput[currentSegment + 1].tiltAngle, 880 | segmentPercent 881 | ) 882 | tilt = Util.lerp( 883 | strokeInput[currentSegment].tilt, 884 | strokeInput[currentSegment + 1].tilt, 885 | segmentPercent 886 | ) 887 | } 888 | 889 | interpolatedStrokeInput.push([ 890 | this.strokeState.isErasing ? 0 : ((this.strokeState.color >> 16) & 255) / 255, 891 | this.strokeState.isErasing ? 0 : ((this.strokeState.color >> 8) & 255) / 255, 892 | this.strokeState.isErasing ? 0 : (this.strokeState.color & 255) / 255, 893 | this.strokeState.size, 894 | this.strokeState.nodeOpacityScale, 895 | point.x, 896 | point.y, 897 | pressure, 898 | tiltAngle, 899 | tilt, 900 | this.brush, 901 | this.strokeState.grainOffset.x, 902 | this.strokeState.grainOffset.y 903 | ]) 904 | k = i 905 | } 906 | this.strokeState.lastSpacing = len - k 907 | 908 | return interpolatedStrokeInput 909 | } 910 | 911 | addStrokeNodes (strokeInput: Array, path: paper.Path, container: PIXI.Container) { 912 | // we have 2+ StrokeInput points (with x, y, pressure, etc), 913 | // and 2+ matching path segments (with location and handles) 914 | // e.g.: strokeInput[0].x === path.segments[0].point.x 915 | let interpolatedStrokeInput = this.getInterpolatedStrokeInput(strokeInput, path) 916 | 917 | for (let args of interpolatedStrokeInput) { 918 | ;(this.addStrokeNode as any)(...args, container) 919 | } 920 | } 921 | 922 | // public 923 | localizePoint (point: {x: number, y: number}) { 924 | return this.sketchPaneContainer.toLocal(new PIXI.Point( 925 | point.x - this.viewClientRect.left, 926 | point.y - this.viewClientRect.top 927 | ), 928 | this.app.stage) 929 | } 930 | 931 | // public 932 | globalizePoint (point: {x: number, y: number}) { 933 | let result = this.sketchPaneContainer.toGlobal(new PIXI.Point( 934 | point.x, 935 | point.y 936 | )) 937 | result.x += this.viewClientRect.left 938 | result.y += this.viewClientRect.top 939 | 940 | return result 941 | } 942 | 943 | addPointerEventAsPoint (e: PointerEvent) { 944 | let corrected = this.localizePoint(e) 945 | 946 | let pressure = e.pointerType === 'mouse' 947 | ? e.pressure > 0 ? 0.5 : 0 948 | : e.pressure 949 | 950 | let tiltAngle = e.pointerType === 'mouse' 951 | ? {angle: -90, tilt: 37} 952 | : Util.calcTiltAngle(e.tiltY, e.tiltX) // NOTE we intentionally reverse these args 953 | 954 | this.strokeState.points.push({ 955 | x: corrected.x, 956 | y: corrected.y, 957 | pressure: pressure, 958 | tiltAngle: tiltAngle.angle, 959 | tilt: tiltAngle.tilt 960 | }) 961 | 962 | // we added a new point, so decrement lastStaticIndex 963 | this.strokeState.lastStaticIndex = Math.max(0, this.strokeState.lastStaticIndex - 1) 964 | 965 | // only keep track of input that hasn't been rendered static yet 966 | this.strokeState.points = this.strokeState.points.slice( 967 | Math.max(0, this.strokeState.lastStaticIndex - 1), 968 | this.strokeState.points.length 969 | ) 970 | this.strokeState.path = new paper.Path( 971 | this.strokeState.points 972 | ) 973 | // only smooth if we have more than 1 point 974 | // resulting in a slight performance improvement for initial `down` event 975 | if (this.strokeState.points.length > 1) { 976 | // @popelyshev: paper typings are wrong 977 | ;(this.strokeState.path.smooth as any)({type: 'catmull-rom', factor: 0.5}) // centripetal 978 | } 979 | } 980 | 981 | // render the live strokes 982 | // TODO instead of slices, could pass offset and length? 983 | drawStroke (finalize = false) { 984 | if (this.strokeState.isStraightLine) { 985 | 986 | // clear the strokeSprite texture 987 | this.app.renderer.render( 988 | new PIXI.Sprite(PIXI.Texture.EMPTY), 989 | this.strokeSprite.texture as PIXI.RenderTexture, 990 | true 991 | ) 992 | 993 | // clear the erase mask 994 | // reset the mask with a solid red background 995 | let graphics = new PIXI.Graphics() 996 | .beginFill(0xff0000, 1.0) 997 | .drawRect(0, 0, this.width, this.height) 998 | .endFill() 999 | this.app.renderer.render( 1000 | graphics, 1001 | this.eraseMask.texture as PIXI.RenderTexture, 1002 | true 1003 | ) 1004 | 1005 | let pointA = this.strokeState.origin 1006 | let pointB = this.strokeState.points[this.strokeState.points.length - 1] 1007 | 1008 | pointB.pressure = pointA.pressure = this.strokeState.straightLinePressure 1009 | 1010 | if (this.strokeState.shouldSnap) { 1011 | let angle = Math.atan2(pointB.y - pointA.y, pointB.x - pointA.x) 1012 | let distance = Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y) 1013 | 1014 | let snapAt = 360/32 1015 | let nearestDegree = Math.round((angle * 180 / Math.PI + 180) / snapAt) * snapAt 1016 | let snapAngle = (nearestDegree - 180) * Math.PI / 180 1017 | 1018 | pointB.x = pointA.x + (Math.cos(snapAngle) * distance) 1019 | pointB.y = pointA.y + (Math.sin(snapAngle) * distance) 1020 | } 1021 | 1022 | this.strokeState.points = [pointA, pointB, pointB] 1023 | this.strokeState.lastStaticIndex = 0 1024 | this.strokeState.path = new paper.Path(this.strokeState.points) 1025 | } 1026 | 1027 | let len = this.strokeState.points.length 1028 | 1029 | // finalize 1030 | // draws all remaining points we know of 1031 | // called on up 1032 | // useful for drawing a dot for only two points 1033 | // e.g.: on quick up/down press with no move 1034 | if (finalize) { 1035 | // the index of the last static point we drew 1036 | let a = this.strokeState.lastStaticIndex 1037 | // the last point we know of 1038 | let b = this.strokeState.points.length - 1 1039 | 1040 | // console.log( 1041 | // '\n', 1042 | // 'rendering to texture.\n', 1043 | // len, 'points in the array.\n', 1044 | // // this.strokeState.points, '\n', 1045 | // 'drawing stroke from point idx', a, 1046 | // 'to point idx', b, '\n' 1047 | // ) 1048 | 1049 | 1050 | // TODO refactor / DRY with similar code below 1051 | // 1052 | // add the last segment 1053 | this.addStrokeNodes( 1054 | this.strokeState.points.slice(a, b + 1), 1055 | new paper.Path(this.strokeState.path.segments.slice(a, b + 1)), 1056 | this.segmentContainer 1057 | ) 1058 | this.app.renderer.render( 1059 | this.segmentContainer, 1060 | this.strokeSprite.texture as PIXI.RenderTexture, 1061 | false 1062 | ) 1063 | 1064 | // stamp 1065 | if (this.strokeState.isErasing) { 1066 | // stamp to erase texture 1067 | this.updateMask(this.segmentContainer, true) 1068 | } else { 1069 | // temporarily set 1070 | this.strokeSprite.alpha = this.strokeState.strokeOpacityScale 1071 | 1072 | // stamp to layer texture 1073 | this.stampStroke( 1074 | this.strokeSprite, 1075 | this.layers.getCurrentLayer() 1076 | ) 1077 | 1078 | // reset 1079 | if (this.strokeState.strokeOpacityScale < 1) { 1080 | this.strokeSprite.alpha = this.strokeState.strokeOpacityScale 1081 | } else { 1082 | this.strokeSprite.alpha = this.strokeState.layerOpacity 1083 | } 1084 | } 1085 | this.disposeContainer(this.segmentContainer) 1086 | this.offscreenContainer.removeChildren() 1087 | 1088 | // clear any sprites from live or stroke 1089 | this.disposeContainer(this.liveContainer) 1090 | this.disposeContainer(this.strokeSprite) 1091 | 1092 | // clear the strokeSprite texture 1093 | this.app.renderer.render( 1094 | new PIXI.Sprite(PIXI.Texture.EMPTY), 1095 | this.strokeSprite.texture as PIXI.RenderTexture, 1096 | true 1097 | ) 1098 | 1099 | return 1100 | } 1101 | 1102 | // static 1103 | // do we have enough points to render a static stroke to the texture? 1104 | if (len >= 3) { 1105 | let last = this.strokeState.points.length - 1 1106 | let a = last - 2 1107 | let b = last - 1 1108 | 1109 | // draw to the segment container 1110 | this.addStrokeNodes( 1111 | this.strokeState.points.slice(a, b + 1), 1112 | new paper.Path(this.strokeState.path.segments.slice(a, b + 1)), 1113 | this.segmentContainer 1114 | ) 1115 | 1116 | // stamp 1117 | if (this.strokeState.isErasing) { 1118 | // stamp to the erase texture 1119 | this.updateMask(this.segmentContainer) 1120 | } else { 1121 | // render to stroke texture 1122 | this.app.renderer.render( 1123 | this.segmentContainer, 1124 | this.strokeSprite.texture as PIXI.RenderTexture, 1125 | false 1126 | ) 1127 | } 1128 | this.disposeContainer(this.segmentContainer) 1129 | this.offscreenContainer.removeChildren() 1130 | 1131 | this.strokeState.lastStaticIndex = b 1132 | } 1133 | 1134 | // live 1135 | // do we have enough points to draw a live stroke to the container? 1136 | if (len >= 2) { 1137 | this.disposeContainer(this.liveContainer) 1138 | 1139 | let last = this.strokeState.points.length - 1 1140 | let a = last - 1 1141 | let b = last 1142 | 1143 | // render the current stroke live 1144 | if (this.strokeState.isErasing) { 1145 | // TODO find a good way to add live strokes to erase mask 1146 | // this.updateMask(this.liveContainer) 1147 | } else { 1148 | // store the current spacing 1149 | let tmpLastSpacing = this.strokeState.lastSpacing 1150 | // draw a live stroke 1151 | this.addStrokeNodes( 1152 | this.strokeState.points.slice(a, b + 1), 1153 | new paper.Path(this.strokeState.path.segments.slice(a, b + 1)), 1154 | this.liveContainer 1155 | ) 1156 | // revert the spacing so the real stroke will be correct 1157 | this.strokeState.lastSpacing = tmpLastSpacing 1158 | } 1159 | } 1160 | } 1161 | 1162 | updateMask (source: any, finalize = false) { 1163 | // find the top-most active layer 1164 | const descending = (a: number, b: number) => b - a 1165 | let layer = this.strokeState.layerIndices 1166 | .map(i => this.layers[i]) 1167 | .sort( 1168 | (a, b) => descending( 1169 | a.sprite.parent.getChildIndex(a.sprite), 1170 | b.sprite.parent.getChildIndex(b.sprite) 1171 | ) 1172 | )[0] 1173 | 1174 | // TODO move this to an initialize step 1175 | // starting a new round 1176 | if (!layer.sprite.mask) { 1177 | // add the mask on top of all layers 1178 | this.layersContainer.addChild(this.eraseMask) 1179 | 1180 | // reset the mask with a solid red background 1181 | let graphics = new PIXI.Graphics() 1182 | .beginFill(0xff0000, 1.0) 1183 | .drawRect(0, 0, this.width, this.height) 1184 | .endFill() 1185 | this.app.renderer.render( 1186 | graphics, 1187 | this.eraseMask.texture as PIXI.RenderTexture, 1188 | true 1189 | ) 1190 | 1191 | // use the mask 1192 | for (let i of this.strokeState.layerIndices) { 1193 | let layer = this.layers[i] 1194 | layer.sprite.mask = this.eraseMask 1195 | } 1196 | } 1197 | 1198 | // render the white strokes onto the red filled erase mask texture 1199 | this.app.renderer.render( 1200 | source, 1201 | this.eraseMask.texture as PIXI.RenderTexture, 1202 | false 1203 | ) 1204 | 1205 | // if finalizing, 1206 | if (finalize) { 1207 | for (let i of this.strokeState.layerIndices) { 1208 | // apply the erase texture to the actual layer texture 1209 | this.layers[i].applyMask(this.eraseMask) 1210 | } 1211 | 1212 | // TODO GC the eraseMask texture? 1213 | } 1214 | } 1215 | 1216 | // TODO handle crop / center 1217 | // TODO mark dirty? 1218 | replaceLayer (index: number, source: any, clear = true) { 1219 | index = (index == null) ? this.layers.getCurrentIndex() : index 1220 | 1221 | this.layers[index].replace(source, clear) 1222 | } 1223 | 1224 | // DEPRECATED 1225 | getLayerCanvas (index: number) { 1226 | console.warn('SketchPane#getLayerCanvas is deprecated. Please fix the caller to use a different method.') 1227 | console.trace() 1228 | index = (index == null) ? this.layers.getCurrentIndex() : index 1229 | 1230 | // #canvas reads the raw pixels and converts to an HTMLCanvasElement 1231 | // see: http://pixijs.download/release/docs/PIXI.extract.WebGLExtract.html 1232 | return this.app.renderer.plugins.extract.canvas(this.layers[index].sprite.texture) 1233 | } 1234 | 1235 | exportLayer (index: number, format = 'base64') { 1236 | index = (index == null) ? this.layers.getCurrentIndex() : index 1237 | 1238 | return this.layers[index].export(format) 1239 | } 1240 | 1241 | extractThumbnailPixels (width: number, height: number, indices : Array = []) { 1242 | return this.layers.extractThumbnailPixels(width, height, indices) 1243 | } 1244 | 1245 | clearLayer (index: number) { 1246 | index = (index == null) ? this.layers.getCurrentIndex() : index 1247 | 1248 | this.layers[index].clear() 1249 | } 1250 | 1251 | getNumLayers () { 1252 | return this.layers.length - 1 1253 | } 1254 | 1255 | // get current layer 1256 | getCurrentLayerIndex (index: number) { 1257 | return this.layers.getCurrentIndex() 1258 | } 1259 | 1260 | // set layer by index (0-indexed) 1261 | setCurrentLayerIndex (index: number) { 1262 | if (this.pointerDown) return // prevent layer change during draw 1263 | 1264 | this.layers.setCurrentIndex(index) 1265 | } 1266 | 1267 | _brushSize: number 1268 | 1269 | // TODO setState instead? 1270 | set brushSize (value) { 1271 | this._brushSize = value 1272 | this.cursor.updateSize() 1273 | } 1274 | 1275 | get brushSize () { 1276 | return this._brushSize 1277 | } 1278 | 1279 | isDrawing () { 1280 | return this.pointerDown 1281 | } 1282 | 1283 | // getIsErasing () { 1284 | // return this.isErasing 1285 | // } 1286 | // 1287 | // setIsErasing (value) { 1288 | // if (this.pointerDown) return // prevent erase mode change during draw 1289 | // 1290 | // this.isErasing = value 1291 | // } 1292 | // 1293 | // setErasableLayers (indices) { 1294 | // this.layers.setActiveIndices(indices) 1295 | // } 1296 | 1297 | getLayerOpacity (index: number) { 1298 | return this.layers[index].getOpacity() 1299 | } 1300 | 1301 | setLayerOpacity (index: number, opacity: number) { 1302 | this.layers[index].setOpacity(opacity) 1303 | } 1304 | 1305 | markLayersDirty (indices: Array) { 1306 | return this.layers.markDirty(indices) 1307 | } 1308 | 1309 | clearLayerDirty (index: number) { 1310 | this.layers[index].setDirty(false) 1311 | } 1312 | 1313 | getLayerDirty (index: number) { 1314 | return this.layers[index].getDirty() 1315 | } 1316 | 1317 | isLayerEmpty (index: number) { 1318 | return this.layers[index].isEmpty() 1319 | } 1320 | 1321 | // getActiveLayerIndices () { 1322 | // return this.layers.getActiveIndices() 1323 | // } 1324 | 1325 | getDOMElement () { 1326 | return this.app.view 1327 | } 1328 | 1329 | // 1330 | // operations 1331 | // 1332 | // 1333 | flipLayers (vertical = false) { 1334 | this.layers.flip(vertical) 1335 | } 1336 | } 1337 | -------------------------------------------------------------------------------- /src/ts/sketch-pane/util.ts: -------------------------------------------------------------------------------- 1 | export default class Util { 2 | static rotatePoint (pointX: number, pointY: number, originX: number, originY: number, angle: number) { 3 | return { 4 | x: 5 | Math.cos(angle) * (pointX - originX) - 6 | Math.sin(angle) * (pointY - originY) + 7 | originX, 8 | y: 9 | Math.sin(angle) * (pointX - originX) + 10 | Math.cos(angle) * (pointY - originY) + 11 | originY 12 | } 13 | } 14 | 15 | static calcTiltAngle (tiltX: number, tiltY: number) { 16 | let angle = Math.atan2(tiltY, tiltX) * (180 / Math.PI) 17 | let tilt = Math.max(Math.abs(tiltX), Math.abs(tiltY)) 18 | return { angle: angle, tilt: tilt } 19 | } 20 | 21 | static lerp (value1: number, value2: number, amount: number) { 22 | amount = amount < 0 ? 0 : amount 23 | amount = amount > 1 ? 1 : amount 24 | return value1 + (value2 - value1) * amount 25 | } 26 | 27 | // via https://github.com/pixijs/pixi.js/pull/4632/files#diff-e38c1de4b0f48ed1293bccc38b07e6c1R123 28 | // AKA un-premultiply 29 | static arrayPostDivide (pixels: any): any { 30 | for (let i = 0; i < pixels.length; i += 4) { 31 | const alpha = pixels[i + 3] 32 | if (alpha) { 33 | pixels[i] = Math.round(Math.min(pixels[i] * 255.0 / alpha, 255.0)) 34 | pixels[i + 1] = Math.round(Math.min(pixels[i + 1] * 255.0 / alpha, 255.0)) 35 | pixels[i + 2] = Math.round(Math.min(pixels[i + 2] * 255.0 / alpha, 255.0)) 36 | } 37 | } 38 | } 39 | 40 | static pixelsToCanvas (pixels: any, width: number, height: number): HTMLCanvasElement { 41 | let canvas = document.createElement('canvas') 42 | canvas.width = width 43 | canvas.height = height 44 | let context = canvas.getContext('2d') 45 | let canvasData = context.createImageData(width, height) 46 | canvasData.data.set(pixels) 47 | context.putImageData(canvasData, 0, 0) 48 | return canvas 49 | } 50 | 51 | static dataURLToFileContents (dataURL: string) { 52 | return dataURL.replace(/^data:image\/\w+;base64,/, '') 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/resize/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 258 | 270 | 302 | 303 | 304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 | 317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 | 326 | 327 | 329 | 472 | 473 | 474 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "module": "es6", 6 | "target": "es5", 7 | "lib": [ 8 | "dom", 9 | "es2017", 10 | "es2017.object" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const createConfig = opt => { 4 | return { 5 | mode: process.env.MODE, 6 | entry: { 7 | 'sketch-pane': './src/ts/index.ts' 8 | }, 9 | ...opt.optimization ? { optimization: opt.optimization } : {}, 10 | output: { 11 | filename: opt.output.filename, 12 | path: path.resolve(__dirname, 'dist'), 13 | library: 'SketchLib', 14 | libraryTarget: opt.output.libraryTarget, 15 | publicPath: '/dist' 16 | }, 17 | resolve: { 18 | extensions: ['.ts', '.js', '.json'] 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.(glsl|frag|vert)$/, 24 | loader: 'shader-loader' 25 | }, 26 | { 27 | test: /\.tsx?$/, 28 | loader: 'ts-loader' 29 | } 30 | ] 31 | }, 32 | performance: { 33 | hints: false, 34 | }, 35 | ...opt.externals ? { externals: opt.externals } : {}, 36 | ...opt.serve ? { serve: opt.serve } : {} 37 | } 38 | } 39 | 40 | const browserConfig = createConfig({ output: { filename: '[name].browser.js', libraryTarget: 'var' }, 41 | optimization: { 42 | splitChunks: { 43 | cacheGroups: { 44 | commons: { 45 | test: /[\\/]node_modules[\\/]/, 46 | name: 'vendor', 47 | chunks: 'all' 48 | } 49 | } 50 | } 51 | }, 52 | ...process.env.WEBPACK_SERVE 53 | ? { 54 | serve: { 55 | dev: { 56 | publicPath: '/dist' 57 | }, 58 | // to force serving development dist/ when production files exist on filesystem 59 | add: (app, middleware, options) => { 60 | middleware.webpack() 61 | middleware.content() 62 | } 63 | } 64 | } 65 | : {} 66 | }) 67 | 68 | const nodeConfig = createConfig({ output: { filename: '[name].common.js', libraryTarget: 'commonjs2' }, 69 | externals: { 70 | 'pixi.js': 'pixi.js', 71 | 'paper': 'paper' 72 | }, 73 | optimization: { 74 | minimize: false 75 | } 76 | }) 77 | 78 | module.exports = process.env.WEBPACK_SERVE 79 | // always produce browser output 80 | ? browserConfig 81 | // only produce commonjs output if we're not in the dev server mode 82 | : [ browserConfig, nodeConfig ] 83 | --------------------------------------------------------------------------------