/** @internal NOTE: Internal APIs. Subject to change. Use of these APIs in production applications is not supported. */ /** */

import { StopWatch } from './stopwatch';
import { Keyboard } from './keyboard';
import '../MapExtensions';
import {
    Engine,
    Scene,
    Color4,
    Mesh,
    Texture,
    HemisphericLight,
    Vector3,
    BoundingInfo,
    KeyboardEventTypes,
    Nullable,
    AbstractMesh,
    TransformNode,
    SkyMaterial,
    Observable,
    Camera,
    Color3,
    GridMaterial,
    PerfCounter,
    Ray,
    TargetCamera,
    DeepImmutable,
    Scalar,
    ISize
} from './babylonjs-import';
import { Geometry3dHandle, Geometry3d } from './vertex-data-merge';
import { Materials } from './materials';
import { createAxis } from './coordinate-axis';
import { ViewerCamera } from '../camera/ViewerCamera';
import { GpuSelectables } from './GpuSelectables';
import { Selectables, PredefinedCanvasPosition, CanvasPosition } from './Selectables';
import { TwinfinityCameraStateSnapshot } from '../camera/TwinfinityCameraStateSnapshot';
import { TwinfinityIfcMeshExtension } from './MeshExtensions';
import { telemetry } from '../Telemetry';
import { ShortFrustumDepthRenderer } from './ShortFrustumDepthRenderer';
import { boundingClientRectCache } from '../BoundingClientRectCache';
import { ClipPlaneOptions } from './ClipPlane';
import { PickResult } from './PickResult';
import { PointerInfoWithTimings, createPointerInfoEnricherAndForwarder } from '../PointerInfoWithTimings';
import { DitheringTextureMode } from './CustomBabylonMaterials/DitherTexture';
import { IFCLightingEnvironment } from './IfcLightingEnvironment';
import { VisualSettings } from './VisualSettings';
import { copyVertex3ToRef } from '../math';
import { DistanceToCamera } from './DistanceToCamera';

/** Options to control a 3D help grid */
export interface GridOptions {
    /** Whether grid shall be shown or not */
    isEnabled: boolean;
    /** Line color */
    lineColor?: Color3;
    /** color between lines */
    mainColor?: Color3;
    /** How far apart lines are supposed to be. */
    lineDistanceInMeter?: number;
    /** Offset of grid along y axis. Default is 0. */
    y?: number;
}

export enum TransparencyMode {
    Seethrough,
    Dithering
}
export interface RunRenderLoopHandler {
    (runRenderLoop: () => void): void;
}

/**
 * Options for adding or updating an environment (world) in the Twinfinity Viewer.
 * Used by {@link TwinfinityViewer.addOrUpdateEnvironment}.
 */
export interface AddOrUpdateEnvironmentOptions {
    /**
     * The bounding info of the environment (world).
     */
    boundingInfo: BoundingInfo;
    /**
     * Position the camera so it fits the bounds provided by {@link boundingInfo}. Default is `true`.
     * It ensures that the entire {@link boundingInfo} is visible and sets the maximum z-coordinate of the camera to a reasonable value that
     * allows everything with {@link boundingInfo} to be visible. However if camera is moved too far away from {@link boundingInfo} then
     * the camera will still not be able to see everything.
     */
    positionCameraToFitBounds?: boolean;
}

/**
 * Performance options for {@link TwinfinityViewer}.
 */
export interface TwinfinityViewerPerformanceOptions {
    /**
     * Set to `true` to disable rendering all together. Default is `true`.
     */
    isPaused: boolean;
    /**
     * Performance settings only applicable to IFC objects.
     */
    readonly ifc: {
        /**
         * Hides expensive models when rotating or moving the camera to increase performance.
         * A bounding sphere will be created around the pivot point when moving the camera, any mesh intersecting that sphere will not be hidden.
         */
        readonly hideExpensiveMeshesOnCameraMove: {
            isEnabled: boolean;
            /**
             * Radius of bounding sphere that will be created around the pivot point to check for meshes to keep.
             */
            pickBoundingSphereRadius: number;
            /**
             * Keeps meshes near the intersecion between the 3d model and the cameras forward vector.
             */
            keepMeshesNearPointOfInterest: boolean;
            /**
             * Keeps meshes near the intersection between the pivot point and the model.
             */
            keepMeshesNearPivotPoint: boolean;
            /**
             * Delay until culling stops after camera stops moving in miliseconds.
             */
            delayUntilCullingStops: number;
        };
    };
    /**
     * If <= 0 then frames are always rendered regardless if user is doing something or not.
     * If > 0 and also {@link forceRenderingTimeoutInMs} > 0 then rendering stops when camera has been stationary for this many milliseconds. As soon
     * as camera is moved rendering resumes in full again.
     * Default is 10000
     */
    idleRenderingTimeoutInMs: number;
    /**
     * If <= 0 then frames are always rendered regardless if user is doing something or not.
     * If > 0 and if {@link idleRenderingTimeoutInMs} > 0 then one frame is always rendered whenever this
     * interval has passed. Default is 1500
     */
    forceRenderingTimeoutInMs: number;
    /**
     * Specifies whether backface culling is enabled or not.
     * Backface culling is a technique used in 3D computer graphics to improve rendering performance by discarding polygons that are not visible to the viewer.
     * When backface culling is enabled, only the front-facing polygons are rendered, while the back-facing polygons are discarded.
     * If geometries are not rendered correctly (they have "holes"), try setting to `false`. This will make rendering slower but geometries with incorrect
     * polygon windingorder will be rendered correctly.
     * This is a global setting and affects all geometries in the scene.
     */
    isBackfaceCullingEnabled: boolean;
}

/**
 * Represents a TwinfinityViewer, which is responsible for rendering and interacting with a 3D scene.
 * There can only be one TwinfinityViewer.
 */
export class TwinfinityViewer {
    private static _temp = {
        vectorA: Vector3.Zero()
    };

    private static _engine?: Engine;
    private _isRenderLoopStarted = false;
    private _ifcRootNode?: TransformNode;

    private _currentlyPickedItemKeys = new Set<string>();
    private _movementRenderTimeout = new StopWatch(false);
    private _lastCanvasSize: ISize = { width: -1, height: -1 };

    /** @hidden */
    public static _isTouch = false;
    public readonly scene: Scene;
    public readonly keyboard: Keyboard;

    /**
     * Subscribe to get access to pointer events (mouse, touchpad etc) and to determine
     * what (if anything) a mouse click interacted with (what object was 'picked' in the 3D scene).
     */
    public readonly onPointerObservable = new Observable<DeepImmutable<PointerInfoWithTimings>>();

    public readonly grid: GridOptions = {
        isEnabled: false
    };

    public readonly statistics = {
        mesh: {
            verticeCount: new PerfCounter(),
            triangleCount: new PerfCounter(),
            count: new PerfCounter()
        },
        addOrUpdateProducts: {
            totalTime: new PerfCounter(),
            meshCreationTime: 0,
            meshMergeTime: 0,
            meshAllocateMemoryTime: 0
        }
    };

    public addOrUpdateProductOptions = {
        meshMerge: {
            deadline: 150,
            sleepInMs: 4
        }
    };

    /**
     * Performance options.
     * @beta
     */
    public performance: TwinfinityViewerPerformanceOptions;

    private readonly _environment: {
        readonly skyboxMaterial: SkyMaterial;
        readonly buildingBoundingInfo: BoundingInfo;
        worldAxis?: TransformNode;
        isEnabledWorldAxis: boolean;
        readonly skybox: Mesh;
        isEnabledSkyBox: boolean;
        readonly camera: ViewerCamera;
        readonly gridMesh: Mesh;
        readonly gridMaterial: GridMaterial;
        cameraState?: TwinfinityCameraStateSnapshot;
    };

    private readonly _gpuSelectables: GpuSelectables;
    private readonly _debugLayerEnabledPromise = Promise.resolve(false);
    private _depthRenderer?: ShortFrustumDepthRenderer;
    private _cameraChangedThisFrame = false;
    private _timeSinceLastCameraMove = new StopWatch();

    /** @hidden for internal use only.*/
    public _materials: Materials;

    /**
     *  Controls the visual settings of the viewer. See {@link VisualSettings} for more information.
     *  @remarks Visual settings are things like global lightning but also how transparency is handled for IFC objects etc.
     */
    public readonly visualSettings: VisualSettings;

    private _adaptiveMinZ = false;

    /**
     * Tracks minimum distance from currently rendered visible meshes to camera.
     * Only valid during and directly after a frame has been rendered.
     * It is updated automatically each frame and only visible meshes are considered.
     * @internal
     * @hidden
     */
    public readonly _meshDistanceToCamera = new DistanceToCamera();

    private constructor(
        public readonly canvas: HTMLCanvasElement,
        materials: Materials,
        private readonly _runRenderLoop: RunRenderLoopHandler
    ) {
        // Create the scene space
        this.scene = new Scene(TwinfinityViewer._engine!);
        this.scene.twinfinity.viewer = this;

        this._materials = materials;

        this.scene.clearColor = new Color4(1, 1, 1, 1);
        this.scene.useRightHandedSystem = false;
        this.scene.autoClear = true;
        this.scene.autoClearDepthAndStencil = true;
        this.scene.customLODSelector = this.onCustomLODSelector.bind(this);

        this.keyboard = new Keyboard(this.scene);
        this._gpuSelectables = new GpuSelectables(this.scene, materials);

        this.performance = {
            isPaused: false,
            idleRenderingTimeoutInMs: 2000,
            forceRenderingTimeoutInMs: 1500,
            isBackfaceCullingEnabled: materials.isBackfaceCullingEnabled,
            ifc: {
                hideExpensiveMeshesOnCameraMove: {
                    isEnabled: false,
                    pickBoundingSphereRadius: 5,
                    keepMeshesNearPivotPoint: true,
                    keepMeshesNearPointOfInterest: true,
                    delayUntilCullingStops: 200
                }
            }
        };

        this.visualSettings = new VisualSettings(this);

        this._environment = {
            skyboxMaterial: new SkyMaterial('skyMaterial', this.scene),
            buildingBoundingInfo: new BoundingInfo(Vector3.Zero(), Vector3.Zero()),
            skybox: Mesh.CreateBox('skyBox', 1, this.scene),
            isEnabledSkyBox: true,
            isEnabledWorldAxis: true,
            // Would be cool to make this mirror sunposition in skyboxmaterial
            // then we could have day and night with correct ligthing since skyBoxMaterial simulates sun
            //belowLight: new HemisphericLight('below light', Vector3.Down(), this.scene),
            camera: new ViewerCamera(this),
            gridMesh: Mesh.CreateGround('grid', 1.0, 1.0, 1, this.scene),
            gridMaterial: new GridMaterial('GridMaterial', this.scene)
        };

        // this._environment.light.specular.r = 0.8;
        // this._environment.light.specular.g = 0.8;
        // this._environment.light.specular.b = 0.8;

        // this._environment.belowLight.specular.set(0.01, 0.01, 0.01);

        // this.camera.
        // this._environment.camera.minZ = 0.1; // 1 cm , sets near clipping plane to reasonable value
        // this._environment.camera.ellipsoid = new Vector3(1, 1, 1);
        // this._environment.camera.speed = 1;

        // this.scene.activeCamera = this._environment.camera;
        // this._environment.camera.attachControl(this.canvas);

        this._environment.skyboxMaterial.backFaceCulling = false;
        this._environment.skyboxMaterial.inclination = -0.45; // The solar inclination, related to the solar azimuth in interval [0, 1]
        this._environment.skyboxMaterial.azimuth = 0.15;
        this._environment.skyboxMaterial.disableDepthWrite = true;
        //skyboxMaterial.luminance = 0.8;
        this._environment.skyboxMaterial.turbidity = 5;
        this._environment.skybox.material = this._environment.skyboxMaterial;
        this._environment.skybox.checkCollisions = false;
        this._environment.skybox.useOctreeForCollisions = false;
        this._environment.skybox.useOctreeForRenderingSelection = false;
        this._environment.skybox.useOctreeForPicking = false;

        //this._gridMesh.reservedDataStore.isInspectorGrid = true;
        this._environment.gridMesh.isPickable = false;

        this._environment.gridMaterial.majorUnitFrequency = 10;
        this._environment.gridMaterial.minorUnitVisibility = 0.3;
        this._environment.gridMaterial.gridRatio = 0.01;
        this._environment.gridMaterial.backFaceCulling = false;
        this._environment.gridMaterial.mainColor = new Color3(1, 1, 1);
        this._environment.gridMaterial.lineColor = new Color3(1.0, 1.0, 1.0);
        this._environment.gridMaterial.opacity = 0.8;
        this._environment.gridMaterial.zOffset = 1.0;
        this._environment.gridMaterial.disableDepthWrite = true;

        this._environment.gridMesh.material = this._environment.gridMaterial;

        // Ensure we can turn debug layer on and off using 'F2' key.
        this.scene.onKeyboardObservable.add(async (kbInfo) => {
            if (kbInfo.type === KeyboardEventTypes.KEYDOWN && kbInfo.event.key === 'F2') {
                const isVisible = await this.isDebugLayerEnabled();
                await this.isDebugLayerEnabled(!isVisible);
                return;
            }
        });
        const enrichPointerInfoAndForward = createPointerInfoEnricherAndForwarder(
            this._environment.camera.activeCamera
        );
        this.scene.onPointerObservable.add((pointerInfo) => {
            enrichPointerInfoAndForward(pointerInfo, this.onPointerObservable);
        });
    }

    /**
     * @deprecated Should not be used, there should exist a API method for changing lighting
     * Should this be removed? Is it used by Jaxel? Otherwise normal constructor injection should be used
     */
    public get globalLight(): HemisphericLight {
        return this.visualSettings.lighting.hemisphericLight;
    }

    /**
     * Gets the current lighting settings, allowing you to change them on the fly.
     */
    public get lightingProperties(): IFCLightingEnvironment {
        return this.visualSettings.ifc.lighting;
    }

    public buildingMeshes(): IterableIterator<Mesh> {
        return this._gpuSelectables.meshes.values();
    }

    public get selectables(): Selectables {
        return this._gpuSelectables;
    }

    public static create(
        canvas: HTMLCanvasElement,
        materials: Materials,
        runRenderLoop: RunRenderLoopHandler
    ): TwinfinityViewer {
        if (TwinfinityViewer._engine === undefined) {
            // engine
            TwinfinityViewer._engine = new Engine(
                canvas,
                false,
                {
                    preserveDrawingBuffer: true,
                    stencil: true,
                    useHighPrecisionMatrix: true,
                    xrCompatible: false,
                    loseContextOnDispose: true
                },
                true
            );
            TwinfinityViewer._engine.enableOfflineSupport = false;
            TwinfinityViewer._engine.doNotHandleContextLost = true;

            const resolution = {
                width: TwinfinityViewer._engine.getRenderWidth(),
                height: TwinfinityViewer._engine.getRenderHeight()
            };

            telemetry.trackEvent(
                { name: 'Initialized 3D engine' },
                {
                    ...TwinfinityViewer._engine.getGlInfo(),
                    aspectRatio: TwinfinityViewer._engine.getScreenAspectRatio(),
                    resolution,
                    hardwareConcurrency: navigator.hardwareConcurrency
                }
            );
        }

        TwinfinityViewer._isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;

        const instance = new TwinfinityViewer(canvas, materials, runRenderLoop);

        // Watch for browser/canvas resize events
        window.addEventListener('resize', (e) => {
            instance.reactToResize();
        });

        return instance;
    }

    private reactToResize(): void {
        if (TwinfinityViewer._engine) {
            TwinfinityViewer._engine.resize();
        } else {
            throw new Error('Trying to resize but no engine? Should not happen');
        }
    }

    public get isSkyboxEnabled(): boolean {
        return this._environment.isEnabledSkyBox;
    }

    public set isSkyboxEnabled(enabled: boolean) {
        this._environment.isEnabledSkyBox = enabled;
    }

    /**
     * Controls wheter to automatically adapt the near plane of the camera to preserve precision when viewing far away IFC objects when there are no close IFC objects
     */
    public get adaptiveMinZ(): boolean {
        return this._adaptiveMinZ;
    }

    public set adaptiveMinZ(enabled: boolean) {
        this._adaptiveMinZ = enabled;
        this.camera.activeCamera.minZ = ViewerCamera.cameraMinZ;
        // Wake up render loop. Renders meshes at least once which means that this._minZCalculator gets updated
        this.wakeRenderLoop();
    }

    public get isTouch(): boolean {
        return TwinfinityViewer._isTouch;
    }

    public get engine(): Engine {
        return TwinfinityViewer._engine!;
    }

    public get camera(): ViewerCamera {
        return this._environment.camera;
    }

    /**
     * Creates a renderer that that writes out the scenes depth to a render target texture which can be sampled for various uses like SSAO and line detection.
     * Unlike Babylonjs standard depth renderer, this renderer applies a custom depth material to the IFC meshes to be able to dicard the depth writes for fragments that are invisible
     *
     * @param storeNonLinearDepth Wheter or not the custom depth renderer material should write the depth linearly or logarithmic
     * @returns The depth renderer
     */
    public enableDepthRenderer(storeNonLinearDepth: boolean): ShortFrustumDepthRenderer {
        const camera = this.camera.activeCamera;
        const scene = this.scene;

        if (!this._depthRenderer || this._depthRenderer.isDisabled()) {
            if (this._environment.cameraState && this._environment.cameraState.checkIsChanged(true)) {
                scene.disableDepthRenderer();
                scene.disableGeometryBufferRenderer(); // Should not be needed
            }

            this._depthRenderer = new ShortFrustumDepthRenderer(
                storeNonLinearDepth,
                scene,
                camera,
                this._materials.getIFCMeshCustomDepthMaterial(scene, storeNonLinearDepth),
                this._materials.getBabylonMeshCustomDepthMaterial(scene, storeNonLinearDepth)
            );
        } else {
            if (storeNonLinearDepth !== this._depthRenderer.depthRenderStoresNonLinearDepth) {
                throw new Error('A depth viewer is already created with a different depth store mode');
            }
        }

        return this._depthRenderer;
    }

    public get ifcRootNode(): TransformNode {
        if (this._ifcRootNode !== undefined) {
            return this._ifcRootNode;
        }
        this._ifcRootNode = new TransformNode('ifcroot', this.scene);
        //this._ifcRootNode.freezeWorldMatrix();
        return this._ifcRootNode;
    }

    /**
     * @hidden
     * @internal
     */
    public get _cameraStateSnapshot(): TwinfinityCameraStateSnapshot | undefined {
        return this._environment.cameraState;
    }

    /**
     * Enables or disables the debug layer. Will only be available if the module
     * 'import "@twinfinity/core/internal/Debug' has also been added to the application. Adding it will
     * generate extra .js files during bundling (which are quite large) and will increase build times.
     * The extra files are loaded on demand though (when debug layer becomes enabled for the first time).
     * @param isEnabled `true` to enable. Otherwise `false`. If Not specified then current state of debug layer is returned.
     * @returns Promise which when resolved is `true` if layer is enabled. Otherwise `false`
     */
    public isDebugLayerEnabled(isEnabled?: boolean): Promise<boolean> {
        console.warn(
            `Debug layer is not available. Add 'import "@twinfinity/core/internal/Debug' to your application to enable it.`
        );
        return this._debugLayerEnabledPromise;
    }

    public get axes(): boolean {
        return this._environment.isEnabledWorldAxis;
    }

    public set axes(isEnabled: boolean) {
        this._environment.isEnabledWorldAxis = isEnabled;
    }

    /**
     * Forces render loop to temporarily wake up if it is sleeping
     */
    public wakeRenderLoop(): void {
        this._movementRenderTimeout.resetAndStart();
    }

    public clear(): void {
        this._environment.camera.clearPivot();

        // Clone map values since m.dispose also modifies this._gpuSelectables.meshes
        // (we listen to mesh.onDisposeObserveable to ensure that we remove meshes
        // from this._gpuSelectables.meshes)
        const meshesClone = [...this._gpuSelectables.meshes.values()];
        for (const m of meshesClone) {
            m.dispose();
        }

        // Cant track clicked if objects when everything has been removed.
        this._currentlyPickedItemKeys.clear();
        this._gpuSelectables.clear();
    }

    /**
     * @internal
     */
    public dispose(): void {
        this.clear();

        TwinfinityViewer._engine?.stopRenderLoop();

        this._depthRenderer?.dispose();
        this._depthRenderer = undefined;

        this._materials.dispose();

        this.scene.dispose();
    }

    /**
     * Adds or updates the environment settings for the viewer.
     * @param boundingInfo AABB.
     * This method calculates the starting position for the camera based on the provided `boundingInfo`.
     * It ensures that the entire `boundingInfo` is visible and sets the maximum z-coordinate of the {@link camera} to a value that
     * allows everything to be visible.
     * Additionally, it creates a skybox and sets its position and scaling based on the `boundingInfo`.
     * The skybox is only visible if the {@link isSkyboxEnabled} property is set to `true`.
     */
    public addOrUpdateEnvironment(boundingInfo: BoundingInfo): void;

    /**
     * Adds or updates the environment settings for the viewer.
     * @param options The options for the environment settings.
     * This method calculates the starting position for the camera based on the provided `boundingInfo`.
     * It ensures that the entire `boundingInfo` is visible and sets the maximum z-coordinate of the camera to a value that
     * allows everything to be visible.
     * Additionally, it creates a skybox and sets its position and scaling based on the `boundingInfo`.
     * The skybox is only visible if the {@link isSkyboxEnabled} property is set to `true`.
     */
    public addOrUpdateEnvironment(options: AddOrUpdateEnvironmentOptions): void;
    public addOrUpdateEnvironment(options: BoundingInfo | AddOrUpdateEnvironmentOptions): void {
        if (options instanceof BoundingInfo) {
            options = { boundingInfo: options };
        }
        const { boundingInfo: buildingBoundingInfo, positionCameraToFitBounds } = options;
        // NOTE This code is not quite correct. The idea is that we look at buildingBoundingInfo
        // so we can calculate a starting position for the camera (ideally so the entire building)
        // is visible. We also set zmax of the camera to a value so everything is visible (but still as small as possible).
        // we should probably also calculate a maximum bbox that the camera cannot go outside (we can check each frame if we
        // are outside and if so move inside again).
        // If possible alwatys render skybox in a separate scene from the building so building always overwrites the skybox
        // (or a similiar technique). If done properly then the skybox does not have to be so large since it always moves with
        // the camera anyway.
        // const sphere = this.scene.getMeshByName("sphere") ?? MeshBuilder.CreateSphere("sphere", {diameter: 1}, this.scene);
        // sphere.position = buildingBoundingInfo.boundingSphere.centerWorld;
        // sphere.scaling.scaleInPlace(buildingBoundingInfo.boundingSphere.radiusWorld * 2);
        if (!(positionCameraToFitBounds === false)) {
            this._environment.camera.zoomToExtent(buildingBoundingInfo, 'default');
        }
        this._environment.buildingBoundingInfo.reConstruct(buildingBoundingInfo.minimum, buildingBoundingInfo.maximum);
        // const freeCamera = this._environment.camera;
        // const camDistance = this.getCameraDistanceRequiredForFullView(
        //     buildingBoundingInfo.boundingSphere,
        //     freeCamera.fov
        // );

        // this._environment.camera.position.copyFrom(buildingBoundingInfo.boundingSphere.center);
        // freeCamera.position.z -= camDistance;
        // freeCamera.setTarget(buildingBoundingInfo.boundingSphere.center);

        this._environment.worldAxis?.dispose();
        this._environment.worldAxis = createAxis(
            'worldAxis',
            buildingBoundingInfo.minimum,
            buildingBoundingInfo.boundingSphere.radiusWorld,
            this.scene,
            { axisArrows: true, axisText: true }
        );

        const skyboxScale = buildingBoundingInfo.diagonalLength * 10;
        this._environment.skybox.scaling.setAll(skyboxScale);
        this._environment.skybox.position.copyFrom(buildingBoundingInfo.boundingSphere.center);
        this._environment.camera.maxZ = skyboxScale * 1.2;

        // const wE = this.scene.getWorldExtends(); // Skybox is taken into account here
        // const worldBbox = new BoundingInfo(wE.min, wE.max);

        // const width = worldBbox.maximum.x - worldBbox.minimum.x;
        // const depth = worldBbox.maximum.z - worldBbox.minimum.z;
        // const worldMaxSize = Math.max(width, depth);

        // const worldBbox = new BoundingInfo(wE.min, wE.max);
        // TODO fix this
        // this._environment.camera.maxZ = worldBbox.diagonalLength;

        // Kick of rendering loop
        this.ensureRenderLoopRunning();
    }

    private ensureRenderLoopRunning(): void {
        if (!this._isRenderLoopStarted) {
            this._isRenderLoopStarted = true;

            // Set up begin frame and end frame observables. Must be done before first call to scene.render()
            this.engine.onBeginFrameObservable.add((_engine, eS) => {
                // Need to update minZ based on min distance from previous frames visible meshes
                if (
                    this._adaptiveMinZ &&
                    !Scalar.WithinEpsilon(this.camera.minZ, this._meshDistanceToCamera.minDistanceToCamera, 1)
                ) {
                    this.camera.minZ = this._meshDistanceToCamera.minDistanceToCamera;
                    this.camera.activeCamera.getProjectionMatrix(true);
                }

                this._meshDistanceToCamera.beginFrame();

                // Reset statistics
                this.statistics.mesh.triangleCount.fetchNewFrame();
                this.statistics.mesh.verticeCount.fetchNewFrame();
                this.statistics.mesh.count.fetchNewFrame();

                this.updateGridVisuals();
                this.updateSkyboxVisuals();
                this.updateWorldAxesVisuals();
            });

            this.engine.onEndFrameObservable.add((_engine, eS) => {
                this.scene.activeCamera?.twinfinity.clearHasMovedInCurrentFrame();
                this.statistics.mesh.count.addCount(0, true);
                this.statistics.mesh.triangleCount.addCount(0, true);
                this.statistics.mesh.verticeCount.addCount(0, true);
            });

            this.scene.render(); // Ensures that view and projection matrices are set up

            this._movementRenderTimeout.resetAndStart();
            const forceRenderTimeout = new StopWatch(true);
            this._movementRenderTimeout.resetAndStart();
            let oldMaterialsState: Record<string, unknown> | undefined = undefined;

            const p = this.performance;

            // Whenever we move mouse or touch keyboard we need to keep renderloop running
            this.scene.onPrePointerObservable.add(() => this._movementRenderTimeout.resetAndStart());
            this.scene.onPreKeyboardObservable.add(() => this._movementRenderTimeout.resetAndStart());

            const renderLoop = (): void => {
                const camera = this.scene.activeCamera;
                if (!camera) {
                    return;
                }

                this._materials.isBackfaceCullingEnabled = p.isBackfaceCullingEnabled;

                const rect = boundingClientRectCache.getOrAdd(this.canvas);
                const isCanvasSizeChanged = !(
                    Scalar.WithinEpsilon(this._lastCanvasSize.width, rect.width) &&
                    Scalar.WithinEpsilon(this._lastCanvasSize.height, rect.height)
                );
                if (isCanvasSizeChanged) {
                    this._lastCanvasSize.width = rect.width;
                    this._lastCanvasSize.height = rect.height;
                    this.reactToResize();
                }

                // Start by checking if camera has changed since the last frame we rendered.
                this._cameraChangedThisFrame = false;
                if (this._environment.cameraState === undefined || this._environment.cameraState.camera !== camera) {
                    this._environment.cameraState = camera.twinfinity.stateSnapshot();
                    this._cameraChangedThisFrame = true;
                } else {
                    this._cameraChangedThisFrame = this._environment.cameraState.refresh();
                }

                if (this._cameraChangedThisFrame) {
                    this._timeSinceLastCameraMove.resetAndStart();
                    this._movementRenderTimeout.resetAndStart(); // Since camera was changed we reset the idle timer
                }

                if (p.isPaused) {
                    return;
                }

                const isVariableRederingEnabled = p.idleRenderingTimeoutInMs > 0 && p.forceRenderingTimeoutInMs > 0;
                if (!isVariableRederingEnabled) {
                    this.scene.render();
                    return;
                }

                if (camera) {
                    // A frame must be rendered if we have movement, if N ms has elapsed, if materials state is not same (so IFC object may have changed color/visibilit etc)
                    // or if camera is not the default camera (in which case we cannot yet detect movement).
                    const isMovementDetected = this._movementRenderTimeout.totalElapsed < p.idleRenderingTimeoutInMs;
                    const isRenderFrameForced = forceRenderTimeout.totalElapsed > p.forceRenderingTimeoutInMs;

                    // Animate any animated textures if the camera was moved
                    if (this._cameraChangedThisFrame) {
                        this._materials.updateAnimatedTextures();
                    }

                    const notDefaultCamera = camera !== this._environment.camera.activeCamera;
                    const renderFrame =
                        notDefaultCamera ||
                        isMovementDetected ||
                        isRenderFrameForced ||
                        oldMaterialsState !== this._materials.state;

                    oldMaterialsState = this._materials.state;

                    if (renderFrame) {
                        if (this._depthRenderer) {
                            this._depthRenderer.setPause(false);
                        }

                        this.scene.render();
                        forceRenderTimeout.resetAndStart();
                    } else {
                        // If we dont render we have to manually check inputs. (otherwise
                        // movement cannot be detected.
                        camera.inputs.checkInputs();

                        if (this._depthRenderer) {
                            this._depthRenderer.setPause(true);
                        }
                    }
                }
            };

            this._runRenderLoop(() => this.engine.runRenderLoop(renderLoop));
        }
    }

    /**
     * @deprecated Use {@link ViewerCamera.pick} instead.
     */
    public getAndUpdatePickCamera(rayOrScreenCoordinate?: Ray | CanvasPosition): TargetCamera {
        return this._gpuSelectables.getAndUpdatePickCamera(rayOrScreenCoordinate ?? PredefinedCanvasPosition.Mouse);
    }

    /**
     * @deprecated Use {@link ViewerCamera.pick} instead.
     */
    public pick(
        pickingCamera: TargetCamera,
        textureSizePow2 = 16,
        doIntersectionTestOnGeometry = false,
        saveRenderedGpuPickingSceneTexture = false
    ): PickResult {
        return this._gpuSelectables.pick(
            pickingCamera,
            textureSizePow2,
            doIntersectionTestOnGeometry,
            saveRenderedGpuPickingSceneTexture
        );
    }

    // TODO Make it possible to add own mesh here instead! That would make it possible to register it
    //      in materials, set uv coordinates and get correct material. If we are carefull it should be possible
    //      to register any meshtype (intances, thinInstances etc). During gpu picking we have to be careful to turn
    //      off for example vertexColors. Possible to mark meshes as NOT alpha transparent? Yes that is on material I believe

    /**
     * Adds a mesh to the scene based on the provided geometry. If a mesh with the same ID already exists, it will be removed and replaced with the new one.
     *
     * @param geometry3D Mesh to add or replace
     * @returns Babylon mesh that was added
     */
    public addOrReplaceMesh(geometry3D: Geometry3d): Mesh {
        let bMesh = this.removeMesh(geometry3D);

        bMesh = new Mesh(geometry3D.id, this.scene, this.ifcRootNode);

        bMesh.onDisposeObservable.addOnce((n, e) => this._gpuSelectables.meshes.delete(n.id));

        bMesh.isPickable = false;
        bMesh.useOctreeForRenderingSelection = true;
        bMesh.useOctreeForCollisions = false;
        bMesh.useOctreeForPicking = false;
        //bMesh.doNotSyncBoundingInfo = true; // BabylonJS picking wont work
        this._gpuSelectables.meshes.set(bMesh.id, bMesh);

        // NOTE very important to apply vertexData before we add
        // submeshes otherwise submeshes will be recreated which costs A LOT

        bMesh.material = this._materials.get(geometry3D.isTransparent!, this.scene);
        geometry3D.applyToMesh(bMesh, TwinfinityViewer._engine!);

        bMesh.twinfinity.ifc = new TwinfinityIfcMeshExtension(bMesh, this._materials, geometry3D);

        bMesh.setEnabled(true);
        bMesh.isVisible = true;
        //bMesh.showBoundingBox = true;

        //bMesh.computeWorldMatrix(true); // Normally done in SubMesh.ctor when createBoundingBox = true, so we do it manually here

        // bMesh.occlusionQueryAlgorithmType = AbstractMesh.OCCLUSION_ALGORITHM_TYPE_CONSERVATIVE;
        // bMesh.occlusionType = AbstractMesh.OCCLUSION_TYPE_OPTIMISTIC;
        //bMesh.occlusionQueryAlgorithmType =
        //   AbstractMesh.;
        //bMesh.createOrUpdateSubmeshesOctree();
        //bMesh.freezeWorldMatrix();

        // Clear vertex data that is now in GPU. Babylon picking and collisions will not work
        // on these objects
        bMesh.twinfinity.clearCachedVertexData();
        //bMesh.freezeWorldMatrix();
        // const numIndices = vertexData.indices.length;
        // if (cullingDistance < 0) {
        //     bMesh.occlusionType = AbstractMesh.OCCLUSION_ALGORITHM_TYPE_CONSERVATIVE;
        //     //bMesh.occlusionRetryCount = 0;
        //     bMesh.isOccluded = false;
        // }
        //monitor.logTrace(`numIndices: ${numIndices}`);
        return bMesh;
    }

    public removeMesh(geometry3D: Geometry3dHandle): Mesh | undefined {
        const bMesh = this._gpuSelectables.meshes.get(geometry3D.id);
        if (bMesh) {
            bMesh.dispose();
        }
        geometry3D._resetBuildId();
        return bMesh;
    }

    /**
     * Diagnostic method to measure actual GPU buffer memory usage.
     * Call from browser console: viewer.measureGpuBufferMemory()
     */
    public measureGpuBufferMemory(): { totalBytes: number; bufferCount: number; meshCount: number } {
        const gl = (TwinfinityViewer._engine as unknown as { _gl: WebGLRenderingContext })._gl;
        let totalBytes = 0;
        let bufferCount = 0;
        let meshCount = 0;

        for (const mesh of this.scene.meshes) {
            if (!(mesh instanceof Mesh) || !mesh.geometry) continue;
            meshCount++;

            const geometry = mesh.geometry;
            const vbs = geometry.getVertexBuffers();
            if (vbs) {
                for (const vbName in vbs) {
                    const vb = vbs[vbName];
                    const dataBuffer = vb.getBuffer();
                    const webglBuffer = dataBuffer?.underlyingResource as WebGLBuffer | null;
                    if (webglBuffer) {
                        gl.bindBuffer(gl.ARRAY_BUFFER, webglBuffer);
                        const size = gl.getBufferParameter(gl.ARRAY_BUFFER, gl.BUFFER_SIZE);
                        totalBytes += size;
                        bufferCount++;
                    }
                }
            }

            const indexDataBuffer = geometry.getIndexBuffer();
            const indexWebglBuffer = indexDataBuffer?.underlyingResource as WebGLBuffer | null;
            if (indexWebglBuffer) {
                gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexWebglBuffer);
                const size = gl.getBufferParameter(gl.ELEMENT_ARRAY_BUFFER, gl.BUFFER_SIZE);
                totalBytes += size;
                bufferCount++;
            }
        }

        gl.bindBuffer(gl.ARRAY_BUFFER, null);
        gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

        console.log(
            `[GPU Memory] Total: ${(totalBytes / 1024 / 1024).toFixed(2)} MB | Buffers: ${bufferCount} | Meshes: ${meshCount}`
        );
        return { totalBytes, bufferCount, meshCount };
    }

    private onCustomLODSelector(mesh: AbstractMesh, camera: Camera): Nullable<AbstractMesh> {
        let isVisible = true;

        if (mesh instanceof Mesh && mesh.twinfinity.ifc !== undefined) {
            //this.statistics.distanceCulling.time.beginMonitoring();

            const ifc = mesh.twinfinity.ifc;
            isVisible = ifc.isVisible(camera);
            // if (isVisible) {
            //     this.statistics.distanceCulling.triangleCount.addCount(ifc.indiceCount / 3, false);
            //     this.statistics.distanceCulling.verticeCount.addCount(ifc.primitiveCount, false);
            //     this.statistics.distanceCulling.meshCount.addCount(1, false);
            // }
            //this.statistics.distanceCulling.time.endMonitoring(false);
        }
        this._adaptiveMinZ && isVisible && this._meshDistanceToCamera.trackDistanceToCamera({ mesh, camera });
        return isVisible ? mesh : null;
    }

    /**
     * Enables or disables a clip plane, indexed from 1 to 6. Enabling a clip plane means that all geometry inside the clip plane will not be rendered.
     * Note that this is only clipping for the IFC models, regular Babylon meshes needs to have clipping applied through the clip planes in Babylons scene
     * Like this: https://doc.babylonjs.com/features/featuresDeepDive/scene/clipPlanes, the docs says that only 4 planes are available, but there are 6
     *
     * @param clipPlaneOptions An object with the options for enabling or disabling clip planes.
     */

    public setClipPlane(clipPlaneOptions: ClipPlaneOptions): void {
        const clipPlane = this.visualSettings.ifc.clipPlanes[clipPlaneOptions.clipPlaneIndex];
        if (clipPlane) {
            const enabled = clipPlaneOptions.enabled;
            clipPlane.enabled = enabled;
            if (enabled) {
                if (
                    clipPlaneOptions.ignoreDepthWrite !== undefined &&
                    clipPlaneOptions.ignoreDepthWrite !== clipPlane.ignoreDepthWrite
                ) {
                    clipPlane.ignoreDepthWrite = clipPlaneOptions.ignoreDepthWrite;
                }
                const fadeSize = clipPlaneOptions.fadeSize;
                if (fadeSize !== undefined) {
                    if (fadeSize < 0.0) {
                        throw new Error('FadeSize must be equal to or larger than 0');
                    }
                }
                const point = clipPlaneOptions.point;

                const normalVector = copyVertex3ToRef(
                    clipPlaneOptions.normalVector,
                    TwinfinityViewer._temp.vectorA
                ).normalize();

                const color = clipPlaneOptions.color;

                if (normalVector.lengthSquared() > 0.0) {
                    copyVertex3ToRef(point, clipPlane.position);
                    copyVertex3ToRef(normalVector, clipPlane.normal);
                } else {
                    throw new Error('Normal not set');
                }
                if (color) {
                    clipPlane.color.r = color.r;
                    clipPlane.color.g = color.g;
                    clipPlane.color.b = color.b;
                }
                if (fadeSize !== undefined) {
                    clipPlane.fadeSize = fadeSize;
                }
                const nX = clipPlane.normal.x;
                const nY = clipPlane.normal.y;
                const nZ = clipPlane.normal.z;
                const pX = clipPlane.position.x;
                const pY = clipPlane.position.y;
                const pZ = clipPlane.position.z;
                const normalVec3 = new Vector3(nX, nY, nZ);
                const pointVec3 = new Vector3(pX, pY, pZ);
                const d = Vector3.Dot(normalVec3, pointVec3);
                clipPlane.plane.normal.set(nX, nY, nZ);
                clipPlane.plane.d = -d;
            }
            // Re-render
            this.wakeRenderLoop();
        } else {
            throw new Error('Unknown clipplane index');
        }
    }

    /**
     * Changes how transparency is handled for transparent IFC objects
     * @param transparencyMode The type of rendering that will be used for all transparent IFC objects, dithering means that pixels are
     * rendered opaque but skipping every few pixels in a screen pattern depending on the opacity
     * Seethrough is normal alpha blending
     */
    public setTransparencyMode(transparencyMode: TransparencyMode): void {
        this.visualSettings.ifc.transparency.mode = transparencyMode;
    }

    /**
     * Gets the currently active mode of transparency
     */
    public getTransparencyMode(): TransparencyMode {
        return this.visualSettings.ifc.transparency.mode;
    }

    /**
     * Set the dither mode and animated frames used for dithering
     * @param ditherMode What dithering mode to use, bayer uses a bayer matrix to determine which pixels to render opaque, blue noise uses a blue noise texture
     * @param animatedFrames How many frames to use for the dithering animation when moving the camera, if 1 then no animation is used. Works best with Blue Noise
     */
    public setDitheringMode(ditherMode: DitheringTextureMode, animatedFrames: number): void {
        this.visualSettings.ifc.transparency.ditheringOptions = {
            mode: ditherMode,
            animatedFrames,
            ditherDiscardDepth: this.visualSettings.ifc.transparency.ditheringOptions.ditherDiscardDepth,
            distanceFade: this.visualSettings.ifc.transparency.ditheringOptions.distanceFade
        };

        this._materials.updateDitherTexture(this.visualSettings.ifc.transparency.ditheringOptions);
    }

    /**
     * Set the currently used dither mode for solid objects that are dithered, never for transparent objects, even if the transparency mode is dithering
     * @param ditherDiscardDepth Wheter or not to discard fragments in the depth shader that are dithered, this is needed for transparent objects to not write to the depth buffer
     */
    public setDitheringDepthDiscardMode(ditherDiscardDepth: boolean): void {
        this.visualSettings.ifc.transparency.ditheringOptions = {
            mode: this.visualSettings.ifc.transparency.ditheringOptions.mode,
            animatedFrames: this.visualSettings.ifc.transparency.ditheringOptions.animatedFrames,
            ditherDiscardDepth: ditherDiscardDepth,
            distanceFade: this.visualSettings.ifc.transparency.ditheringOptions.distanceFade
        };

        // No need to update the dither texture here because none of the changes this method invokes are relevant to that texture
    }

    private updateGridVisuals(): void {
        this._environment.gridMesh.setEnabled(this.grid.isEnabled);
        if (this.grid.isEnabled) {
            this._environment.gridMesh.scaling.x = this._environment.skybox.scaling.x;
            this._environment.gridMesh.scaling.z = this._environment.skybox.scaling.z;
            this._environment.gridMesh.position.copyFrom(this._environment.skybox.position);
            this._environment.gridMesh.position.y = this.grid.y ?? this._environment.buildingBoundingInfo.minimum.y;
            if (!this._environment.gridMaterial.opacityTexture) {
                this._environment.gridMaterial.opacityTexture = new Texture(
                    'https://assets.babylonjs.com/environments/backgroundGround.png',
                    this.scene
                );
            }

            if (!this.grid.lineColor) {
                // If no line color then use inverted clearColor
                const clearColor = this.scene.clearColor;
                this._environment.gridMaterial.lineColor.set(
                    1.0 - clearColor.r,
                    1.0 - clearColor.g,
                    1.0 - clearColor.b
                );
            } else {
                this._environment.gridMaterial.lineColor.copyFrom(this.grid.lineColor);
            }
            // One line per N meter. Default is 1 m
            this._environment.gridMaterial.gridRatio =
                1.0 / (this._environment.gridMesh.scaling.x / (this.grid.lineDistanceInMeter ?? 1.0));
        }
    }

    private updateWorldAxesVisuals(): void {
        this._environment.worldAxis?.setEnabled(this._environment.isEnabledWorldAxis);
    }
    private updateSkyboxVisuals(): void {
        this._environment.skybox?.setEnabled(this._environment.isEnabledSkyBox);
    }
}
