Home Reference Source

viewer/viewer.js

import * as vec3 from "./glmatrix/vec3.js";

import {ProgramManager} from "./programmanager.js";
import {Lighting} from "./lighting.js";
import {BufferSetPool} from "./buffersetpool.js";
import {Camera} from "./camera.js";
import {CameraControl, CLICK_MEASURE_DIST} from "./cameracontrol.js";
import {RenderBuffer} from "./renderbuffer.js";
import {SvgOverlay} from "./svgoverlay.js";
import {FrozenBufferSet} from "./frozenbufferset.js";
import {Utils} from "./utils.js";
import {SSQuad} from "./ssquad.js";
import {FreezableSet} from "./freezableset.js";
import {DefaultColors} from "./defaultcolors.js";
import {AvlTree} from "./collections/avltree.js";
import {SectionPlaneSet} from "./sectionplaneset.js";

import {COLOR_FLOAT_DEPTH_NORMAL, COLOR_ALPHA_DEPTH} from './renderbuffer.js';
import { WSQuad } from './wsquad.js';
import {EventHandler} from "./eventhandler.js";
import { AnimatedVec3 } from "./animatedvec3.js";

export const ALLOW_FLOAT_RENDER_TARGET = true;

// When a change in color results in a different
// transparency state, the objects needs to be hidden
//} from the original buffer and recreate in a new buffer
// to be rendered during the correct render pass. This
// recreated object will have it's most significant bit
// set to 1.
const OVERRIDE_FLAG = (1 << 30);

/**
 * The idea is that this class doesn't know anything about BIMserver, and can possibly be reused in classes other than BimServerViewer
 * 
 *
 * Main viewer class, too many responsibilities:
 * - Keep track of width/height of viewport
 * - Keeps track of dirty scene
 * - Contains the basic render loop (and delegates to the render layers)
 *
 * @class Viewer
 */

export class Viewer {

    constructor(canvas, settings, stats, width, height) {
        this.width = width;
        this.height = height;

        this.defaultColors = settings.defaultColors ? settings.defaultColors : DefaultColors;
        
        // Controls a couple of settings, such as no section plane cap, no automatic
        // camera near and far planes.
        this.geospatialMode = false;

        this.stats = stats;
        this.settings = settings;
        this.canvas = canvas;
        this.camera = new Camera(this);
        if (settings.useOverlay) {
        	this.overlay = new SvgOverlay(this.canvas, this.camera);
        }
        
        this.gl = this.canvas.getContext('webgl2', {stencil: true, premultipliedAlpha: false, preserveDrawingBuffer: true});

        if (!this.gl) {
            alert('Unable to initialize WebGL. Your browser or machine may not support it.');
            return;
        }

		this.useFloatColorBuffer = ALLOW_FLOAT_RENDER_TARGET && this.gl.getExtension("EXT_color_buffer_float");

        if (!this.settings.loaderSettings.prepareBuffers || (this.settings.tilingLayerEnabled && this.settings.loaderSettings.tilingLayerReuse)) {
        	this.bufferSetPool = new BufferSetPool(1000, this.stats);
        }

        this.tmp_unproject = vec3.create();
        
        this.pickIdCounter = 1;

        // Picking ID (unsigned int) -> ViewObject
        // This is an array now since the picking ids form a continues array
        this.pickIdToViewObject = [];
        
        this.renderLayers = new Set();
        this.animationListeners = [];
        this.colorRestore = [];

        this.sectionPlanes = new SectionPlaneSet({viewer: this, n: 7});
        // We de not keep an index but rather return the first available or
        // parallel plane. Therefore we mark the last one as a plane that
        // cannot be enabled, to limit the total number of planes to n - 1 = 6.
        this.sectionPlanes.planes[6].tail = true;
        this.currentSectionPlane = null;
        
        // For geometry loaded from non-bimserver sources we auto-increment
        // an ID on the spot in the loader.
        this.oidCounter = 1;
        
        // User can override this, default assumes strings to be used as unique object identifiers
		if (this.settings.loaderSettings.uniqueIdCompareFunction) {
			this.uniqueIdCompareFunction = this.settings.loaderSettings.uniqueIdCompareFunction;
            this.idAugmentationFunction = (id) => ("O" + id);
		} else {
	        if (this.settings.loaderSettings.useUuidAndRid) {
	        	const collator = new Intl.Collator();
	        	// TODO there is really no need to use a locale-aware comparator here, but somehow > or < does not seem to work, where it work should for string
	        	this.uniqueIdCompareFunction = collator.compare;
	            this.idAugmentationFunction = (id) => ("O" + id);
	        } else {
	        	this.uniqueIdCompareFunction = (a, b) => {
	        		return a - b;
	            };
	            this.idAugmentationFunction = (id) => (id | OVERRIDE_FLAG);
	        }
		}
        
        /* Next function serves two purposes:
         *	- We invert the uniqueIdCompareFunction because for some reason AvlTree sort is descending
         *  - We convert the returned number to a fixed -1, 0 or 1, also because AvlTree does not handle any other numbers
         */
        this.inverseUniqueIdCompareFunction = (a, b) => {
        	let inverse = this.uniqueIdCompareFunction(b, a);
        	return inverse < 0 ? -1 : (inverse > 0 ? 1 : 0);
        };
        
        this.uniqueIdToBufferSet = new AvlTree(this.inverseUniqueIdCompareFunction);

        // Object ID -> ViewObject
        this.viewObjects = new Map();

        // String -> ViewObject[]
        this.viewObjectsByType = new Map();

        // Null means everything visible, otherwise Set(..., ..., ...)
        this.invisibleElements = new FreezableSet(this.uniqueIdCompareFunction);

        // Elements for which the color has been overriden and transparency has
        // changed. These elements are hidden} from their original buffers and
        // recreated in a new buffer during the correct render pass. When elements
        // are unhidden, the overridden elements need to stay hidden.
        this.hiddenDueToSetColor = new Map();
        this.originalColors = new Map();

        // For instances the logic is even different, as the element matrices
        // are removed} from the buffer and added back to different (instanced)
        // bufferset. When undoing colors on reused geometries, the product matrix
        // simply needs to be added back to the original.
        this.instancesWithChangedColor = new Map();

        this.selectedElements = new FreezableSet(this.uniqueIdCompareFunction);

        this.useOrderIndependentTransparency = this.settings.realtimeSettings.orderIndependentTransparency;

        // 0 -> Not dirty, 1 -> Kinda dirty, but rate-limit the repaints to 2/sec, 2 -> Really dirty, repaint ASAP
        this._dirty = 0; 
        this.lastRepaint = 0;
        
//        window._debugViewer = this;  // HACK for console debugging

        this.eventHandler = new EventHandler();
        
        if ("OffscreenCanvas" in window && canvas instanceof OffscreenCanvas) {
        } else {
        	// Tabindex required to be able add a keypress listener to canvas
			canvas.setAttribute("tabindex", "0");
			if (!this.settings.disableDefaultKeyBindings) {
	        	canvas.addEventListener("keypress", (evt) => {
	        		if (evt.key === 'H') {
	        			this.resetVisibility();
	        		} else if (evt.key === 'h') {
	        			this.setVisibility(this.selectedElements, false, false);
	        			this.selectedElements.clear();
	        		} else if (evt.key === 'C') {
	        			this.resetColors();
	        		} else if (evt.key === 'c' || evt.key === 'd') {
	        			let R = Math.random;
	        			let clr = [R(), R(), R(), evt.key === 'd' ? R() : 1.0];
	        			this.setColor(new Set(this.selectedElements), clr);
	//        			this.selectedElements.clear();
	        		} else {
	        			// Don't do a drawScene for every key pressed
	        			return;
	        		}
	//            this.drawScene();
	        	});
			}
        }

        // These parameters are used for camera control sensitivity
        this.lastRecordedDepth = null;
        this.recordedDepthAt = 0;
    }
    
    set dirty(dirty) {
    	this._dirty = dirty;
    }
    
    get dirty() {
    	return this._dirty;
    }

    callByType(method, types, ...args) {
        let elems = types.map((i) => this.viewObjectsByType.get(i) || [])
            .reduce((a, b) => a.concat(b), [])
            .map((o) => o.oid);
        // Assuming all passed methods return a promise
        return method.call(this, elems, ...args);
    }

    setVisibility(elems, visible, sort=true, fireEvent=true) {
        elems = Array.from(elems);
        // @todo. until is properly asserted, documented somewhere, it's probably best to explicitly sort() for now.
        elems.sort(this.uniqueIdCompareFunction);

        let fn = (visible ? this.invisibleElements.delete : this.invisibleElements.add).bind(this.invisibleElements);
        let fn2 = this.idAugmentationFunction;
        return this.invisibleElements.batch(() => {
            elems.forEach((i) => {
                fn(i);
                // Show/hide transparently-adjusted counterpart (even though it might not exist)
                fn(fn2(i));
            });

            // Make sure elements hidden due to setColor() stay hidden
            for (let i of this.hiddenDueToSetColor.keys()) {
                this.invisibleElements.add(i);
            };

            this.dirty = 2;
            if (fireEvent) {
	            var map = this.splitElementsPerRenderLayer(elems);
				for (const [renderLayer, elems] of map) {
	            	this.eventHandler.fire("visbility_changed", renderLayer, elems, visible);
				}
            }
            
            return Promise.resolve();
        });
    }

	splitElementsPerRenderLayer(elems) {
		var map = new Map();
		for (var elem of elems) {
			var viewObject = this.viewObjects.get(elem);
			if (viewObject) {
				var set = map.get(viewObject.renderLayer);
				if (!set) {
					set = [];
					map.set(viewObject.renderLayer, set);
				}
				set.push(elem);
			}
		}
		return map;
	}

    setSelectionState(elems, selected, clear, fireEvent=true) {
        return this.selectedElements.batch(() => {
            if (clear) {
                this.selectedElements.clear();
            }

            let fn = (selected ? this.selectedElements.add : this.selectedElements.delete).bind(this.selectedElements);
            for (let e of elems) {
                fn(e);
            }
            
            this.dirty = 2;

            return Promise.resolve();
        }).then(() => {
        	if (fireEvent) {
        		this.eventHandler.fire("selection_state_changed", elems, selected);
        	}
        });
    }

    getSelected() {
        return Array.from(this.selectedElements)
            .map(this.viewObjects.get.bind(this.viewObjects));
    }

    resetColor(elems) {
    	return this.invisibleElements.batch(() => {
			var bufferSetsToUpdate = this.generateBufferSetToOidsMap(elems);
    		return this.resetColorAlreadyBatched(elems, bufferSetsToUpdate);
		});
    }

    resetColorAlreadyBatched(elems, bufferSetsToUpdate) {
    	for (let [bufferSetId, bufferSetObject] of bufferSetsToUpdate) {
    		var bufferSet = bufferSetObject.bufferSet;
			let id_ranges = bufferSet.getIdRanges(elems);
			let bounds = bufferSet.getBounds(id_ranges);
    		bufferSet.batchGpuRead(this.gl, ["positionBuffer", "normalBuffer", "colorBuffer", "pickColorBuffer"], bounds, () => {
	    		for (let uniqueId of bufferSetObject.oids) {
	    			if (this.hiddenDueToSetColor.has(uniqueId)) {
	    				this.invisibleElements.delete(uniqueId);
	    				let buffer = this.hiddenDueToSetColor.get(uniqueId);
	    				buffer.manager.deleteBuffer(buffer);
	    				
	    				this.hiddenDueToSetColor.delete(uniqueId);
	    			} else if (this.originalColors.has(uniqueId)) {
	    				this.uniqueIdToBufferSet.get(uniqueId).forEach((bufferSet) => {
							const originalColor = this.originalColors.get(uniqueId);
							bufferSet.setColor(this.gl, uniqueId, originalColor);
	    				});
	    				
	    				this.originalColors.delete(uniqueId);
	    			} else if (this.instancesWithChangedColor.has(uniqueId)) {
	    				let entry = this.instancesWithChangedColor.get(uniqueId);
	    				entry.override.manager.deleteBuffer(entry.override);
	    				entry.original.setObjects(this.gl, entry.original.objects.concat([entry.object]));
	    				this.instancesWithChangedColor.delete(uniqueId);
	    			}
	    		}
    		});
    	}
		this.dirty = 2;
		return Promise.resolve();
    }
    
    /**
     * This will create a mapping from BufferSetId -> {bufferSet, oids[]}
     * This is useful when we want to do batch updates of BufferSets, instead of randomly updating single objects in BufferSets
     * The order already in elems will stay intact
     */
    generateBufferSetToOidsMap(elems) {
		var bufferSetsToUpdate = new Map();
		for (let uniqueId of elems) {
			const bufferSets = this.uniqueIdToBufferSet.get(uniqueId);
			if (bufferSets == null) {
				continue;
			}
			var bufferSetObject = bufferSetsToUpdate.get(bufferSets[0].id);
			if (bufferSetObject == null) {
				bufferSetObject = {
					oids: [],
					bufferSet: bufferSets[0]
				};
				bufferSetsToUpdate.set(bufferSets[0].id, bufferSetObject);
			}
			bufferSetObject.oids.push(uniqueId);
		}
		return bufferSetsToUpdate;
    }
    
    setColor(elems, clr, fireEvent=true) {
        let aug = this.idAugmentationFunction;
		let promise = this.invisibleElements.batch(() => {
			var bufferSetsToUpdate = this.generateBufferSetToOidsMap(elems);
			// Reset colors first to clear any potential transparency overrides.
			return this.resetColorAlreadyBatched(elems, bufferSetsToUpdate).then(() => {
				for (let [bufferSetId, bufferSetObject] of bufferSetsToUpdate) {
					var bufferSet = bufferSetObject.bufferSet;
					var oids = bufferSetObject.oids;

					let id_ranges = bufferSet.getIdRanges(oids);
					let bounds = bufferSet.getBounds(id_ranges);
					
					bufferSet.batchGpuRead(this.gl, ["positionBuffer", "normalBuffer", "colorBuffer", "pickColorBuffer"], bounds, () => {
						for (const uniqueId of oids) {
							let originalColor = bufferSet.setColor(this.gl, uniqueId, clr);
							if (originalColor === false) {
								let copiedBufferSet = bufferSet.copy(this.gl, uniqueId);
								let clrSameType, newClrBuffer;
								if (copiedBufferSet instanceof FrozenBufferSet) {
									clrSameType = new window[copiedBufferSet.colorBuffer.js_type](4);
									newClrBuffer = new window[copiedBufferSet.colorBuffer.js_type](copiedBufferSet.colorBuffer.N);
									copiedBufferSet.hasTransparency = clr[3] < 1.;
								} else {
									clrSameType = new copiedBufferSet.colors.constructor(4);
									newClrBuffer = copiedBufferSet.colors;
									copiedBufferSet.hasTransparency = !bufferSet.hasTransparency;
								}
								
								let factor = clrSameType.constructor.name === "Uint8Array" ? 255. : 1.;
								
								for (let i = 0; i < 4; ++i) {
									clrSameType[i] = clr[i] * factor;
								}
								
								for (let i = 0; i < newClrBuffer.length; i += 4) {
									newClrBuffer.set(clrSameType, i);
								}                     
								
								if (bufferSet.node) {
									copiedBufferSet.node = bufferSet.node;
								}
								
								let buffer;
								
								if (copiedBufferSet instanceof FrozenBufferSet) {
									var programInfo = this.programManager.getProgram(this.programManager.createKey(true, false));
									var pickProgramInfo = this.programManager.getProgram(this.programManager.createKey(true, true));
									var lineProgramInfo = this.programManager.getProgram(this.programManager.createKey(true, false, true));
									
									copiedBufferSet.colorBuffer = Utils.createBuffer(this.gl, newClrBuffer, null, null, 4);
									
									let obj = bufferSet.objects.find(o => o.uniqueId === uniqueId);
									bufferSet.setObjects(this.gl, bufferSet.objects.filter(o => o.uniqueId !== uniqueId));
									copiedBufferSet.setObjects(this.gl, [obj]);
									
									copiedBufferSet.buildVao(this.gl, this.settings, programInfo, pickProgramInfo, lineProgramInfo);
									copiedBufferSet.manager.pushBuffer(copiedBufferSet);
									buffer = copiedBufferSet;
									
									// NB: Single bufferset entry is assumed here, which is the case for now.
									this.uniqueIdToBufferSet.get(uniqueId)[0] = buffer;
									
									this.instancesWithChangedColor.set(uniqueId, {
										object: obj,
										original: bufferSet, 
										override: copiedBufferSet
									});
								} else {
									buffer = bufferSet.owner.flushBuffer(copiedBufferSet, false);
									
									buffer.lineIndexBuffers = copiedBufferSet.lineIndexBuffers;
									
									// Note that this is an attribute on the bufferSet, but is
									// not applied to the actual webgl vertex data.
									buffer.uniqueId = aug(uniqueId);
									
									this.invisibleElements.add(uniqueId);
									this.hiddenDueToSetColor.set(uniqueId, buffer);
								}                    
							} else {
								this.originalColors.set(uniqueId, originalColor);
							}
						}
					});
				}
				this._dirty = 2;
				if (fireEvent) {
					var map = this.splitElementsPerRenderLayer(elems);
					for (const [renderLayer, elems] of map) {
						this.eventHandler.fire("color_changed", renderLayer, elems, clr);
					}
				}
			});
		});
		return promise;
    }

    init() {
        var promise = new Promise((resolve, reject) => {
            this._dirty = 2;
            this.then = 0;
            if (this.settings.autoRender) {
            	this.running = true;
            }
            this.firstRun = true;

            this.fps = 0;
            this.timeLast = 0;

            this.canvas.oncontextmenu = function (e) { // Allow right-click for camera panning
                e.preventDefault();
            };

            this.cameraControl = new CameraControl(this);
            this.lighting = new Lighting(this);
            this.programManager = new ProgramManager(this, this.gl, this.settings);

            this.programManager.load().then(() => {
                resolve();
                if (this.running) {
                	requestAnimationFrame((now) => {
                        this.render(now);
                	});
                }
            });

            this.pickBuffer = new RenderBuffer(this, this.canvas, this.gl, COLOR_FLOAT_DEPTH_NORMAL);
            this.oitBuffer = new RenderBuffer(this, this.canvas, this.gl, COLOR_ALPHA_DEPTH);
            this.quad = new SSQuad(this.gl);
        });
        return promise;
    }

    setDimensions(width, height) {
		if (width == null || height == null) {
			throw "Invalid dimensions: " + width + ", " + height;
		}
        this.width = width;
        this.height = height;
        this.camera.forceBuild();
        this.updateViewport();
		this.overlay.resize();
		if(this.running) this.render();
    }

    render(now) {
        const seconds = now * 0.001;
        const deltaTime = seconds - this.then;
        this.then = seconds;

        this.fps++;

        var wasDirty = this._dirty;
        if (this._dirty == 2 || (this._dirty == 1 && now - this.lastRepaint > 500)) {
        	let reason = this._dirty;
            this._dirty = 0;
            this.drawScene(reason, {without: this.invisibleElements});
            this.lastRepaint = now;
        }

        if (seconds - this.timeLast >= 1) {
            if (wasDirty != 0) {
                this.stats.setParameter("Rendering", "FPS", Number(this.fps / (seconds - this.timeLast)).toPrecision(5));
            } else {
                this.stats.setParameter("Rendering", "FPS", "Off");
            }
            this.timeLast = seconds;
            this.fps = 0;
            this.stats.requestUpdate();
            this.stats.update();
        }

        if (this.running || AnimatedVec3.ACTIVE_ANIMATIONS) {
            requestAnimationFrame((now) => {
                if (AnimatedVec3.ACTIVE_ANIMATIONS) {
                    this.camera.forceBuild();
                }
                this.render(now);
            });
        }
        for (var animationListener of this.animationListeners) {
            animationListener(deltaTime);
        }
    }

    internalRender(elems, t) {
        for (var transparency of (t || [false, true])) {

//        	this.gl.enable(this.gl.POLYGON_OFFSET_FILL);
//        	this.gl.polygonOffset(2, 3);

       		this.gl.disable(this.gl.CULL_FACE);
            
        	for (var twoSidedTriangles of [false, true]) {
//            	if (twoSidedTriangles) {
//            		this.gl.disable(this.gl.CULL_FACE);
//            	} else {
//            		this.gl.enable(this.gl.CULL_FACE);
//            	}
	            for (var renderLayer of this.renderLayers) {
	                renderLayer.render(transparency, false, twoSidedTriangles, elems);
	            }
            }

//        	this.gl.disable(this.gl.POLYGON_OFFSET_FILL);
        	
        	if (this.settings.realtimeSettings.drawLineRenders) {
            	this.gl.depthFunc(this.gl.LESS);
                for (var twoSidedTriangles of [false, true]) {
	            	for (var renderLayer of this.renderLayers) {
	            		renderLayer.render(transparency, true, twoSidedTriangles, elems);
	            	}
                }
            	this.gl.depthFunc(this.gl.LEQUAL);
            }
        }
    }
    
    drawScene(reason, what = {without: this.invisibleElements}) {
        // Locks the camera so that intermittent mouse events will not
        // change the matrices until the camera is unlocked again.
        // @todo This might need some work to make sure events are
        // processed timely and smoothly.
        this.camera.lock();

        let gl = this.gl;

        gl.depthMask(true);
        gl.disable(gl.STENCIL_TEST);
        gl.clearColor(1, 1, 1, 0);
        gl.clearDepth(1);
        gl.clearStencil(0);
        gl.enable(gl.DEPTH_TEST);
        gl.depthFunc(gl.LEQUAL);
        
        gl.viewport(0, 0, this.width, this.height);
        gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT);
        gl.disable(gl.CULL_FACE);

        this.sectionPlanes.tempRestore();

        for (var renderLayer of this.renderLayers) {
            renderLayer.prepareRender(reason);
//            renderLayer.renderLines();
        }

        gl.enable(gl.CULL_FACE);

        if (this.modelBounds) {
            if (!this.cameraSet && this.settings.resetToDefaultViewOnLoad) { // HACK to look at model origin as soon as available
            	this.resetToDefaultView();
            }

            // On what side of the plane is the camera eye?
            let planeIsVisbible = (p) => vec3.dot(p.values, this.camera.eye) > p.values[3];

            if (!this.geospatialMode && this.sectionPlanes.planes.some(p => !p.isDisabled && planeIsVisbible(p))) {

                // Fill depth buffer with quads
                // ----------------------------

                gl.stencilMask(0xff);
                gl.colorMask(false, false, false, false);

                // @todo simply draw quad twice with opposing windings so that we
                // can remove the cull_face toggle here?
                gl.disable(gl.CULL_FACE);

                this.sectionPlanes.tempRestore();
                this.sectionPlanes.planes.filter(sp => !sp.isDisabled).forEach(sp => sp.drawQuad());

                // Draw scene twice without planes and without depth test
                // ------------------------------------------------------

                gl.enable(gl.CULL_FACE);
                gl.depthMask(false);
                gl.enable(gl.STENCIL_TEST);
                gl.stencilFunc(gl.ALWAYS, 1, 0xff);

                this.sectionPlanes.tempDisable();

                gl.stencilOp(gl.KEEP, gl.KEEP, gl.INCR); // increment on pass
                gl.cullFace(gl.BACK);
                this.internalRender(what, [false]);

                gl.stencilOp(gl.KEEP, gl.KEEP, gl.DECR); // decrement on pass
                gl.cullFace(gl.FRONT);
                this.internalRender(what, [false]);

                this.sectionPlanes.tempRestore();

                // const eyePlaneDist = this.lastSectionPlaneAdjustment = Math.abs(vec3.dot(this.camera.eye, sp.values2) - sp.values2[3]);
                // sp.values[3] -= 1.e-3 * eyePlaneDist;

                // Renable color mask and draw planes with stencil
                // -----------------------------------------------

                gl.stencilFunc(gl.EQUAL, 1, 0xff);
                gl.colorMask(true, true, true, true);
                gl.depthMask(true);
                gl.clear(gl.DEPTH_BUFFER_BIT);
                gl.disable(gl.CULL_FACE);

                this.sectionPlanes.tempRestore();
                for (var i = 0; i < this.sectionPlanes.planes.length; ++i) {
                    // @todo planes pointing away from camera do not need to be rendered
                    let sp = this.sectionPlanes.planes[i];
                    if (!sp.isDisabled && planeIsVisbible(sp)) {
                        sp.drawQuad();
                    }
                }

                // Restore main render settings
                // ----------------------------

                gl.enable(gl.CULL_FACE);
                gl.cullFace(gl.BACK);
                gl.disable(gl.STENCIL_TEST);
                gl.stencilFunc(gl.ALWAYS, 1, 0xff);
            }
        }

        if (this.useOrderIndependentTransparency) {
        	  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
              gl.disable(gl.BLEND);
              this.internalRender(what, [false]);

              this.oitBuffer.bind();
              gl.clearColor(0, 0, 0, 0);
              this.oitBuffer.clear();
              // @todo It should be possible to eliminate this step. It's necessary
              // to repopulate the depth-buffer with opaque elements.
              this.internalRender(what, [false]);
              this.oitBuffer.clear(false);
              gl.enable(gl.BLEND);
              gl.blendFunc(gl.ONE, gl.ONE);
              gl.depthMask(false);
      
              this.internalRender(what, [true]);
      
              gl.bindFramebuffer(gl.FRAMEBUFFER, null);
              gl.viewport(0, 0, this.width, this.height);
              this.quad.draw(this.oitBuffer.colorBuffer, this.oitBuffer.alphaBuffer);
        } else {
            gl.disable(gl.BLEND);
            this.internalRender(what, [false]);
            gl.enable(gl.BLEND);
            gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
            this.internalRender(what, [true]);
        }

        // From now on section plane is disabled.
        for (let sp of this.sectionPlanes.planes) {
            sp.tempDisable();
        }

        // Selection outlines require face culling to be disabled.
        gl.disable(gl.CULL_FACE);

        if (this.selectedElements.size > 0) {
            gl.enable(gl.STENCIL_TEST);
            gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
            gl.stencilFunc(gl.ALWAYS, 1, 0xff);
            gl.stencilMask(0xff);
            gl.depthMask(false);
            gl.disable(gl.DEPTH_TEST);
            gl.colorMask(false, false, false, false);
            
            this.internalRender({with: this.selectedElements, pass: 'stencil'});

            gl.stencilFunc(gl.NOTEQUAL, 1, 0xff);
            gl.stencilMask(0x00);
            gl.colorMask(true, true, true, true);

            for (var renderLayer of this.renderLayers) {
                renderLayer.renderSelectionOutlines(this.selectedElements);
            }

            gl.disable(gl.STENCIL_TEST);

            for (var renderLayer of this.renderLayers) {
                renderLayer.renderSelectionOutlines(this.selectedElements, 0.002);
            }
        }

        for (var renderLayer of this.renderLayers) {
            if (renderLayer.renderTileBorders) {
                renderLayer.renderTileBorders();
            }
        }

        this.camera.unlock();

//		this.gl.bindFramebuffer(this.gl.READ_FRAMEBUFFER, this.renderFrameBuffer);
//		this.gl.bindFramebuffer(this.gl.DRAW_FRAMEBUFFER, this.colorFrameBuffer);
//		this.gl.clearBufferfv(this.gl.COLOR, 0, [0.0, 0.0, 0.0, 1.0]);
//		this.gl.blitFramebuffer(
//		    0, 0, this.width, this.height,
//		    0, 0, this.width, this.height,
//		    this.gl.COLOR_BUFFER_BIT, this.gl.NEAREST
//		);
    }
    
    resetToDefaultView(modelBounds=this.modelBounds, animate=false) {
//        this.camera.target = [0, 0, 0];
//        this.camera.eye = [0, -1, 0];
        this.camera.up = [0, 0, 1];
        this.camera.worldAxis = [ // Set the +Z axis as World "up"
            1, 0, 0, // Right
            0, 0, 1, // Up
            0, -1, 0  // Forward
        ];
        this.camera.viewFit({aabb: modelBounds, viewDirection: [0, -1, 0], animate: animate}); // Position camera so that entire model bounds are in view
        this.cameraSet = true;
        this.camera.forceBuild();
    }

    removeSectionPlaneWidget() {
        for (let sp of this.sectionPlanes.planes) {
            sp.destroy();
        }
    }

    positionSectionPlaneWidget(params) {
        let p = this.pick({canvasPos: params.canvasPos, select: false});
        if (p.normal && p.coordinates) {
            let pln = this.currentSectionPlane = this.sectionPlanes.firstDisabled();
            if (pln) {
                pln.position(p.coordinates, p.normal);
            }
        }
    }
    
    enableSectionPlane(params) {        
        let p = this.pick({canvasPos: params.canvasPos, select: false});
        if (p.normal && p.coordinates && p.depth) {
            let pln = this.sectionPlanes.firstDisabledOrParallelTo(p.normal);
            console.log("enabled plane", pln.index);
            if (!pln.tail) {
                this.currentSectionPlane = pln;
                pln.enable(params.canvasPos, p.coordinates, p.normal, p.depth);
                this.dirty = 2;
            }
            return true;
        } else {
            // clicked outside of model, disable all.
            this.sectionPlanes.disable();
            this.dirty = 2;
            return false;
        }
    }

    moveSectionPlane(params) {
        if (this.currentSectionPlane) {
            this.currentSectionPlane.move(params.canvasPos);
            this.dirty = 2;
        }
    }

    setMeasurementPoint(params) {        
        let p = this.pick({canvasPos: params.canvasPos, select: false});
        if (p.normal && p.coordinates && p.depth) {
            if (this.activeMeasurement) {
                this.activeMeasurement.updatePoint(p.coordinates);
                if (params.commit) {
                    if (this.activeMeasurement.constrain) {
                        this.commitActiveMeasurement();
                    } else {
                        this.activeMeasurement.fixPoint();
                    }
                    this.overlay.update();
                }
            } else {
                this.activeMeasurement = this.overlay.addMeasurement(p.coordinates, p.normal, params.shift);
                if (params.mode == CLICK_MEASURE_DIST) {
                    this.activeMeasurement.constrain = true;
                }
                this.overlay.update();
            }
        }
    }

    deleteAllMeasurements() {
        // @nb make a copy of list because destroy() removes from sequence
        for(let n of Array.from(this.overlay.nodes)) {
           if (n.constructor.name == 'MeasurementNode' || n.constructor.name == 'InfiniteLine') {
               n.destroy();
           }
        }
        this.activeMeasurement = null;
    }

    destroyActiveMeasurement() {
        if (this.activeMeasurement) {
            if (this.activeMeasurement.line) {
                this.activeMeasurement.line.destroy();
            }
            this.activeMeasurement.destroy();
            this.activeMeasurement = null;
        }
    }

    commitActiveMeasurement() {
        if (this.activeMeasurement) {
            if (!this.activeMeasurement.constrain) {
                // last point is still in progress
                this.activeMeasurement.popLastPoint();
            }
            this.activeMeasurement.fixed = true;
            this.activeMeasurement = null;
        }
    }

    /**
     Attempts to pick an object at the given canvas coordinates.

     @param {*} params
     @param {Array} params.canvasPos Canvas coordinates
     @return {*} Information about the object that was picked, if any.
     */
    pick(params) { // Returns info on the object at the given canvas coordinates
        var canvasPos = params.canvasPos;
        if (!canvasPos) {
            throw "param epected: canvasPos";
        }

        this.sectionPlanes.tempRestore();

        // TODO when the navigation has not changed since the last picking action, we should probably be able to reuse the previously generated render target?

        /*
        if (!this.sectionPlanes.planes[0].isDisabled) {
            // tfk: I forgot what this is.
            this.sectionPlanes.planes[0].values[3] -= 1.e-3 * this.lastSectionPlaneAdjustment;        
        }
        */

        this.pickBuffer.bind();

        this.gl.depthMask(true);
        this.gl.clearBufferuiv(this.gl.COLOR, 0, new Uint8Array([0, 0, 0, 0]));
        
        /*
         * @todo: clearing the 2nd attachment does not work? Not a big issue as long
         * as one of the buffers is cleared to be able to detect clicks outside of the model.
         */
        // this.gl.clearBufferfv(this.gl.COLOR, 1, new Float32Array([1.]));

        this.gl.clearBufferfv(this.gl.DEPTH, this.pickBuffer.depthBuffer, new Uint8Array([1, 0])); // TODO should be a Float16Array, which does not exists, need to find the 16bits that define the number 1 here
        this.gl.enable(this.gl.DEPTH_TEST);
        this.gl.depthFunc(this.gl.LEQUAL);
        this.gl.disable(this.gl.BLEND);

        for (var transparency of [false, true]) {
        	for (var twoSidedTriangles of [false, true]) {
        		// TODO change back face culling setting based on twoSidedTriangles?
        		for (var renderLayer of this.renderLayers) {
        			renderLayer.render(transparency, false, twoSidedTriangles, {without: this.invisibleElements, pass: 'pick'});
        		}
        	}
        }
        
        let [x,y] = [Math.round(canvasPos[0]), Math.round(canvasPos[1])];
        var pickColor = this.pickBuffer.read(x, y);
        var pickId = pickColor[0] + pickColor[1] * 256 + pickColor[2] * 65536 + pickColor[3] * 16777216;
        var viewObject = this.pickIdToViewObject[pickId];
        let normal = this.pickBuffer.normal(x, y);

        // Don't attempt to read depth if there is no object under the cursor
        // Note that the default depth of 1. corresponds to the far plane, which
        // can be quite far away but at least is something that is recognizable
        // in most cases.

        // tfk: I don't know why the pB.d is in [0,1] and needs to be mapped back
        // to [-1, 1] for multiplication with the inverse projMat.
        let z = viewObject ? (this.pickBuffer.depth(x,y) * 2. - 1.) : 1.;
        vec3.set(this.tmp_unproject, x / this.width * 2 - 1, - y / this.height * 2 + 1, z);
        vec3.transformMat4(this.tmp_unproject, this.tmp_unproject, this.camera.projection.projMatrixInverted);
        let depth = -this.tmp_unproject[2];
        vec3.transformMat4(this.tmp_unproject, this.tmp_unproject, this.camera.viewMatrixInverted);
//        console.log("Picked @", this.tmp_unproject[0], this.tmp_unproject[1], this.tmp_unproject[2], uniqueId, viewObject);

        this.pickBuffer.unbind();
        
        if (viewObject) {
        	var uniqueId = viewObject.uniqueId;
            if (params.select !== false) {
            	var triggered = false;
                if (!params.shiftKey) {
                	if (this.selectedElements.size > 0) {
                		this.eventHandler.fire("selection_state_set", viewObject.renderLayer, [uniqueId], true);
                		triggered = true;
                		this.selectedElements.clear();
                		this.addToSelection(uniqueId);
                	}
                }
                if (!triggered) {
                	if (this.selectedElements.has(uniqueId) && !params.onlyAdd) {
                		this.selectedElements.delete(uniqueId);
                		this.eventHandler.fire("selection_state_changed", viewObject.renderLayer, [uniqueId], false);
                	} else {
                		this.addToSelection(uniqueId);
                		this.eventHandler.fire("selection_state_changed", viewObject.renderLayer, [uniqueId], true);
                	}
                }
            }
            this.lastRecordedDepth = depth;
            this.recordedDepthAt = +new Date();
//            console.log("recording depth at", depth);
            return {object: viewObject, normal: normal, coordinates: this.tmp_unproject, depth: depth};
        } else if (params.select !== false) {
        	if (this.selectedElements.size > 0) {
				var map = this.splitElementsPerRenderLayer(this.selectedElements);
				for (const [renderLayer, elems] of map) {
	        		this.eventHandler.fire("selection_state_changed", renderLayer, elems, false);
				}
        		this.selectedElements.clear();
        	}
        }

        this.lastRecordedDepth = null;
        this.recordedDepthAt = +new Date();

        return {object: null, coordinates: this.tmp_unproject, depth: depth};
    }

    addToSelection(uniqueId) {
    	this.selectedElements.add(uniqueId);
    	let bufferSets = this.uniqueIdToBufferSet.get(uniqueId);
    	for (var bufferSet of bufferSets) {
    		bufferSet.generateLines(uniqueId, this.gl);
    	}
    }
    
    getPickColorForPickId(pickId) {
    	var pickColor = new Uint8Array([pickId & 0x000000FF, (pickId & 0x0000FF00) >> 8, (pickId & 0x00FF0000) >> 16, (pickId & 0xFF000000) > 24]);
        return pickColor;
    }
    
    getPickColor(uniqueId) { // Converts an integer to a pick color
    	var viewObject = this.viewObjects.get(uniqueId);
    	if (viewObject == null) {
    		console.error("No viewObject found for " + uniqueId);
    	}
    	var pickId = viewObject.pickId;
    	return this.getPickColorForPickId(pickId);
    }

    setModelBounds(modelBounds, force=false) {
    	if (!force && this.modelBounds != null) {
    		// "Merge"
    		this.modelBounds[0] = Math.min(this.modelBounds[0], modelBounds[0]);
    		this.modelBounds[1] = Math.min(this.modelBounds[1], modelBounds[1]);
    		this.modelBounds[2] = Math.min(this.modelBounds[2], modelBounds[2]);
    		this.modelBounds[3] = Math.max(this.modelBounds[3], modelBounds[3]);
    		this.modelBounds[4] = Math.max(this.modelBounds[4], modelBounds[4]);
    		this.modelBounds[5] = Math.max(this.modelBounds[5], modelBounds[5]);
    	} else {
    		this.modelBounds = modelBounds;
    	}
        this.camera.setModelBounds(this.modelBounds);
        this.updateViewport();
    }

    updateViewport() {
        this.dirty = 2;
    }

    loadingDone() {
        this.dirty = 2;
    }

    cleanup() {
        this.running = false;
        this.cameraControl.cleanup();
//        this.gl.getExtension('WEBGL_lose_context').loseContext();
        this.stats.cleanup();
    }

    addAnimationListener(fn) {
        this.animationListeners.push(fn);
    }
    
    getViewObject(uniqueId) {
    	return this.viewObjects.get(uniqueId);
    }
    
    addViewObject(uniqueId, viewObject) {
        if (this.viewObjects.has(uniqueId)) {
            viewObject.pickId = this.viewObjects.get(uniqueId).pickId;
        } else {
            viewObject.pickId = this.pickIdCounter++;
        }    	
    	this.viewObjects.set(uniqueId, viewObject);
        this.pickIdToViewObject[viewObject.pickId] = viewObject;

        let byType = this.viewObjectsByType.get(viewObject.type) || [];
        byType.push(viewObject);
        this.viewObjectsByType.set(viewObject.type, byType);
    }

	getAabbFor(ids) {
		return ids.map(this.viewObjects.get.bind(this.viewObjects))
			.filter((o) => o != null && o.globalizedAabb != null)
			.map((o) => o.globalizedAabb)
	        .reduce(Utils.unionAabb, Utils.emptyAabb());
	}

    viewFit(ids, settings) {
    	if (ids.length == 0) {
    		return Promise.resolve();
    	}
    	return new Promise((resolve, reject) => {
			const aabb = this.getAabbFor(ids);
            if (Utils.isEmptyAabb(aabb)) {
                console.error("No AABB for objects", ids);
                reject();
            } else {
				if (!settings) {
					settings = {};
				}
				settings.aabb = aabb;
                this.camera.viewFit(settings);
                this.dirty = 2;
                resolve();
            }
    	});
    }

    resetCamera() {
        this.cameraSet = false;
        this.dirty = 2;
    }

	screenshot(callback) {
		if (this.canvas instanceof OffscreenCanvas) {
			if (this.canvas.convertToBlob) {
				return this.canvas.convertToBlob();
			} else if (this.canvas.toBlob) {
				// Firefox
				return this.canvas.toBlob();
			}
		} else {
			return new Promise((resolve, reject) => {
				return this.canvas.toBlob(resolve);
			});
		}
	}
    
    resetColors() {
        return this.resetColor(
            Array.from(this.hiddenDueToSetColor.keys()).concat(
                Array.from(this.originalColors.keys())
            ).concat(
                Array.from(this.instancesWithChangedColor.keys())
            )
        );
    }

    resetVisibility() {
        this.setVisibility(this.invisibleElements.keys(), true, false);
        this.dirty = 2;
    }
	
    addSelectionListener(listener) {
       this.eventHandler.on("selection_state_changed", listener.handler);
    }
}