Bläddra i källkod

add controls to create image

Alexander Rose 5 år sedan
förälder
incheckning
4801435d72

+ 49 - 8
src/mol-canvas3d/util.ts

@@ -4,16 +4,57 @@
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-/** resize canvas to container element */
+/** Set canvas size taking `devicePixelRatio` into account */
+export function setCanvasSize(canvas: HTMLCanvasElement, width: number, height: number) {
+    canvas.width = window.devicePixelRatio * width
+    canvas.height = window.devicePixelRatio * height
+    Object.assign(canvas.style, { width: `${width}px`, height: `${height}px` })
+}
+
+/** Resize canvas to container element taking `devicePixelRatio` into account */
 export function resizeCanvas (canvas: HTMLCanvasElement, container: Element) {
-    let w = window.innerWidth
-    let h = window.innerHeight
+    let width = window.innerWidth
+    let height = window.innerHeight
     if (container !== document.body) {
         let bounds = container.getBoundingClientRect()
-        w = bounds.right - bounds.left
-        h = bounds.bottom - bounds.top
+        width = bounds.right - bounds.left
+        height = bounds.bottom - bounds.top
+    }
+    setCanvasSize(canvas, width, height)
+}
+
+function _canvasToBlob(canvas: HTMLCanvasElement, callback: BlobCallback, type?: string, quality?: any) {
+    const bin = atob(canvas.toDataURL(type, quality).split(',')[1])
+    const len = bin.length
+    const len32 = len >> 2
+    const a8 = new Uint8Array(len)
+    const a32 = new Uint32Array( a8.buffer, 0, len32 )
+
+    let j = 0
+    for (let i = 0; i < len32; ++i) {
+        a32[i] = bin.charCodeAt(j++) |
+            bin.charCodeAt(j++) << 8 |
+            bin.charCodeAt(j++) << 16 |
+            bin.charCodeAt(j++) << 24
     }
-    canvas.width = window.devicePixelRatio * w
-    canvas.height = window.devicePixelRatio * h
-    Object.assign(canvas.style, { width: `${w}px`, height: `${h}px` })
+
+    let tailLength = len & 3;
+    while (tailLength--) a8[j] = bin.charCodeAt(j++)
+
+    callback(new Blob([a8], { type: type || 'image/png' }));
+}
+
+export async function canvasToBlob(canvas: HTMLCanvasElement, type?: string, quality?: any): Promise<Blob> {
+    return new Promise((resolve, reject) => {
+        const callback = (blob: Blob | null) => {
+            if (blob) resolve(blob)
+            else reject('no blob returned')
+        }
+
+        if (!HTMLCanvasElement.prototype.toBlob) {
+            _canvasToBlob(canvas, callback, type, quality)
+        } else {
+            canvas.toBlob(callback, type, quality)
+        }
+    })
 }

+ 3 - 0
src/mol-gl/webgl/context.ts

@@ -190,6 +190,7 @@ export interface WebGLContext {
     readonly framebufferCache: FramebufferCache
 
     readonly maxTextureSize: number
+    readonly maxRenderbufferSize: number
     readonly maxDrawBuffers: number
 
     unbindFramebuffer: () => void
@@ -212,6 +213,7 @@ export function createContext(gl: GLRenderingContext): WebGLContext {
 
     const parameters = {
         maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
+        maxRenderbufferSize: gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) as number,
         maxDrawBuffers: isWebGL2(gl) ? gl.getParameter(gl.MAX_DRAW_BUFFERS) as number : 0,
         maxVertexTextureImageUnits: gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS) as number,
     }
@@ -274,6 +276,7 @@ export function createContext(gl: GLRenderingContext): WebGLContext {
         framebufferCache,
 
         get maxTextureSize () { return parameters.maxTextureSize },
+        get maxRenderbufferSize () { return parameters.maxRenderbufferSize },
         get maxDrawBuffers () { return parameters.maxDrawBuffers },
 
         unbindFramebuffer: () => unbindFramebuffer(gl),

+ 21 - 0
src/mol-plugin/skin/base/components/controls.scss

@@ -362,4 +362,25 @@
             margin: 0 ($control-spacing / 2);
         }
     }
+}
+
+.msp-image-preview {
+    position: relative;
+    background: $default-background;
+    margin-top: 1px;
+    display: flex;
+    justify-content: center;
+
+    > canvas {
+        max-height: 200px;
+        border-width: 0px 1px 0px 1px;
+        border-style: solid;
+        border-color: $border-color;
+
+        background-color: $default-background;
+        background-image: linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey),
+        linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey);
+        background-size: 20px 20px;
+        background-position: 0 0, 10px 10px;
+    }
 }

+ 204 - 0
src/mol-plugin/ui/image.tsx

@@ -0,0 +1,204 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import * as React from 'react';
+import { CollapsableControls, CollapsableState } from './base';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { ParameterControls } from './controls/parameters';
+import { ImagePass } from '../../mol-canvas3d/passes/image';
+import { download } from '../../mol-util/download';
+import { setCanvasSize, canvasToBlob } from '../../mol-canvas3d/util';
+import { Task } from '../../mol-task';
+
+interface ImageControlsState extends CollapsableState {
+    showPreview: boolean
+
+    size: 'canvas' | 'custom'
+    width: number
+    height: number
+}
+
+const maxWidthUi = 260
+const maxHeightUi = 180
+
+export class ImageControls<P, S extends ImageControlsState> extends CollapsableControls<P, S> {
+    private canvasRef = React.createRef<HTMLCanvasElement>()
+
+    private canvas: HTMLCanvasElement
+    private canvasContext: CanvasRenderingContext2D
+
+    private imagePass: ImagePass
+
+    constructor(props: P, context?: any) {
+        super(props, context)
+
+        this.subscribe(this.plugin.events.canvas3d.initialized, () => this.forceUpdate())
+    }
+
+    private getSize() {
+        return this.state.size === 'canvas' ? {
+            width: this.plugin.canvas3d.webgl.gl.drawingBufferWidth,
+            height: this.plugin.canvas3d.webgl.gl.drawingBufferHeight
+        } : {
+            width: this.state.width,
+            height: this.state.height
+        }
+    }
+
+    private preview = () => {
+        const { width, height } = this.getSize()
+        if (width <= 0 || height <= 0) return
+
+        let w: number, h: number
+        const aH = maxHeightUi / height
+        const aW = maxWidthUi / width
+        if (aH < aW) {
+            h = Math.round(Math.min(maxHeightUi, height))
+            w = Math.round(width * (h / height))
+        } else {
+            w = Math.round(Math.min(maxWidthUi, width))
+            h = Math.round(height * (w / width))
+        }
+        setCanvasSize(this.canvas, w, h)
+        const { pixelRatio } = this.plugin.canvas3d.webgl
+        const imageData = this.imagePass.getImageData(w * pixelRatio, h * pixelRatio)
+        this.canvasContext.putImageData(imageData, 0, 0)
+    }
+
+    private downloadTask = () => {
+        return Task.create('Download Image', async ctx => {
+            const { width, height } = this.getSize()
+            if (width <= 0 || height <= 0) return
+
+            await ctx.update('Rendering image...')
+            const imageData = this.imagePass.getImageData(width, height)
+
+            await ctx.update('Encoding image...')
+            const canvas = document.createElement('canvas')
+            canvas.width = imageData.width
+            canvas.height = imageData.height
+            const canvasCtx = canvas.getContext('2d')
+            if (!canvasCtx) throw new Error('Could not create canvas 2d context')
+            canvasCtx.putImageData(imageData, 0, 0)
+
+            await ctx.update('Downloading image...')
+            const blob = await canvasToBlob(canvas)
+            download(blob, 'molstar-image')
+        })
+    }
+
+    private download = () => {
+        this.plugin.runTask(this.downloadTask())
+    }
+
+    private syncCanvas() {
+        if (!this.canvasRef.current) return
+        if (this.canvasRef.current === this.canvas) return
+
+        this.canvas = this.canvasRef.current
+        const ctx = this.canvas.getContext('2d')
+        if (!ctx) throw new Error('Could not get canvas 2d context')
+        this.canvasContext = ctx
+    }
+
+    private handlePreview() {
+        if (this.state.showPreview) {
+            this.syncCanvas()
+            this.preview()
+        }
+    }
+
+    componentDidUpdate() {
+        this.handlePreview()
+    }
+
+    componentDidMount() {
+        this.imagePass = this.plugin.canvas3d.getImagePass()
+        this.imagePass.setProps({
+            multiSample: { mode: 'on', sampleLevel: 2 },
+            postprocessing: this.plugin.canvas3d.props.postprocessing
+        })
+
+        this.handlePreview()
+
+        this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => {
+            this.imagePass.setProps({
+                multiSample: { mode: 'on', sampleLevel: 2 },
+                postprocessing: this.plugin.canvas3d.props.postprocessing
+            })
+            this.handlePreview()
+        })
+
+        this.subscribe(this.plugin.canvas3d.didDraw, () => this.handlePreview())
+    }
+
+    private togglePreview = () => this.setState({ showPreview: !this.state.showPreview })
+
+    private setProps = (p: { param: PD.Base<any>, name: string, value: any }) => {
+        if (p.name === 'size') {
+            if (p.value.name === 'custom') {
+                this.setState({ size: p.value.name, width: p.value.params.width, height: p.value.params.height })
+            } else {
+                this.setState({ size: p.value.name })
+            }
+        }
+    }
+
+    private get params () {
+        const max = Math.min(this.plugin.canvas3d ? this.plugin.canvas3d.webgl.maxRenderbufferSize : 4096, 8192)
+        const { width, height } = this.defaultState()
+        return {
+            size: PD.MappedStatic('custom', {
+                canvas: PD.Group({}),
+                custom: PD.Group({
+                    width: PD.Numeric(width, { min: 1, max, step: 1 }),
+                    height: PD.Numeric(height, { min: 1, max, step: 1 }),
+                }, { isFlat: true })
+            }, { options: [['canvas', 'Canvas'], ['custom', 'Custom']] })
+        }
+    }
+
+    private get values () {
+        return this.state.size === 'canvas'
+            ? { size: { name: 'canvas', params: {} } }
+            : { size: { name: 'custom', params: { width: this.state.width, height: this.state.height } } }
+    }
+
+    protected defaultState() {
+        return {
+            isCollapsed: false,
+            header: 'Create Image',
+
+            showPreview: false,
+
+            size: 'canvas',
+            width: 1920,
+            height: 1080
+        } as S
+    }
+
+    protected renderControls() {
+        return <div>
+            <div className='msp-control-row'>
+                <button className='msp-btn msp-btn-block' onClick={this.download}>Download</button>
+            </div>
+            <ParameterControls params={this.params} values={this.values} onChange={this.setProps} />
+            <div className='msp-control-group-wrapper'>
+                <div className='msp-control-group-header'>
+                    <button className='msp-btn msp-btn-block' onClick={this.togglePreview}>
+                        <span className={`msp-icon msp-icon-${this.state.showPreview ? 'collapse' : 'expand'}`} />
+                        Preview
+                    </button>
+                </div>
+                {this.state.showPreview && <div className='msp-control-offset'>
+                    <div className='msp-image-preview'>
+                        <canvas width='0px' height='0px' ref={this.canvasRef} />
+                    </div>
+                </div>}
+            </div>
+        </div>
+    }
+}

+ 2 - 0
src/mol-plugin/ui/plugin.tsx

@@ -22,6 +22,7 @@ import { StateTransform } from '../../mol-state';
 import { UpdateTransformControl } from './state/update-transform';
 import { SequenceView } from './sequence';
 import { Toasts } from './toast';
+import { ImageControls } from './image';
 
 export class Plugin extends React.Component<{ plugin: PluginContext }, {}> {
     region(kind: 'left' | 'right' | 'bottom' | 'main', element: JSX.Element) {
@@ -129,6 +130,7 @@ export class ControlsWrapper extends PluginUIComponent {
             {/* <AnimationControlsWrapper /> */}
             {/* <CameraSnapshots /> */}
             <StructureToolsWrapper />
+            <ImageControls />
             <StateSnapshots />
         </div>;
     }