UNPKG

fugafacere

Version:

A pure-JS implementation of the W3C's Canvas-2D Context API that can run on top of either Expo Graphics or a browser WebGL context.

1,766 lines (1,485 loc) 76.8 kB
'use strict'; import bezierCubicPoints from 'adaptive-bezier-curve'; import bezierQuadraticPoints from 'adaptive-quadratic-curve'; import parseCssFont from 'css-font-parser'; import DOMException from 'domexception'; import earcut from 'earcut'; import stringFormat from 'string-format'; import tess2 from 'tess2'; import { StrokeExtruder } from './StrokeExtruder'; import { getBuiltinFonts } from './builtinFonts'; import parseColor from './cssColorParser'; import { getEnvironment } from './environment'; import { ShaderProgram, patternShaderTxt, patternShaderRepeatValues, radialGradShaderTxt, disjointRadialGradShaderTxt, linearGradShaderTxt, flatShaderTxt, } from './shaders'; import { ImageData as _ImageData } from './utilityObjects'; import Vector from './vector'; const glm = require('gl-matrix'); export const ImageData = _ImageData; // TODO: rather than setting vertexattribptr on every draw, // create a separate vbo for coords vs pattern coords vs text coords // and call once // TODO: make sure styles don't get reapplied if they're already // set // TODO: use same tex coord attrib array for drawImage and fillText function isValidCanvasImageSource(asset) { const environment = getEnvironment(); if (asset === undefined) { return false; } if (asset instanceof Expo2DContext) { return true; } else if (asset instanceof ImageData) { return true; } else { if ( asset.hasOwnProperty('width') && asset.hasOwnProperty('height') && (asset.hasOwnProperty('localUri') || asset.hasOwnProperty('data')) ) { return true; } if (environment === 'web' && 'nodeName' in asset) { if (asset.nodeName.toLowerCase() === 'img' || asset.nodeName.toLowerCase() === 'canvas') { return true; } } } return false; } export function cssToGlColor(cssStr) { try { return parseColor(cssStr); } catch (e) { return []; } } function outerTangent(p0, r0, p1, r1) { const d = Math.sqrt(Math.pow(p1[0] - p0[0], 2) + Math.pow(p1[1] - p0[1], 2)); const gamma = -Math.atan2(p1[1] - p0[1], p1[0] - p0[0]); const beta = Math.asin((r1 - r0) / d); const alpha = gamma - beta; const angle = Math.PI / 2 - alpha; const tanpt1 = [p0[0] + r0 * Math.cos(angle), p0[1] + r0 * Math.sin(angle)]; const tanpt2 = [p1[0] + r1 * Math.cos(angle), p1[1] + r1 * Math.sin(angle)]; //let tanm = (c2[1] - c1[1] + Math.sin(angle)*(c2[2] - c1[2])) / (c2[0] - c1[0] + Math.cos(angle)*(c2[2] - c1[2])); const tanm = (tanpt2[1] - tanpt1[1]) / (tanpt2[0] - tanpt1[0]); const tanb = tanpt1[1] - tanm * tanpt1[0]; const centerm = (p1[1] - p0[1]) / (p1[0] - p0[0]); const centerb = p0[1] - centerm * p0[0]; const o = [0, 0]; if (!isFinite(tanm)) { o[0] = tanpt1[0]; o[1] = o[0] * centerm + centerb; } else if (!isFinite(centerm)) { o[0] = p1[0]; o[1] = o[0] * tanm + tanb; } else { o[0] = (centerb - tanb) / (tanm - centerm); o[1] = o[0] * tanm + tanb; } return o; } export class CanvasPattern { constructor(pattern, repeat) { this.pattern = pattern; this.repeat = repeat; } } export default class Expo2DContext { /************************************************** * Utility methods **************************************************/ _initDrawingState() { this.drawingState = { mvMatrix: glm.mat4.create(), fillStyle: '#000000', strokeStyle: '#000000', lineWidth: 1, lineCap: 'butt', lineJoin: 'miter', miterLimit: 10, strokeDashes: [], strokeDashOffset: 0, // TODO: figure out directionality/font size/other css tweakability font_css: '10px sans-serif', font_parsed: null, font_resources: null, textAlign: 'start', textBaseline: 'alphabetic', globalAlpha: 1.0, clippingPaths: [], }; this.drawingStateStack = []; this._invMvMatrix = null; this.stencilsEnabled = false; this.pMatrix = glm.mat4.create(); this.strokeExtruder = new StrokeExtruder(); this._updateStrokeExtruderState(); this.beginPath(); } _updateStrokeExtruderState() { Object.assign(this.strokeExtruder, { thickness: this.drawingState.lineWidth, cap: this.drawingState.lineCap, join: this.drawingState.lineJoin, miterLimit: this.drawingState.miterLimit, dashList: this.drawingState.strokeDashes, dashOffset: this.drawingState.strokeDashOffset, }); } _getInvMvMatrix() { if (this._invMvMatrix == null) { this._invMvMatrix = glm.mat4.create(); glm.mat4.invert(this._invMvMatrix, this.drawingState.mvMatrix); } return this._invMvMatrix; } _updateMatrixUniforms() { const gl = this.gl; this._invMvMatrix = null; if (this.activeShaderProgram != null) { gl.uniformMatrix4fv(this.activeShaderProgram.uniforms['uPMatrix'], false, this.pMatrix); gl.uniformMatrix4fv( this.activeShaderProgram.uniforms['uMVMatrix'], false, this.drawingState.mvMatrix ); if ('uiMVMatrix' in this.activeShaderProgram.uniforms) { gl.uniformMatrix4fv( this.activeShaderProgram.uniforms['uiMVMatrix'], false, this._getInvMvMatrix() ); } gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], false); } } _updateClippingRegion() { const gl = this.gl; if (this.drawingState.clippingPaths.length === 0) { gl.disable(gl.STENCIL_TEST); this.stencilsEnabled = false; } else { if (!this.stencilsEnabled) { gl.enable(gl.STENCIL_TEST); this.stencilsEnabled = true; } // TODO: can this be done incrementally (eg, across clip() calls)? gl.colorMask(false, false, false, false); gl.stencilMask(0xff); gl.clear(gl.STENCIL_BUFFER_BIT); gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], true); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); // TODO: this shouldn't have to get called here: gl.vertexAttribPointer( this.activeShaderProgram.attributes['aVertexPosition'], 2, gl.FLOAT, false, 0, 0 ); /// Current procedure: /// (TODO: clean up this comment) /// - intersected buffer on bit 0 /// - workspace on bit 1 /// - clear bit 0 to all 1s /// per clipping path: /// - build non-0 clipping path on bit 1 with INVERT /// - draw full-screen rect, stencil test EQUAL 3, set stencil value to 1 on pass and 0 on fail /// - finally, set test to EQUAL 1 / KEEP /// if this works, i am a gl stencil witch for (let i = 0; i < this.drawingState.clippingPaths.length; i++) { gl.stencilMask(0x2); gl.clear(gl.STENCIL_BUFFER_BIT); // TODO: Is there any way to be more clever with the algorithm to avoid this clear? gl.stencilFunc(gl.ALWAYS, 0, 0xff); gl.stencilOp(gl.INVERT, gl.INVERT, gl.INVERT); const triangles = this.drawingState.clippingPaths[i]; gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(triangles), gl.STATIC_DRAW); gl.drawArrays(gl.TRIANGLES, 0, triangles.length / 2); gl.stencilMask(0x3); gl.stencilFunc(gl.EQUAL, 0x3, 0x3); gl.stencilOp(gl.ZERO, gl.KEEP, gl.KEEP); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([ 0, 0, gl.drawingBufferWidth, 0, gl.drawingBufferWidth, gl.drawingBufferHeight, 0, 0, 0, gl.drawingBufferHeight, gl.drawingBufferWidth, gl.drawingBufferHeight, ]), gl.STATIC_DRAW ); gl.drawArrays(gl.TRIANGLES, 0, 6); } // Change draw target back to framebuffer and set up stencilling gl.colorMask(true, true, true, true); gl.stencilMask(0x00); gl.stencilFunc(gl.EQUAL, 1, 1); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], false); } } /************************************************** * Pixel data methods **************************************************/ createImageData() { let sw; let sh; /* eslint-disable prefer-rest-params */ if (arguments.length === 1) { if (!(arguments[0] instanceof ImageData)) { throw new TypeError('Bad imagedata'); } sw = arguments[0].width; sh = arguments[0].height; } else if (arguments.length === 2) { sw = arguments[0]; sh = arguments[1]; sw = Math.floor(Math.abs(sw)); sh = Math.floor(Math.abs(sh)); } else { throw new TypeError(); } if (!isFinite(sw) || !isFinite(sh)) { throw new TypeError('Bad dimensions'); } if (!(this instanceof Expo2DContext)) { throw new TypeError('Bad object instance'); } if (sw === 0 || sh === 0) { throw new DOMException('Bad dimensions', 'IndexSizeError'); } return new ImageData(sw, sh); } getImageData(sx, sy, sw, sh) { const gl = this.gl; if (arguments.length !== 4) throw new TypeError(); if (sw < 0) { sx += sw; sw = -sw; } if (sh < 0) { sy += sh; sh = -sh; } if (!isFinite(sx) || !isFinite(sy) || !isFinite(sw) || !isFinite(sh)) { throw new TypeError('Bad geometry'); } if (this.environment === 'expo' && !this.renderWithOffscreenBuffer) { console.log( 'WARNING: getImageData() may fail when renderWithOffscreenBuffer param is set to false' ); } if (this.environment === 'web' && !gl.getContextAttributes()['preserveDrawingBuffer']) { console.log( "WARNING: getImageData() may fail when the underlying GL context's preserveDrawingBuffer attribute is not set to true" ); } sx = Math.floor(sx); sy = Math.floor(sy); sw = Math.floor(sw); sh = Math.floor(sh); if (sw === 0 || sh === 0) { throw new DOMException('Bad geometry', 'IndexSizeError'); } // This flush isn't technically necessary because readPixels should cause // an expo gl flush anyway, but here just in case more operations get added // to Expo2DContext flush in the future: this.flush(); const imageDataObj = new ImageData(sw, sh); const rawTexData = new this._framebuffer_format.typed_array(sw * sh * 4); const flip_y = this._framebuffer_format.origin === 'internal'; gl.readPixels( sx, flip_y ? gl.drawingBufferHeight - sh - sy : sy, sw, sh, gl.RGBA, this._framebuffer_format.readpixels_type, rawTexData ); // Undo premultiplied alpha // (TODO: is there any way to do this with the GPU??) for (let y = 0; y < imageDataObj.height; y += 1) { const src_base = y * imageDataObj.width * 4; const dst_base = (flip_y ? imageDataObj.height - y - 1 : y) * imageDataObj.width * 4; for (let i = 0; i < imageDataObj.width * 4; i += 4) { const src = src_base + i; const dst = dst_base + i; imageDataObj.data[dst + 0] = Math.floor( (rawTexData[src + 0] / rawTexData[src + 3]) * 256.0 ); imageDataObj.data[dst + 1] = Math.floor( (rawTexData[src + 1] / rawTexData[src + 3]) * 256.0 ); imageDataObj.data[dst + 2] = Math.floor( (rawTexData[src + 2] / rawTexData[src + 3]) * 256.0 ); imageDataObj.data[dst + 3] = Math.floor( (rawTexData[src + 3] / this._framebuffer_format.max_alpha) * 256.0 ); } } return imageDataObj; } putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight) { const gl = this.gl; let asset; let typeError = ''; if (imagedata instanceof Expo2DContext) { // TODO: in browsers support canvas tags too asset = this._assetFromContext(imagedata); } else if (imagedata instanceof ImageData) { asset = { width: imagedata.width, height: imagedata.height, data: new Uint8Array(imagedata.data.buffer), }; } else { typeError = 'Bad imagedata'; } if (!isFinite(dx) || !isFinite(dy)) { typeError = 'Bad dx/dy'; } if (!isFinite(dirtyX)) { if (arguments.length >= 4) { typeError = 'Bad dirtyX'; } dirtyX = 0; } if (!isFinite(dirtyY)) { if (arguments.length >= 5) { typeError = 'Bad dirtyY'; } dirtyY = 0; } if (!isFinite(dirtyWidth)) { if (arguments.length >= 6) { typeError = 'Bad dirtyWidth'; } dirtyWidth = asset.width; } if (!isFinite(dirtyHeight)) { if (arguments.length >= 7) { typeError = 'Bad dirtyHeight'; } dirtyHeight = asset.height; } if (typeError !== '') { throw new TypeError(typeError); } if (dirtyWidth < 0) { dirtyX += dirtyWidth; dirtyWidth = -dirtyWidth; } if (dirtyHeight < 0) { dirtyY += dirtyHeight; dirtyHeight = -dirtyHeight; } if (dirtyX < 0) { dirtyWidth += dirtyX; dirtyX = 0; } if (dirtyY < 0) { dirtyHeight += dirtyY; dirtyY = 0; } if (dirtyX + dirtyWidth > asset.width) { dirtyWidth = asset.width - dirtyX; } if (dirtyY + dirtyHeight > asset.height) { dirtyHeight = asset.height - dirtyY; } dx = Math.floor(dx); dy = Math.floor(dy); dirtyX = Math.floor(dirtyX); dirtyY = Math.floor(dirtyY); dirtyWidth = Math.floor(dirtyWidth); dirtyHeight = Math.floor(dirtyHeight); if (dirtyWidth <= 0 || dirtyHeight <= 0) { return; } const pattern = this.createPattern(asset, 'src-rect'); this._applyStyle(pattern); if (this.activeShaderProgram == null) { return; } const minScreenX = dx + dirtyX; const minScreenY = dy + dirtyY; const maxScreenX = minScreenX + dirtyWidth; const maxScreenY = minScreenY + dirtyHeight; const minTexX = dirtyX / asset.width; const minTexY = dirtyY / asset.height; const maxTexX = minTexX + dirtyWidth / asset.width; const maxTexY = minTexY + dirtyHeight / asset.height; const vertices = [ minScreenX, minScreenY, minTexX, minTexY, minScreenX, maxScreenY, minTexX, maxTexY, maxScreenX, minScreenY, maxTexX, minTexY, maxScreenX, maxScreenY, maxTexX, maxTexY, ]; gl.enableVertexAttribArray(this.activeShaderProgram.attributes['aTexCoord']); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aVertexPosition'], 2, gl.FLOAT, false, 4 * 2 * 2, 0 ); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aTexCoord'], 2, gl.FLOAT, false, 4 * 2 * 2, 4 * 2 ); if (this.stencilsEnabled) { gl.disable(gl.STENCIL_TEST); } gl.uniform1f(this.activeShaderProgram.uniforms['uGlobalAlpha'], 1.0); gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ZERO, gl.ONE, gl.ZERO); gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], true); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], false); gl.disableVertexAttribArray(this.activeShaderProgram.attributes['aTexCoord']); if (this.stencilsEnabled) { gl.enable(gl.STENCIL_TEST); } this._applyCompositingState(); } /************************************************** * Image methods **************************************************/ drawImage() { const gl = this.gl; const asset = arguments[0]; if (typeof asset !== 'object' || asset === null || !isValidCanvasImageSource(asset)) { throw new TypeError('Bad asset'); } if (asset.width === 0 || asset.height === 0) { // Zero-sized asset image causes DOMException throw new DOMException('Bad source rectangle', 'InvalidStateError'); } let sx = 0; let sy = 0; let sw = 1; let sh = 1; let dx; let dy; let dw; let dh; /* eslint-disable prefer-rest-params */ if (arguments.length === 3) { dx = arguments[1]; dy = arguments[2]; dw = asset.width; dh = asset.height; } else if (arguments.length === 5) { dx = arguments[1]; dy = arguments[2]; dw = arguments[3]; dh = arguments[4]; } else if (arguments.length === 9) { sx = arguments[1] / asset.width; sy = arguments[2] / asset.height; sw = arguments[3] / asset.width; sh = arguments[4] / asset.height; dx = arguments[5]; dy = arguments[6]; dw = arguments[7]; dh = arguments[8]; } else { throw new TypeError(); } if ( !isFinite(dx) || !isFinite(dy) || !isFinite(dw) || !isFinite(dh) || !isFinite(sx) || !isFinite(sy) || !isFinite(sw) || !isFinite(sh) ) { return; } if (sw === 0 || sh === 0) { // Zero-sized source rect specified by the programmer is A-OK :P return; } // TODO: the shader clipping method for source rectangles that are // out of bounds relies on BlendFunc being set to SRC_ALPHA/SRC_ONE_MINUS_ALPHA // if we can't rely on that, we'll have to clip beforehand by messing // with rectangle dimensions const dxmin = Math.min(dx, dx + dw); const dxmax = Math.max(dx, dx + dw); const dymin = Math.min(dy, dy + dh); const dymax = Math.max(dy, dy + dh); const sxmin = Math.min(sx, sx + sw); const sxmax = Math.max(sx, sx + sw); const symin = Math.min(sy, sy + sh); const symax = Math.max(sy, sy + sh); const vertices = [ dxmin, dymin, sxmin, symin, dxmin, dymax, sxmin, symax, dxmax, dymin, sxmax, symin, dxmax, dymax, sxmax, symax, ]; const pattern = this.createPattern(asset, 'src-rect'); this._applyStyle(pattern); if (this.activeShaderProgram == null) { return; } gl.enableVertexAttribArray(this.activeShaderProgram.attributes['aTexCoord']); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aVertexPosition'], 2, gl.FLOAT, false, 4 * 2 * 2, 0 ); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aTexCoord'], 2, gl.FLOAT, false, 4 * 2 * 2, 4 * 2 ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.disableVertexAttribArray(this.activeShaderProgram.attributes['aTexCoord']); } /************************************************** * Text methods **************************************************/ _getTextAlignmentOffset(text) { let halign = this.drawingState.textAlign; if (halign === 'start') { halign = 'left'; } if (halign === 'end') { halign = 'right'; } const textWidth = this.measureText(text).width; if (halign === 'right') { return textWidth; } else if (halign === 'center') { return textWidth * 0.5; } else { return 0; } } _getTextBaselineOffset(text) { const valign = this.drawingState.textBaseline; const font = this.drawingState.font_resources; if (valign === 'alphabetic') { return font.common.base; } else if (valign === 'top') { return 0; } else if (valign === 'bottom') { // TODO } else if (valign === 'ideographic') { // TODO: this isn't technically correct, // but not sure if there is any way to // actually do it with the BMFont format: return font.common.base; } else if (valign === 'hanging') { // TODO } return 0; } _prepareText(text, x, y, xscale, geometry) { // TODO: directionality const font = this.drawingState.font_resources; xscale *= this.drawingState.font_parsed['size-scalar']; const yscale = this.drawingState.font_parsed['size-scalar']; let xskew = 0; if ( this.drawingState.font_parsed['font-style'] === 'italic' || this.drawingState.font_parsed['font-style'] === 'oblique' ) { xskew = (font.chars['M'].xadvance / 4) * xscale; } const small_caps = this.drawingState.font_parsed['font-variant'] === 'small-caps'; text = text.replace(/\s/g, ' '); let space_width; if (font.chars[' ']) { space_width = font.chars[' '].xadvance * xscale; } else { space_width = (font.chars['M'].xadvance / 2) * xscale; } let pen_x = x; const pen_y = y; for (let i = 0; i < text.length; i++) { if (text[i] === ' ') { pen_x += space_width * xscale; } let glyph = font.chars[text[i]]; let smallcap_scale = 1.0; if (small_caps && text[i].toLowerCase() === text[i]) { glyph = font.chars[text[i].toUpperCase()]; smallcap_scale = 0.75; } if (!glyph) { continue; // TODO: what to actually do?? } if (geometry) { const x1 = pen_x + glyph.xoffset * xscale * smallcap_scale; let y1 = pen_y + glyph.yoffset * yscale * smallcap_scale; const x2 = x1 + glyph.width * xscale * smallcap_scale; let y2 = y1 + glyph.height * yscale * smallcap_scale; if (small_caps) { const smallcap_offset = (y2 - y1) / smallcap_scale - (y2 - y1); y1 += smallcap_offset; y2 += smallcap_offset; } geometry.push( x1 + xskew, y1, glyph.u1, glyph.v1, glyph.page, x2 + xskew, y1, glyph.u2, glyph.v1, glyph.page, x1, y2, glyph.u1, glyph.v2, glyph.page, x2 + xskew, y1, glyph.u2, glyph.v1, glyph.page, x2, y2, glyph.u2, glyph.v2, glyph.page, x1, y2, glyph.u1, glyph.v2, glyph.page ); } pen_x += (glyph.xadvance + font.info.spacing[0]) * xscale * smallcap_scale; // TODO: make sure this is right: if (i < text.length - 1) { if (text[i] in font.kernings && text[i + 1] in font.kernings[text[i]]) { pen_x += font.kernings[text[i]][text[i + 1]] * xscale; } } } return pen_x - x; } measureText(text) { if (arguments.length !== 1) throw new TypeError(); return { width: this._prepareText(text, 0, 0, 1) }; } async initializeText() { if (arguments.length !== 0) throw new TypeError(); const promises = []; const font_objects = Object.values(this.builtinFonts); for (let i = 0; i < font_objects.length; i++) { if (font_objects[i] != null) { promises.push(font_objects[i].await_assets()); } } await Promise.all(promises); } _drawText(text, x, y, maxWidth, strokeWidth) { const gl = this.gl; const font = this.drawingState.font_resources; if (font === null) { throw new ReferenceError('Font system is not initialized (await initializeText())'); } if (maxWidth !== undefined && !isFinite(maxWidth)) { return; } this._applyStyle(this.drawingState.fillStyle); if (this.activeShaderProgram == null) { return; } gl.enableVertexAttribArray(this.activeShaderProgram.attributes['aTextPageCoord']); gl.uniform1i(this.activeShaderProgram.uniforms['uTextEnabled'], 1); gl.uniform1f(this.activeShaderProgram.uniforms['uTextStrokeWidth'], strokeWidth); if (this.drawingState.font_parsed['font-weight'] === 'bold') { gl.uniform1f( this.activeShaderProgram.uniforms['uTextDistanceFieldThreshold'], font.info.thresholds.bold ); } else if (this.drawingState.font_parsed['font-weight'] === 'bolder') { gl.uniform1f( this.activeShaderProgram.uniforms['uTextDistanceFieldThreshold'], font.info.thresholds.bolder ); } else if (this.drawingState.font_parsed['font-weight'] === 'lighter') { gl.uniform1f( this.activeShaderProgram.uniforms['uTextDistanceFieldThreshold'], font.info.thresholds.lighter ); } else { gl.uniform1f( this.activeShaderProgram.uniforms['uTextDistanceFieldThreshold'], font.info.thresholds.normal ); } const geometry = []; let xscale = 1; if (maxWidth !== undefined) { const textWidth = this.measureText(text).width; if (textWidth > maxWidth) { xscale = maxWidth / textWidth; } } x -= this._getTextAlignmentOffset(text) * xscale; y -= this._getTextBaselineOffset(text); this._prepareText(text, x, y, xscale, geometry); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D_ARRAY, font.textures); gl.uniform1i(this.activeShaderProgram.uniforms['uTextPages'], 1); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(geometry), gl.STATIC_DRAW); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aVertexPosition'], 2, gl.FLOAT, false, 4 * 5, 0 ); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aTextPageCoord'], 3, gl.FLOAT, false, 4 * 5, 4 * 2 ); gl.drawArrays(gl.TRIANGLES, 0, geometry.length / 5); gl.disableVertexAttribArray(this.activeShaderProgram.attributes['aTextPageCoord']); gl.uniform1i(this.activeShaderProgram.uniforms['uTextEnabled'], 0); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.nullTextPage); } fillText(text, x, y, maxWidth) { if (arguments.length !== 3 && arguments.length !== 4) throw new TypeError(); this._drawText(text, x, y, maxWidth, -1); } strokeText(text, x, y, maxWidth) { if (arguments.length !== 3 && arguments.length !== 4) throw new TypeError(); // TODO: how to actually map lineWidth to distance field thresholds?? // TODO: scale width with mvmatrix? or does texture scaling already take care of that? let shaderStrokeWidth = this.drawingState.lineWidth / 7.0; shaderStrokeWidth /= this.drawingState.font_parsed['size-scalar']; this._drawText(text, x, y, maxWidth, shaderStrokeWidth); } /************************************************** * Rect methods **************************************************/ clearRect(x, y, w, h) { if (arguments.length !== 4) throw new TypeError(); const gl = this.gl; if (!isFinite(x) || !isFinite(y) || !isFinite(w) || !isFinite(h)) { return; } const old_fill_style = this.drawingState.fillStyle; const old_global_alpha = this.drawingState.globalAlpha; gl.blendFunc(gl.SRC_ALPHA, gl.ZERO); this.drawingState.fillStyle = 'rgba(0,0,0,0)'; this.fillRect(x, y, w, h); this.drawingState.fillStyle = old_fill_style; this.drawingState.globalAlpha = old_global_alpha; this._applyCompositingState(); } fillRect(x, y, w, h) { if (arguments.length !== 4) throw new TypeError(); if (!isFinite(x) || !isFinite(y) || !isFinite(w) || !isFinite(h)) { return; } const gl = this.gl; this._applyStyle(this.drawingState.fillStyle); if (this.activeShaderProgram == null) { return; } const vertices = [x, y, x, y + h, x + w, y, x + w, y + h]; gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aVertexPosition'], 2, gl.FLOAT, false, 0, 0 ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } strokeRect(x, y, w, h) { if (arguments.length !== 4) throw new TypeError(); if (!isFinite(x) || !isFinite(y) || !isFinite(w) || !isFinite(h)) { return; } const gl = this.gl; this._applyStyle(this.drawingState.strokeStyle); if (this.activeShaderProgram == null) { return; } const topLeft = this._getTransformedPt(x, y); const bottomRight = this._getTransformedPt(x + w, y + h); let polyline; let oldLineCap; if (w === 0 || h === 0) { oldLineCap = this.lineCap; this.lineCap = 'butt'; polyline = [topLeft[0], topLeft[1], bottomRight[0], bottomRight[1]]; } else { polyline = [ topLeft[0], topLeft[1], bottomRight[0], topLeft[1], bottomRight[0], bottomRight[1], topLeft[0], bottomRight[1], ]; } gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], true); this.strokeExtruder.closed = true; this.strokeExtruder.mvMatrix = this.drawingState.mvMatrix; this.strokeExtruder.invMvMatrix = this._getInvMvMatrix(); const vertices = this.strokeExtruder.build(polyline); this._drawStenciled(vertices); if (w === 0 || h === 0) { this.lineCap = oldLineCap; } } /************************************************** * Path methods **************************************************/ beginPath() { if (arguments.length !== 0) throw new TypeError(); this.subpaths = [[]]; this.subpathsModified = true; this.currentSubpath = this.subpaths[0]; this.currentSubpath.closed = false; } closePath() { if (arguments.length !== 0) throw new TypeError(); if (this.currentSubpath.length >= 2) { this.currentSubpath.closed = true; const baseIdx = this.currentSubpath.length - 2; // Note that this is almost moveTo() verbatim, except it doesn't // apply (or in this case, reapply) the transformation matrix to the // close point this.currentSubpath = []; this.currentSubpath.closed = false; this.subpathsModified = true; this.subpaths.push(this.currentSubpath); this.currentSubpath.push(this.currentSubpath[baseIdx]); this.currentSubpath.push(this.currentSubpath[baseIdx + 1]); } } _pathTriangles(path) { if (this.subpathsModified) { const triangles = []; const prunedSubpaths = []; for (let i = 0; i < this.subpaths.length; i++) { const subpath = this.subpaths[i]; if (subpath.length <= 4) { continue; } prunedSubpaths.push(subpath); } // TODO: be smarter about tesselator selection if (this.fastFillTesselation) { for (let i = 0; i < prunedSubpaths.length; i++) { const subpath = prunedSubpaths[i]; const triangleIndices = earcut(subpath, null); for (let i = 0; i < triangleIndices.length; i++) { triangles.push(subpath[triangleIndices[i] * 2]); triangles.push(subpath[triangleIndices[i] * 2 + 1]); } } } else { const result = tess2.tesselate({ contours: prunedSubpaths, windingRule: tess2.WINDING_NONZERO, elementType: tess2.POLYGONS, polySize: 3, vertexSize: 2, }); for (let i = 0; i < result.elements.length; i++) { const vertexBaseIdx = result.elements[i] * 2; triangles.push(result.vertices[vertexBaseIdx]); triangles.push(result.vertices[vertexBaseIdx + 1]); } } this.subpaths.triangles = triangles; this.subpathsModified = false; } return this.subpaths.triangles; } _ensureStartPath(x, y) { if (this.currentSubpath.length === 0) { const tPt = this._getTransformedPt(x, y); this.currentSubpath.push(tPt[0]); this.currentSubpath.push(tPt[1]); return false; } else { return true; } } isPointInPath(x, y) { if (arguments.length !== 2) throw new TypeError(); if (!isFinite(x) || !isFinite(y)) { return false; } const tPt = [x, y]; // TODO: is this approach more or less efficient than some // other inclusion test that works on the untesselated polygon? // investigate.... const triangles = this._pathTriangles(this.subpaths); for (let j = 0; j < triangles.length; j += 6) { // Point-in-triangle test adapted from: // https://koozdra.wordpress.com/2012/06/27/javascript-is-point-in-triangle/ const v0 = [triangles[j + 4] - triangles[j], triangles[j + 5] - triangles[j + 1]]; const v1 = [triangles[j + 2] - triangles[j], triangles[j + 3] - triangles[j + 1]]; const v2 = [tPt[0] - triangles[j], tPt[1] - triangles[j + 1]]; const dot00 = v0[0] * v0[0] + v0[1] * v0[1]; const dot01 = v0[0] * v1[0] + v0[1] * v1[1]; const dot02 = v0[0] * v2[0] + v0[1] * v2[1]; const dot11 = v1[0] * v1[0] + v1[1] * v1[1]; const dot12 = v1[0] * v2[0] + v1[1] * v2[1]; const invDenom = 1 / (dot00 * dot11 - dot01 * dot01); const u = (dot11 * dot02 - dot01 * dot12) * invDenom; const v = (dot00 * dot12 - dot01 * dot02) * invDenom; if (u >= 0 && v >= 0 && u + v <= 1) { return true; } } return false; } clip() { if (arguments.length !== 0) throw new TypeError(); const newClipPoly = this._pathTriangles(this.subpaths); this.drawingState.clippingPaths.push(newClipPoly); this._updateClippingRegion(); } fill() { if (arguments.length !== 0) throw new TypeError(); const gl = this.gl; this._applyStyle(this.drawingState.fillStyle); if (this.activeShaderProgram == null) { return; } gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], true); const triangles = this._pathTriangles(this.subpaths); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(triangles), gl.STATIC_DRAW); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aVertexPosition'], 2, gl.FLOAT, false, 0, 0 ); gl.drawArrays(gl.TRIANGLES, 0, triangles.length / 2); gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], false); } _drawStenciled(vertices) { const gl = this.gl; if (!this.stencilsEnabled) { gl.enable(gl.STENCIL_TEST); } gl.stencilMask(0x2); // Use bit 1, as bit 0 stores the clipping bounds gl.colorMask(false, false, false, false); gl.clear(gl.STENCIL_BUFFER_BIT); // Clear bit 1 to '0' gl.stencilFunc(gl.ALWAYS, 0xff, 0xff); gl.stencilOp(gl.REPLACE, gl.REPLACE, gl.REPLACE); this._applyStyle('black'); gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], true); gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); gl.vertexAttribPointer( this.activeShaderProgram.attributes['aVertexPosition'], 2, gl.FLOAT, false, 0, 0 ); gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 2); gl.stencilMask(0x00); gl.colorMask(true, true, true, true); gl.stencilFunc(gl.EQUAL, 3, 0xff); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); this._applyStyle(this.drawingState.strokeStyle); if (this.activeShaderProgram == null) { return; } gl.uniform1i(this.activeShaderProgram.uniforms['uSkipMVTransform'], true); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([ 0, 0, gl.drawingBufferWidth, 0, gl.drawingBufferWidth, gl.drawingBufferHeight, 0, 0, 0, gl.drawingBufferHeight, gl.drawingBufferWidth, gl.drawingBufferHeight, ]), gl.STATIC_DRAW ); gl.drawArrays(gl.TRIANGLES, 0, 6); if (!this.stencilsEnabled) { gl.disable(gl.STENCIL_TEST); } else { // Set things back to normal for clipping system // TODO: decompose this and the same code at the bottom of _updateClippingRegion() gl.stencilFunc(gl.EQUAL, 1, 1); gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP); } } stroke() { if (arguments.length !== 0) throw new TypeError(); const vertices = []; for (let i = 0; i < this.subpaths.length; i++) { const subpath = this.subpaths[i]; if (subpath.length === 0) { continue; } this.strokeExtruder.closed = subpath.closed || false; this.strokeExtruder.mvMatrix = this.drawingState.mvMatrix; this.strokeExtruder.invMvMatrix = this._getInvMvMatrix(); vertices.push(...this.strokeExtruder.build(subpath)); } // TODO: test integration with clipping this._drawStenciled(vertices); } moveTo(x, y) { if (arguments.length !== 2) throw new TypeError(); if (!isFinite(x) || !isFinite(y)) { return; } this.currentSubpath = []; this.currentSubpath.closed = false; this.subpathsModified = true; this.subpaths.push(this.currentSubpath); const tPt = this._getTransformedPt(x, y); this.currentSubpath.push(tPt[0]); this.currentSubpath.push(tPt[1]); } lineTo(x, y) { if (arguments.length !== 2) throw new TypeError(); if (!isFinite(x) || !isFinite(y)) { y.valueOf(); // Call to make 2d.path.lineTo.nonfinite.details happy return; } if (!this._ensureStartPath(x, y)) { return; } const tPt = this._getTransformedPt(x, y); if ( tPt[0] === this.currentSubpath[this.currentSubpath.length - 2] && tPt[1] === this.currentSubpath[this.currentSubpath.length - 1] ) { return; } this.currentSubpath.push(tPt[0]); this.currentSubpath.push(tPt[1]); this.subpathsModified = true; } quadraticCurveTo(cpx, cpy, x, y) { if (arguments.length !== 4) throw new TypeError(); if (!isFinite(cpx) || !isFinite(cpy) || !isFinite(x) || !isFinite(y)) { return; } this._ensureStartPath(cpx, cpy); const scale = 1; // TODO: ?? const vertsLen = this.currentSubpath.length; const startPt = [this.currentSubpath[vertsLen - 2], this.currentSubpath[vertsLen - 1]]; const points = bezierQuadraticPoints( startPt, this._getTransformedPt(cpx, cpy), this._getTransformedPt(x, y), scale ); for (let i = 0; i < points.length; i++) { this.currentSubpath.push(points[i][0]); this.currentSubpath.push(points[i][1]); } this.subpathsModified = true; } bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) { if (arguments.length !== 6) throw new TypeError(); if ( !isFinite(cp1x) || !isFinite(cp1y) || !isFinite(cp2x) || !isFinite(cp2y) || !isFinite(x) || !isFinite(y) ) { return; } this._ensureStartPath(cp1x, cp1y); // TODO: ensure start path? const scale = 1; // TODO: ?? const vertsLen = this.currentSubpath.length; const startPt = [this.currentSubpath[vertsLen - 2], this.currentSubpath[vertsLen - 1]]; const points = bezierCubicPoints( startPt, this._getTransformedPt(cp1x, cp1y), this._getTransformedPt(cp2x, cp2y), this._getTransformedPt(x, y), scale ); for (let i = 0; i < points.length; i++) { this.currentSubpath.push(points[i][0]); this.currentSubpath.push(points[i][1]); } this.subpathsModified = true; } rect(x, y, w, h) { if (arguments.length !== 4) throw new TypeError(); if (!isFinite(x) || !isFinite(y) || !isFinite(w) || !isFinite(h)) { return; } this.moveTo(x, y); this.lineTo(x + w, y); this.lineTo(x + w, y + h); this.lineTo(x, y + h); this.closePath(); this.moveTo(x, y); } arc(x, y, radius, startAngle, endAngle, counterclockwise) { if (arguments.length !== 5 && arguments.length !== 6) throw new TypeError(); if ( !isFinite(x) || !isFinite(y) || !isFinite(radius) || !isFinite(startAngle) || !isFinite(endAngle) ) { return; } if (radius < 0) { throw new DOMException('Bad radius', 'IndexSizeError'); } if (radius === 0) { this.lineTo(x, y); return; } if (startAngle === endAngle) { return; } counterclockwise = counterclockwise || 0; const centerPt = [x, y]; if (counterclockwise) { const temp = startAngle; startAngle = endAngle; endAngle = temp; } if (startAngle > endAngle) { endAngle = (endAngle % (2 * Math.PI)) + Math.trunc(startAngle / (2 * Math.PI)) * 2 * Math.PI + 2 * Math.PI; } if (endAngle > startAngle + 2 * Math.PI) { endAngle = startAngle + 2 * Math.PI; } // Figure out angle increment based on the radius transformed along // the most stretched axis, assuming anisotropy const xformedOrigin = this._getTransformedPt(0, 0); const xformedVectorAxis1 = this._getTransformedPt(radius, 0); const xformedVectorAxis2 = this._getTransformedPt(0, radius); const actualRadiusAxis1 = Math.sqrt( Math.pow(xformedVectorAxis1[0] - xformedOrigin[0], 2) + Math.pow(xformedVectorAxis1[1] - xformedOrigin[1], 2) ); const actualRadiusAxis2 = Math.sqrt( Math.pow(xformedVectorAxis2[0] - xformedOrigin[0], 2) + Math.pow(xformedVectorAxis2[1] - xformedOrigin[1], 2) ); const increment = (1 / Math.max(actualRadiusAxis1, actualRadiusAxis2)) * 10.0; if (increment >= Math.abs(startAngle - endAngle)) { return; } const pathStartIdx = this.currentSubpath.length; const thetaPt = (theta) => { const arcPt = this._getTransformedPt( centerPt[0] + radius * Math.cos(theta), centerPt[1] + radius * Math.sin(theta) ); this.currentSubpath.push(arcPt[0]); this.currentSubpath.push(arcPt[1]); }; let theta = counterclockwise ? endAngle : startAngle; while (true) { thetaPt(theta); if (!counterclockwise) { theta += increment; if (theta >= endAngle) { theta = endAngle; break; } } else { theta -= increment; if (theta <= startAngle) { theta = startAngle; break; } } } thetaPt(theta); const pathEndIdx = this.currentSubpath.length; this.currentSubpath._arcs = this.currentSubpath._arcs || []; this.currentSubpath._arcs.push({ startIdx: pathStartIdx, endIdx: pathEndIdx, center: new Vector(centerPt[0], centerPt[1]), radius, }); this.subpathsModified = true; } arcTo(x1, y1, x2, y2, radius) { if (arguments.length !== 5) throw new TypeError(); if (!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2) || !isFinite(radius)) { return; } if (radius < 0) { throw new DOMException('Bad radius', 'IndexSizeError'); } this._ensureStartPath(x1, y1); this.subpathsModified = true; const s = new Vector( ...this._getUntransformedPt( this.currentSubpath[this.currentSubpath.length - 2], this.currentSubpath[this.currentSubpath.length - 1] ) ); const t0 = new Vector(x1, y1); const t1 = new Vector(x2, y2); // Check for colinearity if (s.x * (t0.y - t1.y) + t0.x * (t1.y - s.y) + t1.x * (s.y - t0.y) === 0) { this.lineTo(x1, y1); return; } // For further explanation of the geometry here - // https://math.stackexchange.com/questions/797828/calculate-center-of-circle-tangent-to-two-lines-in-space const s_t0 = s.subtract(t0); const s_t0_hat = s_t0.unit(); const t1_t0 = t1.subtract(t0); const t1_t0_hat = t1_t0.unit(); // TODO: use Vector class's angleBetween() const tangent_inner_angle = Math.acos(s_t0.dot(t1_t0) / (s_t0.length() * t1_t0.length())); // // TODO: should be possible to reduce normalizations here? const bisector = s_t0_hat.add(t1_t0_hat).divide(2).unit(); const radius_scalar = radius / Math.sin(tangent_inner_angle / 2); let center_pt = bisector.multiply(radius_scalar); let start_pt = s_t0_hat.multiply(center_pt.dot(s_t0_hat)); let end_pt = t1_t0_hat.multiply(center_pt.dot(t1_t0_hat)); // Shift center of calculations to center pt center_pt = center_pt.add(t0); start_pt = start_pt.add(t0).subtract(center_pt); end_pt = end_pt.add(t0).subtract(center_pt); const start_angle = Math.atan2(start_pt.y, start_pt.x); const end_angle = Math.atan2(end_pt.y, end_pt.x); // TODO: not sure how to choose cw/ccw here - this might require more thought const s_t1 = center_pt.subtract(t1); const t0_t1 = t0.subtract(t1); const clockwise = s_t1.cross(t0_t1).z <= 0; this.arc(center_pt.x, center_pt.y, radius, start_angle, end_angle, clockwise); } /************************************************** * Transformation methods **************************************************/ save() { if (arguments.length !== 0) throw new TypeError(); this.drawingStateStack.push(this.drawingState); this.drawingState = Object.assign({}, this.drawingState); this.drawingState.strokeDashes = this.drawingState.strokeDashes.slice(); this.drawingState.clippingPaths = this.drawingState.clippingPaths.slice(); this.drawingState.mvMatrix = glm.mat4.clone(this.drawingState.mvMatrix); // TODO: this will make gradients/patterns un-live, is that ok? this.drawingState.fillStyle = this._cloneStyle(this.drawingState.fillStyle); this.drawingState.strokeStyle = this._cloneStyle(this.drawingState.strokeStyle); } restore() { if (arguments.length !== 0) throw new TypeError(); if (this.drawingStateStack.length > 0) { this.drawingState = this.drawingStateStack.pop(); this._updateMatrixUniforms(); this._updateStrokeExtruderState(); this._updateClippingRegion(); } } scale(x, y) { if (arguments.length !== 2) throw new TypeError(); for (let argIdx = 0; argIdx < arguments.length; argIdx++) { if (!isFinite(arguments[argIdx])) return; } glm.mat4.scale(this.drawingState.mvMatrix, this.drawingState.mvMatrix, [x, y, 1.0]); this._updateMatrixUniforms(); } rotate(angle) { if (arguments.length !== 1) throw new TypeError(); for (let argIdx = 0; argIdx < arguments.length; argIdx++) { if (!isFinite(arguments[argIdx])) return; } glm.mat4.rotateZ(this.drawingState.mvMatrix, this.drawingState.mvMatrix, angle); this._updateMatrixUniforms(); } translate(x, y) { if (arguments.length !== 2) throw new TypeError(); for (let argIdx = 0; argIdx < arguments.length; argIdx++) { if (!isFinite(arguments[argIdx])) return; } glm.mat4.translate(this.drawingState.mvMatrix, this.drawingState.mvMatrix, [x, y, 0.0]); this._updateMatrixUniforms(); } transform(a, b, c, d, e, f) { if (arguments.length !== 6) throw new TypeError(); for (let argIdx = 0; argIdx < arguments.length; argIdx++) { if (!isFinite(arguments[argIdx])) return; } glm.mat4.multiply( this.drawingState.mvMatrix, this.drawingState.mvMatrix, glm.mat4.fromValues(a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, e, f, 0, 1) ); this._updateMatrixUniforms(); } setTransform(a, b, c, d, e, f) { if (arguments.length !== 6) throw new TypeError(); for (let argIdx = 0; argIdx < arguments.length; argIdx++) { if (!isFinite(arguments[argIdx])) return; } glm.mat4.identity(this.drawingState.mvMatrix); this.transform(a, b, c, d, e, f); } _getTransformedPt(x, y) { // TODO: creating a new vec3 every time seems potentially inefficient const tPt = glm.vec3.fromValues(x, y, 0.0); glm.vec3.transformMat4(tPt, tPt, this.drawingState.mvMatrix); return [tPt[0], tPt[1]]; } _getUntransformedPt(x, y) { // TODO: creating a new vec3 every time seems potentially inefficient const tPt = glm.vec3.fromValues(x, y, 0.0); glm.vec3.transformMat4(tPt, tPt, this._getInvMvMatrix()); return [tPt[0], tPt[1]]; } /************************************************** * Style methods **************************************************/ set globalAlpha(val) { this.drawingState.globalAlpha = val; } get globalAlpha() { return this.drawingState.globalAlpha; } set shadowColor(val) { throw new SyntaxError('Property not supported'); } get shadowColor() { throw new SyntaxError('Property not supported'); } set shadowBlur(val) { throw new SyntaxError('Property not supported'); } get shadowBlur() { throw new SyntaxError('Property not supported'); } set shadowOffsetX(val) { throw new SyntaxError('Property not supported'); } get shadowOffsetX() { throw new SyntaxError('Property not supported'); } set shadowOffsetY(val) { throw new SyntaxError('Property not supported'); } get shadowOffsetY() { throw new SyntaxError('Property not supported'); } set globalCompositeOperation(val) { throw new SyntaxError('Property not supported'); } get globalCompositeOperation() { throw new SyntaxError('Property not supported'); } // TODO: some day, use an off-screen rendering target to support // these -- // set globalCompositeOperation(val) { // let gl = this.gl; // if (val == 'source-atop') { // } else if (val == 'source-in') { // } else if (val == 'source-out') { // } else if (val == 'source-over') { // } else if (val == 'destination-atop') { // } else if (val == 'destination-in') { // } else if (val == 'des