import { CoordinateSystem } from './CoordinateSystem.js'; import { Layer } from './Layer.js' import { LayerCombiner } from './LayerCombiner.js' import { ShaderLens } from './ShaderLens.js' /** * @typedef {Object} LayerLensOptions * @property {boolean} [overlay=true] - Whether the lens renders as an overlay * @property {number} [radius=100] - Initial lens radius in pixels * @property {number[]} [borderColor=[0.078, 0.078, 0.078, 1]] - RGBA border color * @property {number} [borderWidth=12] - Border width in pixels * @property {boolean} [borderEnable=false] - Whether to show lens border * @property {Object} [dashboard=null] - Dashboard UI component for lens control * @property {Camera} camera - Camera instance (required) * @extends LayerCombinerOptions */ /** * LayerLens implements a magnifying lens effect that can display content from one or two layers. * It provides an interactive lens that can be moved and resized, showing different layer content * inside and outside the lens area. * * Features: * - Interactive lens positioning and sizing * - Support for base and overlay layers * - Animated transitions * - Customizable border appearance * - Dashboard UI integration * - Optimized viewport rendering * * Technical Details: * - Uses framebuffer composition for layer blending * - Implements viewport optimization for performance * - Handles coordinate transformations between systems * - Supports animated parameter changes * - Manages WebGL resources efficiently * * @extends LayerCombiner * * @example * ```javascript * // Create lens with base layer * const lens = new OpenLIME.LayerLens({ * camera: viewer.camera, * radius: 150, * borderEnable: true, * borderColor: [0, 0, 0, 1] * }); * * // Set layers * lens.setBaseLayer(baseLayer); * lens.setOverlayLayer(overlayLayer); * * // Animate lens position * lens.setCenter(500, 500, 1000, 'ease-out'); * * // Add to viewer * viewer.addLayer('lens', lens); * ``` */ class LayerLens extends LayerCombiner { /** * Creates a new LayerLens instance * @param {LayerLensOptions} options - Configuration options * @throws {Error} If camera is not provided */ constructor(options) { options = Object.assign({ overlay: true, radius: 100, borderColor: [0.078, 0.078, 0.078, 1], borderWidth: 12, borderEnable: false, dashboard: null, }, options); super(options); if (!this.camera) { console.log("Missing camera"); throw "Missing Camera" } // Shader lens currently handles up to 2 layers let shader = new ShaderLens(); if (this.layers.length == 2) shader.setOverlayLayerEnabled(true); //FIXME Is it a mode? Control? this.shaders['lens'] = shader; this.setShader('lens'); this.addControl('center', [0, 0]); this.addControl('radius', [this.radius, 0]); this.addControl('borderColor', this.borderColor); this.addControl('borderWidth', [this.borderWidth]); this.oldRadius = -9999; this.oldCenter = [-9999, -9999]; this.useGL = true; if (this.dashboard) this.dashboard.lensLayer = this; } /** * Sets layer visibility and updates dashboard if present * @param {boolean} visible - Whether layer should be visible * @override */ setVisible(visible) { if (this.dashboard) { if (visible) { this.dashboard.container.style.display = 'block'; } else { this.dashboard.container.style.display = 'none'; } } super.setVisible(visible); } /** * Removes the overlay layer, returning to single layer mode */ removeOverlayLayer() { this.layers.length = 1; this.shader.setOverlayLayerEnabled(false); } /** * Sets the base layer (shown outside lens) * @param {Layer} layer - Base layer instance * @fires Layer#update */ setBaseLayer(l) { this.layers[0] = l; this.emit('update'); } /** * Sets the overlay layer (shown inside lens) * @param {Layer} layer - Overlay layer instance */ setOverlayLayer(l) { this.layers[1] = l; this.layers[1].setVisible(true); this.shader.setOverlayLayerEnabled(true); this.regenerateFrameBuffers(); } /** * Sets the overlay layer (shown inside lens) * @param {Layer} layer - Overlay layer instance */ regenerateFrameBuffers() { // Regenerate frame buffers const w = this.layout.width; const h = this.layout.height; this.deleteFramebuffers(); this.layout.width = w; this.layout.height = h; this.createFramebuffers(); } /** * Sets lens radius with optional animation * @param {number} radius - New radius in pixels * @param {number} [delayms=100] - Animation duration * @param {string} [easing='linear'] - Animation easing function */ setRadius(r, delayms = 100, easing = 'linear') { this.setControl('radius', [r, 0], delayms, easing); } /** * Gets current lens radius * @returns {number} Current radius in pixels */ getRadius() { return this.controls['radius'].current.value[0]; } /** * Sets lens center position with optional animation * @param {number} x - X coordinate in scene space * @param {number} y - Y coordinate in scene space * @param {number} [delayms=100] - Animation duration * @param {string} [easing='linear'] - Animation easing function */ setCenter(x, y, delayms = 100, easing = 'linear') { this.setControl('center', [x, y], delayms, easing); } /** * Gets current lens center position * @returns {{x: number, y: number}} Center position in scene coordinates */ getCurrentCenter() { const p = this.controls['center'].current.value; return { x: p[0], y: p[1] }; } /** * Gets target lens position for ongoing animation * @returns {{x: number, y: number}} Target position in scene coordinates */ getTargetCenter() { const p = this.controls['center'].target.value; return { x: p[0], y: p[1] }; } /** * Gets current border color * @returns {number[]} RGBA color array */ getBorderColor() { return this.controls['borderColor'].current.value; } /** * Gets current border width * @returns {number} Border width in pixels */ getBorderWidth() { return this.controls['borderWidth'].current.value[0]; } /** * Renders the lens effect * @param {Transform} transform - Current view transform * @param {Object} viewport - Current viewport * @returns {boolean} Whether all animations are complete * @override * @private */ draw(transform, viewport) { let done = this.interpolateControls(); // Update dashboard size & pos if (this.dashboard) { const c = this.getCurrentCenter(); const r = this.getRadius(); this.dashboard.update(c.x, c.y, r); this.oldCenter = c; this.oldRadius = r; } // const vlens = this.getLensInViewportCoords(transform, viewport); // this.shader.setLensUniforms(vlens, [viewport.w, viewport.h], this.borderColor); // this.emit('draw'); // super.draw(transform, viewport); for (let layer of this.layers) if (layer.status != 'ready') return false; if (!this.shader) throw "Shader not specified!"; let gl = this.gl; // Draw on a restricted viewport around the lens, to lower down the number of required tiles let lensViewport = this.getLensViewport(transform, viewport); // If an overlay is present, merge its viewport with the lens one let overlayViewport = this.getOverlayLayerViewport(transform, viewport); if (overlayViewport != null) { lensViewport = this.joinViewports(lensViewport, overlayViewport); } gl.viewport(lensViewport.x, lensViewport.y, lensViewport.dx, lensViewport.dy); // Keep the framwbuffer to the window size in order to avoid changing at each scale event if (!this.framebuffers.length || this.layout.width != viewport.w || this.layout.height != viewport.h) { this.deleteFramebuffers(); this.layout.width = viewport.w; this.layout.height = viewport.h; this.createFramebuffers(); } var b = [0, 0, 0, 0]; gl.clearColor(b[0], b[1], b[2], b[3]); // Draw the layers only within the viewport enclosing the lens for (let i = 0; i < this.layers.length; i++) { gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffers[i]); gl.clear(gl.COLOR_BUFFER_BIT); this.layers[i].draw(transform, lensViewport); gl.bindFramebuffer(gl.FRAMEBUFFER, null); } // Set in the lensShader the proper lens position wrt the window viewport const vl = this.getLensInViewportCoords(transform, viewport); this.shader.setLensUniforms(vl, [viewport.w, viewport.h], this.getBorderColor(), this.borderEnable); this.prepareWebGL(); // Bind all textures and combine them with the shaderLens for (let i = 0; i < this.layers.length; i++) { gl.uniform1i(this.shader.samplers[i].location, i); gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, this.textures[i]); } // Get texture coords of the lensViewport with respect to the framebuffer sz const lx = lensViewport.x / lensViewport.w; const ly = lensViewport.y / lensViewport.h; const hx = (lensViewport.x + lensViewport.dx) / lensViewport.w; const hy = (lensViewport.y + lensViewport.dy) / lensViewport.h; this.updateTileBuffers( new Float32Array([-1, -1, 0, -1, 1, 0, 1, 1, 0, 1, -1, 0]), new Float32Array([lx, ly, lx, hy, hx, hy, hx, ly])); gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); // Restore old viewport gl.viewport(viewport.x, viewport.x, viewport.dx, viewport.dy); return done; } /** * Calculates viewport region affected by lens * @param {Transform} transform - Current view transform * @param {Object} viewport - Current viewport * @returns {Object} Viewport specification for lens region * @private */ getLensViewport(transform, viewport) { const lensC = this.getCurrentCenter(); const l = CoordinateSystem.fromSceneToViewport(lensC, this.camera, this.useGL); const r = this.getRadius() * transform.z; return { x: Math.floor(l.x - r) - 1, y: Math.floor(l.y - r) - 1, dx: Math.ceil(2 * r) + 2, dy: Math.ceil(2 * r) + 2, w: viewport.w, h: viewport.h }; } /** * Calculates viewport region for overlay layer * @param {Transform} transform - Current view transform * @param {Object} viewport - Current viewport * @returns {Object|null} Viewport specification for overlay or null * @private */ getOverlayLayerViewport(transform, viewport) { let result = null; if (this.layers.length == 2) { // Get overlay projected viewport let bbox = this.layers[1].boundingBox(); const p0v = CoordinateSystem.fromSceneToViewport({ x: bbox.xLow, y: bbox.yLow }, this.camera, this.useGL); const p1v = CoordinateSystem.fromSceneToViewport({ x: bbox.xHigh, y: bbox.yHigh }, this.camera, this.useGL); // Intersect with window viewport const x0 = Math.min(Math.max(0, Math.floor(p0v.x)), viewport.w); const y0 = Math.min(Math.max(0, Math.floor(p0v.y)), viewport.h); const x1 = Math.min(Math.max(0, Math.ceil(p1v.x)), viewport.w); const y1 = Math.min(Math.max(0, Math.ceil(p1v.y)), viewport.h); const width = x1 - x0; const height = y1 - y0; result = { x: x0, y: y0, dx: width, dy: height, w: viewport.w, h: viewport.h }; } return result; } /** * Combines two viewport regions * @param {Object} v0 - First viewport * @param {Object} v1 - Second viewport * @returns {Object} Combined viewport encompassing both regions * @private */ joinViewports(v0, v1) { const xm = Math.min(v0.x, v1.x); const xM = Math.max(v0.x + v0.dx, v1.x + v1.dx); const ym = Math.min(v0.y, v1.y); const yM = Math.max(v0.y + v0.dy, v1.y + v1.dy); const width = xM - xm; const height = yM - ym; return { x: xm, y: ym, dx: width, dy: height, w: v0.w, h: v0.h }; } /** * Converts lens parameters to viewport coordinates * @param {Transform} transform - Current view transform * @param {Object} viewport - Current viewport * @returns {number[]} [centerX, centerY, radius, borderWidth] in viewport coordinates * @private */ getLensInViewportCoords(transform, viewport) { const lensC = this.getCurrentCenter(); const c = CoordinateSystem.fromSceneToViewport(lensC, this.camera, this.useGL); const r = this.getRadius(); return [c.x, c.y, r * transform.z, this.getBorderWidth()]; } } /** * Register this layer type with the Layer factory * @type {Function} * @private */ Layer.prototype.types['lens'] = (options) => { return new LayerLens(options); } export { LayerLens }