| <!DOCTYPE html> |
| <title>Mesh2D Demo</title> |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| |
| <style> |
| canvas { |
| width: 1024px; |
| height: 1024px; |
| background-color: #ccc; |
| display: none; |
| } |
| |
| .root { |
| display: flex; |
| } |
| |
| .controls { |
| display: flex; |
| } |
| .controls-left { width: 50%; } |
| .controls-right { width: 50%; } |
| .controls-right select { width: 100%; } |
| |
| #loader { |
| width: 1024px; |
| height: 1024px; |
| display: flex; |
| flex-direction: column; |
| justify-content: center; |
| align-items: center; |
| background-color: #f1f2f3; |
| font: bold 2em monospace; |
| color: #85a2b6; |
| } |
| </style> |
| |
| <div class="root"> |
| <div id="loader"> |
| <img src="BeanEater-1s-200px.gif"> |
| <div>Fetching <a href="https://skia.org/docs/user/modules/canvaskit/">CanvasKit</a>...</div> |
| </div> |
| |
| <div id="canvas_wrapper"> |
| <canvas id="canvas2d" width="1024" height="1024"></canvas> |
| <canvas id="canvas3d" width="1024" height="1024"></canvas> |
| </div> |
| |
| <div class="controls"> |
| <div class="controls-left"> |
| <div>Show mesh</div> |
| <div>Level of detail</div> |
| <div>Animator</div> |
| <div>Renderer</div> |
| </div> |
| <div class="controls-right"> |
| <div> |
| <input type="checkbox" id="show_mesh"/> |
| </div> |
| <div> |
| <select id="lod"> |
| <option value="4">4x4</option> |
| <option value="8" selected>8x8</option> |
| <option value="16">16x16</option> |
| <option value="32">32x32</option> |
| <option value="64">64x64</option> |
| <option value="128">128x128</option> |
| <option value="255">255x255</option> |
| </select> |
| </div> |
| <div> |
| <select id="animator"> |
| <option value="">Manual</option> |
| <option value="squircleAnimator">Squircle</option> |
| <option value="twirlAnimator">Twirl</option> |
| <option value="wiggleAnimator">Wiggle</option> |
| <option value="cylinderAnimator" selected>Cylinder</option> |
| </select> |
| </div> |
| <div> |
| <select id="renderer" disabled> |
| <option value="ckRenderer" selected>CanvasKit (polyfill)</option> |
| <option value="nativeRenderer">Canvas2D (native)</option> |
| </select> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <script type="text/javascript" src="canvaskit.js"></script> |
| |
| <script type="text/javascript"> |
| class MeshData { |
| constructor(size, renderer) { |
| const vertex_count = size*size; |
| |
| // 2 floats per point |
| this.verts = new Float32Array(vertex_count*2); |
| this.animated_verts = new Float32Array(vertex_count*2); |
| this.uvs = new Float32Array(vertex_count*2); |
| |
| let i = 0; |
| for (let y = 0; y < size; ++y) { |
| for (let x = 0; x < size; ++x) { |
| // To keep things simple, all vertices are normalized. |
| this.verts[i + 0] = this.uvs[i + 0] = x / (size - 1); |
| this.verts[i + 1] = this.uvs[i + 1] = y / (size - 1); |
| |
| i += 2; |
| } |
| } |
| |
| // 2 triangles per LOD square, 3 indices per triangle |
| this.indices = new Uint16Array((size - 1)*(size - 1)*6); |
| i = 0; |
| for (let y = 0; y < size - 1; ++y) { |
| for (let x = 0; x < size - 1; ++x) { |
| const vidx0 = x + y*size; |
| const vidx1 = vidx0 + size; |
| |
| this.indices[i++] = vidx0; |
| this.indices[i++] = vidx0 + 1; |
| this.indices[i++] = vidx1 + 1; |
| |
| this.indices[i++] = vidx0; |
| this.indices[i++] = vidx1; |
| this.indices[i++] = vidx1 + 1; |
| } |
| } |
| |
| // These can be cached upfront (constant during animation). |
| this.uvBuffer = renderer.makeUVBuffer(this.uvs); |
| this.indexBuffer = renderer.makeIndexBuffer(this.indices); |
| } |
| |
| animate(animator) { |
| function bezier(t, p0, p1, p2, p3){ |
| return (1 - t)*(1 - t)*(1 - t)*p0 + |
| 3*(1 - t)*(1 - t)*t*p1 + |
| 3*(1 - t)*t*t*p2 + |
| t*t*t*p3; |
| } |
| |
| // Tuned for non-linear transition. |
| function ease(t) { return bezier(t, 0, 0.4, 1, 1); } |
| |
| if (!animator) { |
| return; |
| } |
| |
| const ms = Date.now() - timeBase; |
| const t = Math.abs((ms / 1000) % 2 - 1); |
| |
| animator(this.verts, this.animated_verts, t); |
| } |
| |
| generateTriangles(func) { |
| for (let i = 0; i < this.indices.length; i += 3) { |
| const i0 = 2*this.indices[i + 0]; |
| const i1 = 2*this.indices[i + 1]; |
| const i2 = 2*this.indices[i + 2]; |
| |
| func(this.animated_verts[i0 + 0], this.animated_verts[i0 + 1], |
| this.animated_verts[i1 + 0], this.animated_verts[i1 + 1], |
| this.animated_verts[i2 + 0], this.animated_verts[i2 + 1]); |
| } |
| } |
| } |
| |
| class PatchControls { |
| constructor() { |
| this.controls = [ |
| { pos: [ 0.00, 0.33], color: '#0ff', deps: [] }, |
| { pos: [ 0.00, 0.00], color: '#0f0', deps: [0, 2] }, |
| { pos: [ 0.33, 0.00], color: '#0ff', deps: [] }, |
| |
| { pos: [ 0.66, 0.00], color: '#0ff', deps: [] }, |
| { pos: [ 1.00, 0.00], color: '#0f0', deps: [3, 5] }, |
| { pos: [ 1.00, 0.33], color: '#0ff', deps: [] }, |
| |
| { pos: [ 1.00, 0.66], color: '#0ff', deps: [] }, |
| { pos: [ 1.00, 1.00], color: '#0f0', deps: [6, 8] }, |
| { pos: [ 0.66, 1.00], color: '#0ff', deps: [] }, |
| |
| { pos: [ 0.33, 1.00], color: '#0ff', deps: [] }, |
| { pos: [ 0.00, 1.00], color: '#0f0', deps: [9, 11] }, |
| { pos: [ 0.00, 0.66], color: '#0ff', deps: [] }, |
| ]; |
| |
| this.radius = 0.01; |
| this.drag_target = null; |
| } |
| |
| mapMouse(ev) { |
| const w = canvas2d.width, |
| h = canvas2d.height; |
| return [ |
| (ev.offsetX - w*(1 - meshScale)*0.5)/(w*meshScale), |
| (ev.offsetY - h*(1 - meshScale)*0.5)/(h*meshScale), |
| ]; |
| } |
| |
| onMouseDown(ev) { |
| const mouse_pos = this.mapMouse(ev); |
| |
| for (let i = this.controls.length - 1; i >= 0; --i) { |
| const dx = this.controls[i].pos[0] - mouse_pos[0], |
| dy = this.controls[i].pos[1] - mouse_pos[1]; |
| |
| if (dx*dx + dy*dy <= this.radius*this.radius) { |
| this.drag_target = this.controls[i]; |
| this.drag_offset = [dx, dy]; |
| break; |
| } |
| } |
| } |
| |
| onMouseMove(ev) { |
| if (!this.drag_target) return; |
| |
| const mouse_pos = this.mapMouse(ev), |
| dx = mouse_pos[0] + this.drag_offset[0] - this.drag_target.pos[0], |
| dy = mouse_pos[1] + this.drag_offset[1] - this.drag_target.pos[1]; |
| |
| this.drag_target.pos = [ this.drag_target.pos[0] + dx, this.drag_target.pos[1] + dy ]; |
| |
| for (let dep_index of this.drag_target.deps) { |
| const dep = this.controls[dep_index]; |
| dep.pos = [ dep.pos[0] + dx, dep.pos[1] + dy ]; |
| } |
| |
| this.updateVerts(); |
| } |
| |
| onMouseUp(ev) { |
| this.drag_target = null; |
| } |
| |
| updateVerts() { |
| this.samplePatch(parseInt(lodSelectUI.value), meshData.animated_verts); |
| } |
| |
| drawUI(line_func, circle_func) { |
| for (let i = 0; i < this.controls.length; i += 3) { |
| const c0 = this.controls[i + 0], |
| c1 = this.controls[i + 1], |
| c2 = this.controls[i + 2]; |
| |
| line_func(c0.pos, c1.pos, '#f00'); |
| line_func(c1.pos, c2.pos, '#f00'); |
| circle_func(c0.pos, this.radius, c0.color); |
| circle_func(c1.pos, this.radius, c1.color); |
| circle_func(c2.pos, this.radius, c2.color); |
| } |
| } |
| |
| // Based on https://github.com/google/skia/blob/de56f293eb41d65786b9e6224fdf9a4702b30f51/src/utils/SkPatchUtils.cpp#L84 |
| sampleCubic(cind, lod) { |
| const divisions = lod - 1, |
| h = 1/divisions, |
| h2 = h*h, |
| h3 = h*h2, |
| pts = [ |
| this.controls[cind[0]].pos, |
| this.controls[cind[1]].pos, |
| this.controls[cind[2]].pos, |
| this.controls[cind[3]].pos, |
| ], |
| coeffs = [ |
| [ |
| pts[3][0] + 3*(pts[1][0] - pts[2][0]) - pts[0][0], |
| pts[3][1] + 3*(pts[1][1] - pts[2][1]) - pts[0][1], |
| ], |
| [ |
| 3*(pts[2][0] - 2*pts[1][0] + pts[0][0]), |
| 3*(pts[2][1] - 2*pts[1][1] + pts[0][1]), |
| ], |
| [ |
| 3*(pts[1][0] - pts[0][0]), |
| 3*(pts[1][1] - pts[0][1]), |
| ], |
| pts[0], |
| ], |
| fwDiff3 = [ |
| 6*h3*coeffs[0][0], |
| 6*h3*coeffs[0][1], |
| ]; |
| |
| let fwDiff = [ |
| coeffs[3], |
| [ |
| h3*coeffs[0][0] + h2*coeffs[1][0] + h*coeffs[2][0], |
| h3*coeffs[0][1] + h2*coeffs[1][1] + h*coeffs[2][1], |
| ], |
| [ |
| fwDiff3[0] + 2*h2*coeffs[1][0], |
| fwDiff3[1] + 2*h2*coeffs[1][1], |
| ], |
| fwDiff3, |
| ]; |
| |
| let verts = []; |
| |
| for (let i = 0; i <= divisions; ++i) { |
| verts.push(fwDiff[0]); |
| fwDiff[0] = [ fwDiff[0][0] + fwDiff[1][0], fwDiff[0][1] + fwDiff[1][1] ]; |
| fwDiff[1] = [ fwDiff[1][0] + fwDiff[2][0], fwDiff[1][1] + fwDiff[2][1] ]; |
| fwDiff[2] = [ fwDiff[2][0] + fwDiff[3][0], fwDiff[2][1] + fwDiff[3][1] ]; |
| } |
| |
| return verts; |
| } |
| |
| // Based on https://github.com/google/skia/blob/de56f293eb41d65786b9e6224fdf9a4702b30f51/src/utils/SkPatchUtils.cpp#L256 |
| samplePatch(lod, verts) { |
| const top_verts = this.sampleCubic([ 1, 2, 3, 4 ], lod), |
| right_verts = this.sampleCubic([ 4, 5, 6, 7 ], lod), |
| bottom_verts = this.sampleCubic([ 10, 9, 8, 7 ], lod), |
| left_verts = this.sampleCubic([ 1, 0, 11, 10 ], lod); |
| |
| let i = 0; |
| for (let y = 0; y < lod; ++y) { |
| const v = y/(lod - 1), |
| left = left_verts[y], |
| right = right_verts[y]; |
| |
| for (let x = 0; x < lod; ++x) { |
| const u = x/(lod - 1), |
| top = top_verts[x], |
| bottom = bottom_verts[x], |
| |
| s0 = [ |
| (1 - v)*top[0] + v*bottom[0], |
| (1 - v)*top[1] + v*bottom[1], |
| ], |
| s1 = [ |
| (1 - u)*left[0] + u*right[0], |
| (1 - u)*left[1] + u*right[1], |
| ], |
| s2 = [ |
| (1 - v)*((1 - u)*this.controls[ 1].pos[0] + u*this.controls[4].pos[0]) + |
| v*((1 - u)*this.controls[10].pos[0] + u*this.controls[7].pos[0]), |
| (1 - v)*((1 - u)*this.controls[ 1].pos[1] + u*this.controls[4].pos[1]) + |
| v*((1 - u)*this.controls[10].pos[1] + u*this.controls[7].pos[1]), |
| ]; |
| |
| verts[i++] = s0[0] + s1[0] - s2[0]; |
| verts[i++] = s0[1] + s1[1] - s2[1]; |
| } |
| } |
| } |
| } |
| |
| class CKRenderer { |
| constructor(ck, img, canvasElement) { |
| this.ck = ck; |
| this.surface = ck.MakeCanvasSurface(canvasElement); |
| this.meshPaint = new ck.Paint(); |
| |
| // UVs are normalized, so we scale the image shader down to 1x1. |
| const skimg = ck.MakeImageFromCanvasImageSource(img); |
| const localMatrix = [1/skimg.width(), 0, 0, |
| 0, 1/skimg.height(), 0, |
| 0, 0, 1]; |
| |
| this.meshPaint.setShader(skimg.makeShaderOptions(ck.TileMode.Decal, |
| ck.TileMode.Decal, |
| ck.FilterMode.Linear, |
| ck.MipmapMode.None, |
| localMatrix)); |
| |
| this.gridPaint = new ck.Paint(); |
| this.gridPaint.setColor(ck.BLUE); |
| this.gridPaint.setAntiAlias(true); |
| this.gridPaint.setStyle(ck.PaintStyle.Stroke); |
| |
| this.controlsPaint = new ck.Paint(); |
| this.controlsPaint.setAntiAlias(true); |
| this.controlsPaint.setStyle(ck.PaintStyle.Fill); |
| } |
| |
| // Unlike the native renderer, CK drawVertices() takes typed arrays directly - so |
| // we don't need to allocate separate buffers. |
| makeVertexBuffer(buf) { return buf; } |
| makeUVBuffer (buf) { return buf; } |
| makeIndexBuffer (buf) { return buf; } |
| |
| meshPath(mesh) { |
| // 4 commands per triangle, 3 floats per cmd |
| const cmds = new Float32Array(mesh.indices.length*12); |
| let ci = 0; |
| mesh.generateTriangles((x0, y0, x1, y1, x2, y2) => { |
| cmds[ci++] = this.ck.MOVE_VERB; cmds[ci++] = x0; cmds[ci++] = y0; |
| cmds[ci++] = this.ck.LINE_VERB; cmds[ci++] = x1; cmds[ci++] = y1; |
| cmds[ci++] = this.ck.LINE_VERB; cmds[ci++] = x2; cmds[ci++] = y2; |
| cmds[ci++] = this.ck.LINE_VERB; cmds[ci++] = x0; cmds[ci++] = y0; |
| }); |
| return this.ck.Path.MakeFromCmds(cmds); |
| } |
| |
| drawMesh(mesh, ctrls) { |
| const vertices = this.ck.MakeVertices(this.ck.VertexMode.Triangles, |
| this.makeVertexBuffer(mesh.animated_verts), |
| mesh.uvBuffer, null, mesh.indexBuffer, false); |
| |
| const canvas = this.surface.getCanvas(); |
| const w = this.surface.width(), |
| h = this.surface.height(); |
| |
| canvas.save(); |
| canvas.translate(w*(1-meshScale)*0.5, h*(1-meshScale)*0.5); |
| canvas.scale(w*meshScale, h*meshScale); |
| |
| canvas.drawVertices(vertices, this.ck.BlendMode.Dst, this.meshPaint); |
| |
| if (showMeshUI.checked) { |
| canvas.drawPath(this.meshPath(mesh), this.gridPaint); |
| } |
| |
| ctrls?.drawUI( |
| (p0, p1, color) => { |
| this.controlsPaint.setColor(this.ck.parseColorString(color)); |
| canvas.drawLine(p0[0], p0[1], p1[0], p1[1], this.controlsPaint); |
| }, |
| (c, r, color) => { |
| this.controlsPaint.setColor(this.ck.parseColorString(color)); |
| canvas.drawCircle(c[0], c[1], r, this.controlsPaint); |
| } |
| ); |
| canvas.restore(); |
| this.surface.flush(); |
| } |
| } |
| |
| class NativeRenderer { |
| constructor(img, canvasElement) { |
| this.img = img; |
| this.ctx = canvasElement.getContext("2d"); |
| } |
| |
| // New Mesh2D API: https://github.com/fserb/canvas2D/blob/master/spec/mesh2d.md#mesh2d-api |
| makeVertexBuffer(buf) { return new Mesh2DVertexBuffer(buf); } |
| makeUVBuffer(buf) { |
| // Temp workaround for lack of uv normalization support in the current prototype. |
| for (let i = 0; i < buf.length; i+= 2) { |
| buf[i + 0] *= this.img.width; |
| buf[i + 1] *= this.img.height; |
| } |
| return new Mesh2DVertexBuffer(buf); |
| } |
| makeIndexBuffer(buf) { return new Mesh2DIndexBuffer(buf); } |
| |
| meshPath(mesh) { |
| const path = new Path2D(); |
| mesh.generateTriangles((x0, y0, x1, y1, x2, y2) => { |
| path.moveTo(x0, y0); |
| path.lineTo(x1, y1); |
| path.lineTo(x2, y2); |
| path.lineTo(x0, y0); |
| }); |
| return path; |
| } |
| |
| drawMesh(mesh, ctrls) { |
| const vbuf = new Mesh2DVertexBuffer(mesh.animated_verts); |
| const w = canvas2d.width, |
| h = canvas2d.height; |
| |
| this.ctx.clearRect(0, 0, canvas2d.width, canvas2d.height); |
| this.ctx.save(); |
| this.ctx.translate(w*(1-meshScale)*0.5, h*(1-meshScale)*0.5); |
| this.ctx.scale(w*meshScale, h*meshScale); |
| |
| this.ctx.drawMesh(vbuf, mesh.uvBuffer, mesh.indexBuffer, this.img); |
| |
| if (showMeshUI.checked) { |
| this.ctx.strokeStyle = "blue"; |
| this.ctx.lineWidth = 0.001; |
| this.ctx.stroke(this.meshPath(mesh)); |
| } |
| |
| ctrls?.drawUI( |
| (p0, p1, color) => { |
| this.ctx.lineWidth = 0.001; |
| this.ctx.strokeStyle = color; |
| this.ctx.beginPath(); |
| this.ctx.moveTo(p0[0], p0[1]); |
| this.ctx.lineTo(p1[0], p1[1]); |
| this.ctx.stroke(); |
| }, |
| (c, r, color) => { |
| this.ctx.fillStyle = color; |
| this.ctx.beginPath(); |
| this.ctx.arc(c[0], c[1], r, 0, 2*Math.PI); |
| this.ctx.fill(); |
| } |
| ); |
| this.ctx.restore(); |
| } |
| } |
| |
| function squircleAnimator(verts, animated_verts, t) { |
| function lerp(a, b, t) { return a + t*(b - a); } |
| |
| for (let i = 0; i < verts.length; i += 2) { |
| const uvx = verts[i + 0] - 0.5, |
| uvy = verts[i + 1] - 0.5, |
| d = Math.sqrt(uvx*uvx + uvy*uvy)*0.5/Math.max(Math.abs(uvx), Math.abs(uvy)), |
| s = d > 0 ? lerp(1, (0.5/ d), t) : 1; |
| animated_verts[i + 0] = uvx*s + 0.5; |
| animated_verts[i + 1] = uvy*s + 0.5; |
| } |
| } |
| |
| function twirlAnimator(verts, animated_verts, t) { |
| const kMaxRotate = Math.PI*4; |
| |
| for (let i = 0; i < verts.length; i += 2) { |
| const uvx = verts[i + 0] - 0.5, |
| uvy = verts[i + 1] - 0.5, |
| r = Math.sqrt(uvx*uvx + uvy*uvy), |
| a = kMaxRotate * r * t; |
| animated_verts[i + 0] = uvx*Math.cos(a) - uvy*Math.sin(a) + 0.5; |
| animated_verts[i + 1] = uvy*Math.cos(a) + uvx*Math.sin(a) + 0.5; |
| } |
| } |
| |
| function wiggleAnimator(verts, animated_verts, t) { |
| const radius = t*0.2/(Math.sqrt(verts.length/2) - 1); |
| |
| for (let i = 0; i < verts.length; i += 2) { |
| const phase = i*Math.PI*0.1505; |
| const angle = phase + t*Math.PI*2; |
| animated_verts[i + 0] = verts[i + 0] + radius*Math.cos(angle); |
| animated_verts[i + 1] = verts[i + 1] + radius*Math.sin(angle); |
| } |
| } |
| |
| function cylinderAnimator(verts, animated_verts, t) { |
| const kCylRadius = .2; |
| const cyl_pos = t; |
| |
| for (let i = 0; i < verts.length; i += 2) { |
| const uvx = verts[i + 0], |
| uvy = verts[i + 1]; |
| |
| if (uvx <= cyl_pos) { |
| animated_verts[i + 0] = uvx; |
| animated_verts[i + 1] = uvy; |
| continue; |
| } |
| |
| const arc_len = uvx - cyl_pos, |
| arc_ang = arc_len/kCylRadius; |
| |
| animated_verts[i + 0] = cyl_pos + Math.sin(arc_ang)*kCylRadius; |
| animated_verts[i + 1] = uvy; |
| } |
| } |
| |
| function drawFrame() { |
| meshData.animate(animator); |
| currentRenderer.drawMesh(meshData, patchControls); |
| requestAnimationFrame(drawFrame); |
| } |
| |
| function switchRenderer(renderer) { |
| currentRenderer = renderer; |
| meshData = new MeshData(parseInt(lodSelectUI.value), currentRenderer); |
| |
| const showCanvas = renderer == ckRenderer ? canvas3d : canvas2d; |
| const hideCanvas = renderer == ckRenderer ? canvas2d : canvas3d; |
| showCanvas.style.display = 'block'; |
| hideCanvas.style.display = 'none'; |
| |
| patchControls?.updateVerts(); |
| } |
| |
| const canvas2d = document.getElementById("canvas2d"); |
| const canvas3d = document.getElementById("canvas3d"); |
| const hasMesh2DAPI = 'drawMesh' in CanvasRenderingContext2D.prototype; |
| const showMeshUI = document.getElementById("show_mesh"); |
| const lodSelectUI = document.getElementById("lod"); |
| const animatorSelectUI = document.getElementById("animator"); |
| const rendererSelectUI = document.getElementById("renderer"); |
| |
| const meshScale = 0.75; |
| |
| const loadCK = CanvasKitInit({ locateFile: (file) => 'https://demos.skia.org/demo/mesh2d/' + file }); |
| const loadImage = new Promise(resolve => { |
| const image = new Image(); |
| image.addEventListener('load', () => { resolve(image); }); |
| image.src = 'baby_tux.png'; |
| }); |
| |
| var ckRenderer; |
| var nativeRenderer; |
| var currentRenderer; |
| var meshData; |
| var image; |
| |
| const timeBase = Date.now(); |
| |
| var animator = window[animatorSelectUI.value]; |
| var patchControls = animator ? null : new PatchControls(); |
| |
| Promise.all([loadCK, loadImage]).then(([ck, img]) => { |
| ckRenderer = new CKRenderer(ck, img, canvas3d); |
| nativeRenderer = 'drawMesh' in CanvasRenderingContext2D.prototype |
| ? new NativeRenderer(img, canvas2d) |
| : null; |
| |
| rendererSelectUI.disabled = !nativeRenderer; |
| rendererSelectUI.value = nativeRenderer ? "nativeRenderer" : "ckRenderer"; |
| |
| document.getElementById('loader').style.display = 'none'; |
| switchRenderer(nativeRenderer ? nativeRenderer : ckRenderer); |
| |
| requestAnimationFrame(drawFrame); |
| }); |
| |
| lodSelectUI.onchange = () => { switchRenderer(currentRenderer); } |
| rendererSelectUI.onchange = () => { switchRenderer(window[rendererSelectUI.value]); } |
| animatorSelectUI.onchange = () => { |
| animator = window[animatorSelectUI.value]; |
| patchControls = animator ? null : new PatchControls(); |
| patchControls?.updateVerts(); |
| } |
| |
| const cwrapper = document.getElementById('canvas_wrapper'); |
| cwrapper.onmousedown = (ev) => { patchControls?.onMouseDown(ev); } |
| cwrapper.onmousemove = (ev) => { patchControls?.onMouseMove(ev); } |
| cwrapper.onmouseup = (ev) => { patchControls?.onMouseUp(ev); } |
| </script> |