import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";
import { Engine } from "@babylonjs/core/Engines/engine";
import { WebGPUEngine } from "@babylonjs/core/Engines/webgpuEngine";
import { StandardMaterial } from "@babylonjs/core/Materials/standardMaterial";
import { DynamicTexture } from "@babylonjs/core/Materials/Textures/dynamicTexture";
import { Color3, Color4 } from '@babylonjs/core/Maths/math.color';
import { Matrix, Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector'
import { Viewport } from "@babylonjs/core/Maths/math.viewport";
import { Mesh } from "@babylonjs/core/Meshes/mesh";
import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";
import { Scene } from "@babylonjs/core/scene";
import { Animation, Effect, EngineStore, PickingInfo, Ray, SolidParticle, VertexBuffer } from '@babylonjs/core'
import { SolidParticleSystem } from "@babylonjs/core/Particles/solidParticleSystem";
import { AdomStatics } from "../AdomStatics";
import { HemisphericLight } from '@babylonjs/core/Lights/hemisphericLight';
import { ShaderMaterial } from '@babylonjs/core/Materials/shaderMaterial'
import { BoundingInfo } from '@babylonjs/core/Culling/boundingInfo'

export interface ViewCubeUICallbacks {
    onTooltip: (text: string | null, x: number, y: number) => void
    onHomeClick: () => void
    onFitClick: () => void
    onViewModeClick: (x: number, y: number) => void
}

export class ViewCubeBuilder {
    private static prevPick: number = -1
    private static prevDarkened: number = -1
    private static particleColorMap: Map<number, Color4> = new Map()

    /**
     * Creates a combined texture atlas for all six faces of the cube
     * @internal
     */
    static _createCombinedTexture(scene: Scene) {
        const size = 512
        const dynamicTexture = new DynamicTexture(
            'CombinedTexture',
            { width: size * 3, height: size * 3 },
            scene,
            true
        )
        const context = dynamicTexture.getContext()
        dynamicTexture.hasAlpha = true

        // Define the face names in the texture atlas layout
        const texts = [
            ['FRONT', 'BACK', 'RIGHT'],
            ['LEFT', 'BOT', 'TOP']
        ]

        // Fill background with white
        context.fillStyle = 'white'
        context.fillRect(0, 0, size * 3, size * 2)

        // Setup text style for face labels
        context.font = '150px Arial'
        context.textAlign = 'center'
        context.textBaseline = 'middle'
        context.fillStyle = 'black'

        // Draw each face text
        for (let row = 0; row < 2; row++) {
            for (let col = 0; col < 3; col++) {
                context.fillText(texts[row][col], (col + 0.5) * size, (row + 0.5) * size)
            }
        }

        // Setup text style for axis labels
        context.font = 'bold 120px Arial'
        context.fillStyle = 'red'
        context.fillText('X', 0.5 * size, (2 + 0.5) * size)

        context.fillStyle = 'green'
        context.fillText('Y', (1 + 0.5) * size, (2 + 0.5) * size)

        context.fillStyle = 'blue'
        context.fillText('Z', (2 + 0.5) * size, (2 + 0.5) * size)

        dynamicTexture.update()
        return dynamicTexture
    }

    /**
     * Normalizes an angle between -PI and PI
     * @internal
     */
    static _normalizeAngle(angle: number): number {
        return angle - Math.PI * 2 * Math.floor((angle + Math.PI) / (2 * Math.PI))
    }

    /**
     * Calculates the shortest angle distance between two angles
     * @internal
     */
    static _shortestAngleDistance(from: number, to: number): number {
        const normalizedFrom = ViewCubeBuilder._normalizeAngle(from)
        const normalizedTo = ViewCubeBuilder._normalizeAngle(to)
        const difference = normalizedTo - normalizedFrom
        if (difference > Math.PI) {
            return difference - Math.PI * 2
        } else if (difference < -Math.PI) {
            return difference + Math.PI * 2
        }
        return difference
    }

    /**
     * Animates camera to a new position
     * @internal
     */
    static _animateCameraToPosition(
        camera: ArcRotateCamera,
        newAlpha: number,
        newBeta: number,
        newRadius: number,
        duration: number
    ) {
        const fps = 60
        const totalFrames = fps * duration
        const targetAlpha =
            camera.alpha + ViewCubeBuilder._shortestAngleDistance(camera.alpha, newAlpha)
        const targetBeta =
            camera.beta + ViewCubeBuilder._shortestAngleDistance(camera.beta, newBeta)

        const alphaAnimation = new Animation(
            'alphaAnimation',
            'alpha',
            fps,
            Animation.ANIMATIONTYPE_FLOAT,
            Animation.ANIMATIONLOOPMODE_CONSTANT
        )

        const alphaKeys = [
            { frame: 0, value: camera.alpha },
            { frame: totalFrames, value: targetAlpha }
        ]
        alphaAnimation.setKeys(alphaKeys)

        const betaAnimation = new Animation(
            'betaAnimation',
            'beta',
            fps,
            Animation.ANIMATIONTYPE_FLOAT,
            Animation.ANIMATIONLOOPMODE_CONSTANT
        )

        const betaKeys = [
            { frame: 0, value: camera.beta },
            { frame: totalFrames, value: targetBeta }
        ]
        betaAnimation.setKeys(betaKeys)

        const radiusAnimation = new Animation(
            'radiusAnimation',
            'radius',
            fps,
            Animation.ANIMATIONTYPE_FLOAT,
            Animation.ANIMATIONLOOPMODE_CONSTANT
        )

        const radiusKeys = [
            { frame: 0, value: camera.radius },
            { frame: totalFrames, value: newRadius }
        ]
        radiusAnimation.setKeys(radiusKeys)

        camera.animations = [alphaAnimation, betaAnimation, radiusAnimation]
        camera.getScene().beginAnimation(camera, 0, totalFrames, false)
    }

    static createViewCube(
        mainScene: Scene,
        mainCamera: ArcRotateCamera,
        engine: WebGPUEngine | Engine,
        uiCallbacks?: ViewCubeUICallbacks
    ) {
        const scene2 = new Scene(engine)
        scene2.autoClear = false
        scene2.useRightHandedSystem = true

        const camera2 = new ArcRotateCamera('camera2', 0, Math.PI / 2, 30, Vector3.Zero(), scene2)
        camera2.upVector = new Vector3(0, 0, 1) // Set Z as up
        camera2.viewport = new Viewport(0.85, 0.85, 0.15, 0.15)

        const light = new HemisphericLight('light', new Vector3(0, 1, 0), scene2)
        light.intensity = 1
        light.groundColor = new Color3(1, 1, 1)
        light.specular = Color3.Black()


        let homeButtonMesh: Mesh = null!
        let fitButtonMesh: Mesh = null!
        let viewModeButtonMesh: Mesh = null!
        let homeMat: StandardMaterial = null!
        let fitMat: StandardMaterial = null!
        let viewModeMat: StandardMaterial = null!
        let homeTexNormal: DynamicTexture = null!
        let homeTexHover: DynamicTexture = null!
        let fitTexNormal: DynamicTexture = null!
        let fitTexHover: DynamicTexture = null!
        let vmPerspNormal: DynamicTexture = null!
        let vmPerspHover: DynamicTexture = null!
        let vmOrthoNormal: DynamicTexture = null!
        let vmOrthoHover: DynamicTexture = null!
        let currentCameraMode = mainCamera.mode
        let homeHovered = false
        let fitHovered = false
        let viewModeHovered = false
        let buttonsAlpha = 0

        mainScene.afterRender = () => {
            // The cube scene is right-handed (scene2.useRightHandedSystem).
            // When the MAIN scene is left-handed (zUp=false) the camera azimuth
            // has the opposite sense, so copying mainCamera.alpha verbatim makes
            // the cube orbit backwards. Reflect alpha (PI - alpha) only in that
            // mismatched case — it reverses the horizontal direction and is the
            // identity at alpha=PI/2 so the home FRONT face still lines up. When
            // the main scene is also right-handed (zUp=true) the senses match
            // and alpha is copied directly. Beta is unaffected by handedness.
            camera2.alpha = mainScene.useRightHandedSystem
                ? mainCamera.alpha
                : Math.PI - mainCamera.alpha
            camera2.beta = mainCamera.beta
            camera2.lowerAlphaLimit = null
            camera2.lowerBetaLimit = null
            camera2.upperBetaLimit = null
            camera2.upperRadiusLimit = null

            // Ensure the ViewCube viewport is at least minVpPx pixels so the
            // cube stays readable at small canvas sizes. The center is kept
            // at (0.925, 0.925) — same as the default — and only shifts when
            // the viewport would exceed the canvas edge.
            const canvasW = engine.getRenderWidth()
            const canvasH = engine.getRenderHeight()
            const minVpPx = 120
            const defaultFrac = 0.15
            const vpNormW = Math.min(Math.max(canvasW * defaultFrac, minVpPx) / canvasW, 0.3)
            const vpNormH = Math.min(Math.max(canvasH * defaultFrac, minVpPx) / canvasH, 0.3)
            const vpX = Math.max(0, Math.min(1 - vpNormW, 0.925 - vpNormW / 2))
            const vpY = Math.max(0, Math.min(1 - vpNormH, 0.925 - vpNormH / 2))
            camera2.viewport = new Viewport(vpX, vpY, vpNormW, vpNormH)

            if (homeButtonMesh) {
                const depth = 9
                const fovHalf = (camera2.fov || 0.8) / 2
                const halfH = depth * Math.tan(fovHalf)
                const vpW = canvasW * camera2.viewport.width
                const vpH = canvasH * camera2.viewport.height
                const aspect = vpW / vpH
                const halfW = halfH * aspect

                // Button layout constants (at default scale, view-space at depth 9)
                const btnDiam = 1.5
                const btnGap = 0.05
                const edgePad = 0.15
                const cubePad = 0.3

                // Everything (cube projection, button size, gaps) scales
                // proportionally with 1/R, keeping the full-screen visual
                // ratio at every viewport size.
                const defaultR = 14
                const cubeProj0 = 2.2 * depth / defaultR
                const totalAtDefault = cubeProj0 + cubePad + btnDiam + edgePad

                let R = defaultR
                if (totalAtDefault > halfW) {
                    // Viewport too narrow — zoom out just enough to fit.
                    // total(R) = (2.2*depth + (cubePad+btnDiam+edgePad)*defaultR) / R
                    const k = 2.2 * depth + (cubePad + btnDiam + edgePad) * defaultR
                    R = Math.min(k / halfW, defaultR * 2.5)
                }
                camera2.radius = R
                const scale = defaultR / R

                const cubeProj = 2.2 * depth / R
                const invViewMat = Matrix.Invert(camera2.getViewMatrix())

                const sz = btnDiam * scale
                const step = (btnDiam + btnGap) * scale
                const cPad = cubePad * scale
                const ePad = edgePad * scale

                // Hard-floor: buttons are ALWAYS to the right of the cube.
                const xMin = cubeProj + cPad + sz / 2
                const xMax = halfW - ePad - sz / 2
                let xCenter: number
                if (xMin <= xMax) {
                    xCenter = (xMin + xMax) / 2
                } else {
                    // Extreme narrow: let buttons clip at viewport edge,
                    // but never move into the cube.
                    xCenter = xMin
                }
                const yTop = halfH - ePad - sz / 2

                // Hover fade: show buttons only when mouse is in the ViewCube viewport
                const vpPixelLeft = camera2.viewport.x * canvasW
                const vpPixelTop = (1 - camera2.viewport.y - camera2.viewport.height) * canvasH
                const mx = mainScene.pointerX
                const my = mainScene.pointerY
                const inViewport = mx >= vpPixelLeft && mx <= vpPixelLeft + vpW &&
                                   my >= vpPixelTop && my <= vpPixelTop + vpH

                const fadeTarget = inViewport ? 1.0 : 0.0
                buttonsAlpha += (fadeTarget - buttonsAlpha) * 0.12
                if (buttonsAlpha < 0.01) buttonsAlpha = 0
                if (buttonsAlpha > 0.99) buttonsAlpha = 1

                // Dismiss tooltip when buttons fade out
                if (!inViewport && uiCallbacks) {
                    uiCallbacks.onTooltip(null, 0, 0)
                }

                const pickable = buttonsAlpha > 0.1
                homeButtonMesh.visibility = buttonsAlpha
                fitButtonMesh.visibility = buttonsAlpha
                viewModeButtonMesh.visibility = buttonsAlpha
                homeButtonMesh.isPickable = pickable
                fitButtonMesh.isPickable = pickable
                viewModeButtonMesh.isPickable = pickable

                homeButtonMesh.scaling.setAll(scale)
                homeButtonMesh.position = Vector3.TransformCoordinates(
                    new Vector3(xCenter, yTop, -depth), invViewMat
                )
                fitButtonMesh.scaling.setAll(scale)
                fitButtonMesh.position = Vector3.TransformCoordinates(
                    new Vector3(xCenter, yTop - step, -depth), invViewMat
                )
                viewModeButtonMesh.scaling.setAll(scale)
                viewModeButtonMesh.position = Vector3.TransformCoordinates(
                    new Vector3(xCenter, yTop - step * 2, -depth), invViewMat
                )
            }

            // Detect external camera mode changes (e.g. API calls)
            if (viewModeMat && mainCamera.mode !== currentCameraMode) {
                currentCameraMode = mainCamera.mode
                const isOrtho = currentCameraMode === 1
                viewModeMat.diffuseTexture = viewModeHovered
                    ? (isOrtho ? vmOrthoHover : vmPerspHover)
                    : (isOrtho ? vmOrthoNormal : vmPerspNormal)
            }

            scene2.render()
        }

        const cr = 2 // Cube radius
        const es = 0.75 // Edge sizing

        const sps = this.generateSPS(scene2, cr, es)

        sps.initParticles = () => {
            this.initParticlePositions(sps, cr, es)
        }

        // Update the label planes to always face the camera
        sps.updateParticle = particle => {
            this.billboardLabelPlanes(particle, camera2)
            return particle
        }

        const viewCubeMesh = sps.buildMesh()

        //TODO - Refactor to add wanted colors to the atlas and then adjust uvs accordingly.
        // Removes the need for the shader material and buffer which will save performance.

        // Shader material is used selectively choose which meshes to apply the texture to.
        const textureAtlas = ViewCubeBuilder._createCombinedTexture(scene2)
        const shaderMaterial = this.createShaderMaterial(scene2)
        shaderMaterial.setTexture('textureSampler', textureAtlas)
        shaderMaterial.alphaMode = Engine.ALPHA_ADD
        viewCubeMesh.material = shaderMaterial

        //Buffer for particle IDs, used to ignore the applied texture
        const NUM_VERTS_PER_PLANE = 4
        const NUM_TEXTURED_PLANES = 9
        const planeVertCount = NUM_VERTS_PER_PLANE * NUM_TEXTURED_PLANES
        const particleIDs = new Float32Array(viewCubeMesh.getTotalVertices())
        for (let i = 0; i < planeVertCount; i++) {
            particleIDs[i] = 2
        }
        const idBuffer = new VertexBuffer(engine, particleIDs, 'particleID', false, false, 1)
        viewCubeMesh.setVerticesBuffer(idBuffer)

        sps.initParticles()
        sps.setParticles()

        // Visibility box is important for picking,
        // Set to same size as cube to ignore axis and the labels planes
        sps.setVisibilityBox(4)
        sps.isVisibilityBoxLocked = true

        // --- Home Button (Fusion 360-style) ---
        homeButtonMesh = MeshBuilder.CreatePlane('homeButton', { size: 1.5 }, scene2)
        homeButtonMesh.billboardMode = Mesh.BILLBOARDMODE_ALL
        homeButtonMesh.isPickable = true
        homeButtonMesh.enablePointerMoveEvents = true

        homeTexNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._homeIconContent(false), false)
        homeTexHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._homeIconContent(true), true)

        homeMat = new StandardMaterial('homeButtonMat', scene2)
        homeMat.diffuseTexture = homeTexNormal
        homeMat.emissiveColor = new Color3(1, 1, 1)
        homeMat.disableLighting = true
        homeMat.useAlphaFromDiffuseTexture = true
        homeMat.backFaceCulling = false
        homeButtonMesh.material = homeMat

        // --- Fit / Zoom-to-Fit Button ---
        fitButtonMesh = MeshBuilder.CreatePlane('fitButton', { size: 1.5 }, scene2)
        fitButtonMesh.billboardMode = Mesh.BILLBOARDMODE_ALL
        fitButtonMesh.isPickable = true
        fitButtonMesh.enablePointerMoveEvents = true

        fitTexNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._fitIconContent(false), false)
        fitTexHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._fitIconContent(true), true)

        fitMat = new StandardMaterial('fitButtonMat', scene2)
        fitMat.diffuseTexture = fitTexNormal
        fitMat.emissiveColor = new Color3(1, 1, 1)
        fitMat.disableLighting = true
        fitMat.useAlphaFromDiffuseTexture = true
        fitMat.backFaceCulling = false
        fitButtonMesh.material = fitMat

        // --- View Mode Button (Ortho/Perspective toggle) ---
        viewModeButtonMesh = MeshBuilder.CreatePlane('viewModeButton', { size: 1.5 }, scene2)
        viewModeButtonMesh.billboardMode = Mesh.BILLBOARDMODE_ALL
        viewModeButtonMesh.isPickable = true
        viewModeButtonMesh.enablePointerMoveEvents = true

        const isOrthoInit = mainCamera.mode === 1
        vmPerspNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(false, false), false)
        vmPerspHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(true, false), true)
        vmOrthoNormal = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(false, true), false)
        vmOrthoHover = ViewCubeBuilder._makeSvgTexture(scene2, ViewCubeBuilder._viewModeIconContent(true, true), true)

        viewModeMat = new StandardMaterial('viewModeButtonMat', scene2)
        viewModeMat.diffuseTexture = isOrthoInit ? vmOrthoNormal : vmPerspNormal
        viewModeMat.emissiveColor = new Color3(1, 1, 1)
        viewModeMat.disableLighting = true
        viewModeMat.useAlphaFromDiffuseTexture = true
        viewModeMat.backFaceCulling = false
        viewModeButtonMesh.material = viewModeMat

        scene2.onPointerDown = function (evt, pickResult) {
            if (pickResult.pickedMesh === homeButtonMesh) {
                uiCallbacks?.onTooltip(null, 0, 0)
                if (uiCallbacks) {
                    uiCallbacks.onHomeClick()
                } else {
                    const x = 1.625, y = -1.625, z = 1.625
                    const adjust = -Math.PI / 2
                    const targetAlpha = Math.atan2(x, y) + adjust
                    const targetBeta = Math.acos(z / Math.sqrt(x * x + y * y + z * z))
                    ViewCubeBuilder._animateCameraToPosition(
                        mainCamera, targetAlpha, targetBeta, mainCamera.radius, 0.5
                    )
                }
                return
            }
            if (pickResult.pickedMesh === fitButtonMesh) {
                uiCallbacks?.onTooltip(null, 0, 0)
                uiCallbacks?.onFitClick()
                return
            }
            if (pickResult.pickedMesh === viewModeButtonMesh) {
                uiCallbacks?.onTooltip(null, 0, 0)
                if (uiCallbacks) {
                    uiCallbacks.onViewModeClick(evt.offsetX, evt.offsetY)
                } else {
                    ;(mainCamera as any).toggleCameraMode()
                    currentCameraMode = mainCamera.mode
                    const isOrtho = currentCameraMode === 1
                    viewModeMat.diffuseTexture = isOrtho ? vmOrthoHover : vmPerspHover
                }
                return
            }
            const id = cubeRayCollision(pickResult)
            if (id) rotateToParticle(id);
        }

        camera2.onViewMatrixChangedObservable.add(() => {
            darkenFromCameraPosition(getClosestParticleId())
            sps.setParticles()
        })

        scene2.onPointerMove = function (evt, pickResult) {
            const pickedMesh = pickResult.pickedMesh
            const isOverHome = pickedMesh === homeButtonMesh
            const isOverFit = pickedMesh === fitButtonMesh
            const isOverViewMode = pickedMesh === viewModeButtonMesh
            const isOverButton = isOverHome || isOverFit || isOverViewMode

            if (isOverHome !== homeHovered) {
                homeHovered = isOverHome
                homeMat.diffuseTexture = homeHovered ? homeTexHover : homeTexNormal
            }
            if (isOverFit !== fitHovered) {
                fitHovered = isOverFit
                fitMat.diffuseTexture = fitHovered ? fitTexHover : fitTexNormal
            }
            if (isOverViewMode !== viewModeHovered) {
                viewModeHovered = isOverViewMode
                const isOrtho = currentCameraMode === 1
                viewModeMat.diffuseTexture = viewModeHovered
                    ? (isOrtho ? vmOrthoHover : vmPerspHover)
                    : (isOrtho ? vmOrthoNormal : vmPerspNormal)
            }
            if (uiCallbacks) {
                if (isOverHome) {
                    uiCallbacks.onTooltip('Home', evt.offsetX, evt.offsetY)
                } else if (isOverFit) {
                    uiCallbacks.onTooltip('Fit All', evt.offsetX, evt.offsetY)
                } else if (isOverViewMode) {
                    uiCallbacks.onTooltip('View Mode', evt.offsetX, evt.offsetY)
                } else {
                    uiCallbacks.onTooltip(null, 0, 0)
                }
            }
            if (isOverButton) {
                if (ViewCubeBuilder.prevPick !== -1) {
                    const clearColor = new Color4(0.8, 0.8, 1, 0.3)
                    const prevDiff = ViewCubeBuilder.particleColorMap
                        .get(ViewCubeBuilder.prevPick)
                        ?.subtract(clearColor)
                    if (prevDiff) sps.particles[ViewCubeBuilder.prevPick].color!.addInPlace(prevDiff)
                    ViewCubeBuilder.prevPick = -1
                    sps.setParticles()
                }
                return
            }
            highlightFromPointer(evt.offsetX, evt.offsetY, pickResult)
        }
        const endMousePos = camera2.viewport.x + camera2.viewport.width
        viewCubeMesh.enablePointerMoveEvents = true

        EngineStore._LastCreatedScene = AdomStatics.scene

        return scene2

        function cubeRayCollision(pickResult: PickingInfo) {
            if (pickResult.faceId === -1) return

            const picked = sps.pickedParticle(pickResult)
            if (!picked) return

            //Recursive picking to only get the box particles
            //We are guaranteed to hit the box because of the visibility box
            //This is necessary to ignore the axis and the labels planes on the edge case
            //where ray will pass through the box, but the other meshes are in the way
            let id = picked.idx
            let result = pickResult
            while (id < 3 || 29 < id) {
                const ray = new Ray(
                    result.pickedPoint!.add(
                        result.ray!.direction.multiplyByFloats(0.001, 0.001, 0.001)
                    ),
                    result.ray!.direction,
                    10
                )
                result = scene2.pickWithRay(ray)!
                id = sps.pickedParticle(result)!.idx
            }

            return id
        }

        function rotateToParticle(id: number) {
            const position = sps.particles[id].position
            const adjust = position.x === 0 && position.y === 0 ? Math.PI/2 : -Math.PI/2
            // atan2 gives the cube-scene (right-handed) azimuth that faces the
            // clicked particle. The MAIN camera alpha that lands on this face
            // must use the SAME reflection as the display sync above: identity
            // when the main scene is right-handed (zUp=true), PI-x when it's
            // left-handed (zUp=false). Without matching the display, clicking a
            // face navigated to its mirror (front-left jumped to front-right).
            const cubeAlpha = Math.atan2(position.x, position.y) + adjust
            const targetAlpha = mainScene.useRightHandedSystem
                ? cubeAlpha
                : Math.PI - cubeAlpha
            const targetBeta = Math.acos(
                position.z /
                    Math.sqrt(
                        position.x * position.x + position.y * position.y + position.z * position.z
                    )
            )

            if (targetAlpha !== undefined && targetBeta !== undefined) {
                camera2.onViewMatrixChangedObservable.clear()
                const temp_casting = camera2.onViewMatrixChangedObservable.add(() => {
                    const result = scene2.pick(scene2.pointerX, scene2.pointerY)
                    highlightFromPointer(scene2.pointerX, scene2.pointerY, result)
                    sps.setParticles()
                })

                darkenFromCameraPosition(id)
                sps.setParticles()

                sps.particles[id].color!.addInPlace(new Color4(0, 0, 0, 0))

                const animate_dur = 0.5

                ViewCubeBuilder._animateCameraToPosition(
                    mainCamera,
                    targetAlpha,
                    targetBeta,
                    mainCamera.radius,
                    animate_dur
                )

                setTimeout(() => {
                    camera2.onViewMatrixChangedObservable.remove(temp_casting)
                    camera2.onViewMatrixChangedObservable.add(() => {
                        darkenFromCameraPosition(getClosestParticleId())
                        sps.setParticles()
                    })
                    sps.particles[id].color!.subtractInPlace(new Color4(0, 0, 0, 0))
                }, animate_dur * 1000)
            }
        }

        //TODO: Currently using minimum distance for camera -> particle selection, there should be a
        // smarter way by projecting the cube faces onto the sphere and selecting the current region
        function getClosestParticleId() {
            const distances = []
            for (let i = 3; i < 29; i++) {
                distances.push(Vector3.Distance(camera2.position, sps.particles[i].position))
            }
            return distances.indexOf(Math.min(...distances)) + 3
        }

        function darkenFromCameraPosition(id: number) {
            const targetColor = new Color4(0.4, 0.4, 0.4, 1)
            if (id && id !== ViewCubeBuilder.prevDarkened) {
                const prevDiff = ViewCubeBuilder.particleColorMap
                    .get(ViewCubeBuilder.prevDarkened)
                    ?.subtract(targetColor)
                if (prevDiff) sps.particles[ViewCubeBuilder.prevDarkened].color!.addInPlace(prevDiff)
                ViewCubeBuilder.prevDarkened = id
                const diffFromOriginal = ViewCubeBuilder.particleColorMap
                    .get(id)!
                    .subtract(targetColor)
                sps.particles[id].color!.subtractInPlace(diffFromOriginal)
            }
        }

        function highlightFromPointer(xOffset: number, yOffset: number, pickResult: PickingInfo) {
            //Only ray-cast if pointer is in the viewcube viewport.
            //Uncomment other conditions if the viewcube is moved from the top right corner.
            const targetColor = new Color4(0.8, 0.8, 1, 0.3)

            if (pickResult.faceId === -1) {
                const prevDiff = ViewCubeBuilder.particleColorMap
                    .get(ViewCubeBuilder.prevPick)
                    ?.subtract(targetColor)
                if (prevDiff) sps.particles[ViewCubeBuilder.prevPick].color!.addInPlace(prevDiff)
                ViewCubeBuilder.prevPick = -1
            }
            if (
                // camera2.viewport.x < evt.offsetX / engine.getRenderWidth() &&
                // 1 - endMousePosY < evt.offsetY / engine.getRenderHeight() &&
                xOffset / engine.getRenderWidth() < endMousePos &&
                yOffset / engine.getRenderHeight() < 1 - camera2.viewport.y
            ) {
                const id = cubeRayCollision(pickResult)
                if (id && id !== ViewCubeBuilder.prevPick) {
                    const prevDiff = ViewCubeBuilder.particleColorMap
                        .get(ViewCubeBuilder.prevPick)
                        ?.subtract(targetColor)
                    if (prevDiff)
                        sps.particles[ViewCubeBuilder.prevPick].color!.addInPlace(prevDiff)
                    ViewCubeBuilder.prevPick = id
                    const diffFromOriginal = ViewCubeBuilder.particleColorMap
                        .get(id)!
                        .subtract(targetColor)
                    sps.particles[id].color!.subtractInPlace(diffFromOriginal)
                    sps.setParticles()
                }
            }
        }
    }



    //TODO Fix the math so that the labels don't rotate when rotating overhead the cube
    // alternatively, use a separate sps in billboard mode, but that will use a separate draw call.
    /***
     Manually rotates the labels to face the camera.
     ***/
    private static billboardLabelPlanes(particle: SolidParticle, camera2: ArcRotateCamera) {
        if (particle.idx <= 2) {
            const cameraPosition = camera2.position
            const particlePosition = particle.position

            // Calculate direction vector from particle to camera
            const direction = cameraPosition.subtract(particlePosition).normalize()

            // Use the camera's up vector instead of a fixed (0,1,0)
            const up = camera2.upVector.normalize()
            const right = Vector3.Cross(up, direction).normalize()
            const newUp = Vector3.Cross(direction, right).normalize()

            // Apply rotation to align with camera
            particle.rotationQuaternion = Quaternion.RotationQuaternionFromAxis(
                right,
                newUp,
                direction
            )
        }
    }

    /***
     Initialize particles with proper positions, rotations and UVs
    ***/
    private static initParticlePositions(sps: SolidParticleSystem, cr: number, es: number) {
        // Define UV mappings for the 6 sides of the box in the texture atlas
        // Format: [u1, v1, u2, v2] represents the rectangle in the texture
        const uvMapping: [number, number, number, number][] = [
            [0, 2 / 3, 1 / 3, 1], // FRONT
            [1 / 3, 2 / 3, 2 / 3, 1], // BACK
            [2 / 3, 2 / 3, 1, 1], // RIGHT

            [0, 1 / 3, 1 / 3, 2 / 3], // LEFT
            [1 / 3, 1 / 3, 2 / 3, 2 / 3], // BOTTOM
            [2 / 3, 1 / 3, 1, 2 / 3], // TOP

            [0, 0, 1 / 3.1, 1 / 3.1], // X Label
            [1 / 3.1, 0, 2 / 3.1, 1 / 3.1], // Y Label
            [2 / 3.1, 0, 0.9, 1 / 3.1] // Z Label
        ]

        const labelPlanePositions = [
            [3.5, -2, -2],
            [-2, 3.5, -2],
            [-2, -2, 3.5]
        ]

        const labelPlaneRotations = [
            [0, -Math.PI / 2, Math.PI / 2],
            [Math.PI / 2, 0, 0],
            [0, 0, 0]
        ]

        // Corner positions (relative to center, size = 4)
        const cornerPositions = [
            [-(cr - es / 2), -(cr - es / 2), -(cr - es / 2)],
            [-(cr - es / 2), -(cr - es / 2), cr - es / 2],
            [-(cr - es / 2), cr - es / 2, -(cr - es / 2)],
            [-(cr - es / 2), cr - es / 2, cr - es / 2],
            [cr - es / 2, -(cr - es / 2), -(cr - es / 2)],
            [cr - es / 2, -(cr - es / 2), cr - es / 2],
            [cr - es / 2, cr - es / 2, -(cr - es / 2)],
            [cr - es / 2, cr - es / 2, cr - es / 2]
        ]

        // Edge positions and rotations
        const edgeBoxPositions = [
            // X-aligned edges
            [-(cr - es / 2), -(cr - es / 2), 0],
            [-(cr - es / 2), cr - es / 2, 0],
            [cr - es / 2, -(cr - es / 2), 0],
            [cr - es / 2, cr - es / 2, 0],
            // Y-aligned edges
            [-(cr - es / 2), 0, -(cr - es / 2)],
            [cr - es / 2, 0, -(cr - es / 2)],
            [-(cr - es / 2), 0, cr - es / 2],
            [cr - es / 2, 0, cr - es / 2],
            // Z-aligned edges
            [0, -(cr - es / 2), -(cr - es / 2)],
            [0, cr - es / 2, -(cr - es / 2)],
            [0, -(cr - es / 2), cr - es / 2],
            [0, cr - es / 2, cr - es / 2]
        ]

        const edgeBoxRotations = [
            [0, Math.PI / 2, 0],
            [0, Math.PI / 2, 0],
            [0, Math.PI / 2, 0],
            [0, Math.PI / 2, 0], // Z-aligned
            [0, 0, Math.PI / 2],
            [0, 0, Math.PI / 2],
            [0, 0, Math.PI / 2],
            [0, 0, Math.PI / 2], // Y-aligned
            [0, 0, 0],
            [0, 0, 0],
            [0, 0, 0],
            [0, 0, 0] // X-aligned
        ]

        // Center face positions and rotations (similar to original)
        const facePositions = [
            [0, -cr, 0],
            [0, cr, 0], // Front, Back
            [cr, 0, 0],
            [-cr, 0, 0], // Right, Left
            [0, 0, -cr],
            [0, 0, cr] // Bottom, Top
        ]

        const faceRotations = [
            [Math.PI / 2, 0, 0],
            [-Math.PI / 2, Math.PI, 0],
            [0, Math.PI / 2, Math.PI / 2],
            [0, -Math.PI / 2, -Math.PI / 2],
            [0, Math.PI, Math.PI],
            [0, 0, 0]
        ]

        const edgeCylinderPositions = [
            [-cr, 0, cr],
            [cr, 0, cr],
            [cr, 0, -cr],
            [0, cr, cr],
            [0, -cr, cr],
            [0, cr, -cr],
            [-cr, cr, 0],
            [cr, cr, 0],
            [cr, -cr, 0]
        ]

        const edgeCylinderRotations = [
            [0, 0, 0],
            [0, 0, 0],
            [0, 0, 0],
            [0, 0, Math.PI / 2],
            [0, 0, Math.PI / 2],
            [0, 0, Math.PI / 2],
            [-Math.PI / 2, 0, 0],
            [-Math.PI / 2, 0, 0],
            [-Math.PI / 2, 0, 0]
        ]

        const rgb = [new Color4(1, 0, 0, 1), new Color4(0, 1, 0, 1), new Color4(0, 0, 1, 1)]

        const axisCylinderPositions = [
            [0.5, -2, -2],
            [-2, 0.5, -2],
            [-2, -2, 0.5]
        ]

        const axisCylinderRotations = [
            [0, 0, Math.PI / 2],
            [0, 0, 0],
            [-Math.PI / 2, 0, 0]
        ]

        const axisConePositions = [
            [3, -2, -2],
            [-2, 3, -2],
            [-2, -2, 3]
        ]

        const axisConeRotations = [
            [0, 0, -Math.PI / 2],
            [0, 0, 0],
            [-Math.PI / 2, 0, Math.PI]
        ]

        // Set label plane particles
        for (let i = 0; i < 3; i++) {
            const particle = sps.particles[i]
            particle.scaling.set(2, 2, 2)
            particle.position.set(...(labelPlanePositions[i] as [number, number, number]))
            particle.rotation.set(...(labelPlaneRotations[i] as [number, number, number]))
            particle.uvs.set(...uvMapping[i + 6])
        }

        // Set center face particles
        for (let i = 0; i < 6; i++) {
            const particle = sps.particles[i + 3]
            particle.position.set(...(facePositions[i] as [number, number, number]))
            particle.rotation.set(...(faceRotations[i] as [number, number, number]))
            particle.uvs.set(...uvMapping[i])
            this.particleColorMap.set(i + 3, particle.color!.clone())
        }

        // Set corner particles
        for (let i = 0; i < 8; i++) {
            const particle = sps.particles[i + 9]
            particle.position.set(...(cornerPositions[i] as [number, number, number]))
            const color = new Color4(0.7, 0.7, 0.7, 1)
            particle.color = color
            this.particleColorMap.set(i + 9, color.clone())
        }

        // Set edge box particles
        for (let i = 0; i < 12; i++) {
            const particle = sps.particles[i + 17]
            particle.position.set(...(edgeBoxPositions[i] as [number, number, number]))
            particle.rotation.set(...(edgeBoxRotations[i] as [number, number, number]))
            const color = new Color4(0.8, 0.8, 0.8, 1)
            particle.color = color
            this.particleColorMap.set(i + 17, color.clone())
        }

        // Set edge cylinder particles
        for (let i = 0; i < 9; i++) {
            const particle = sps.particles[i + 29]
            particle.position.set(...(edgeCylinderPositions[i] as [number, number, number]))
            particle.rotation.set(...(edgeCylinderRotations[i] as [number, number, number]))
            const color = new Color4(0, 0, 0, 1)
            particle.color = color
            this.particleColorMap.set(i + 29, color.clone())
        }

        // Set axis cylinder particles
        for (let i = 0; i < 3; i++) {
            const particle = sps.particles[i + 38]
            particle.position.set(...(axisCylinderPositions[i] as [number, number, number]))
            particle.rotation.set(...(axisCylinderRotations[i] as [number, number, number]))
            particle.color = rgb[i]
        }

        // Set axis cone particles
        for (let i = 0; i < 3; i++) {
            const particle = sps.particles[i + 41]
            particle.position.set(...(axisConePositions[i] as [number, number, number]))
            particle.rotation.set(...(axisConeRotations[i] as [number, number, number]))
            particle.color = rgb[i]
        }
    }

    /*** Adds all the required meshes to the SPS ***/
    private static generateSPS(scene2: Scene, cr: number, es: number) {
        const cornerCubeMesh = MeshBuilder.CreateBox('cornerBase', { size: es }, scene2)
        const edgeBoxMesh = MeshBuilder.CreateBox(
            'edgeBase',
            {
                width: 2 * cr - 2 * es,
                height: es,
                depth: es
            },
            scene2
        )
        const centerPlaneMesh = MeshBuilder.CreatePlane('plane', { size: 2 * cr - 2 * es }, scene2)
        centerPlaneMesh.flipFaces()

        const cylinderMesh = MeshBuilder.CreateCylinder(
            'cylinderBase',
            { height: 4, diameterTop: 0.015, diameterBottom: 0.015 },
            scene2
        )
        const coneMesh = MeshBuilder.CreateCylinder(
            'coneBase',
            { height: 1, diameterTop: 0, diameterBottom: 0.6 },
            scene2
        )
        const axisCylinderMesh = MeshBuilder.CreateCylinder(
            'axisCylinderBase',
            { height: 5, diameterTop: 0.3, diameterBottom: 0.3 },
            scene2
        )

        // Create SPS for all the elements
        const sps = new SolidParticleSystem('viewCubeSPS', scene2, { isPickable: true })

        // Add shapes to SPS
        const _labelPlanesIdx = sps.addShape(centerPlaneMesh, 3)
        const _centerPlanesIdx = sps.addShape(centerPlaneMesh, 6) // 6 center faces
        const _cornerCubesIdx = sps.addShape(cornerCubeMesh, 8) // 8 corners
        const _edgeBoxesIdx = sps.addShape(edgeBoxMesh, 12) // 12 edges
        const _edgeCylindersIdx = sps.addShape(cylinderMesh, 9)
        const _axisCylindersIdx = sps.addShape(axisCylinderMesh, 3)
        const _axisConesIdx = sps.addShape(coneMesh, 3)

        // Dispose of base meshes as they're no longer needed
        cornerCubeMesh.dispose()
        edgeBoxMesh.dispose()
        centerPlaneMesh.dispose()
        cylinderMesh.dispose()
        coneMesh.dispose()
        axisCylinderMesh.dispose()
        return sps
    }

    static createShaderMaterial(scene: Scene) {
        // Load shaders into Babylon.js
        Effect.ShadersStore['customVertexShader'] = `
        precision highp float;
        attribute vec3 position;
        attribute vec3 normal;
        attribute vec2 uv;
        attribute vec4 color;
        attribute float particleID;
        
        uniform mat4 world;
        uniform mat4 worldView;
        uniform mat4 worldViewProjection;
        
        varying vec2 vUV;
        varying vec4 vColor;
        varying float vParticleID;
        
        void main() {
            vUV = uv;
            vColor = color;
            vParticleID = particleID; // Pass the ID to fragment shader
            gl_Position = worldViewProjection * vec4(position, 1.0);
        }`

        Effect.ShadersStore['customFragmentShader'] = `
        precision highp float;
        uniform sampler2D textureSampler;
        varying vec2 vUV;
        varying vec4 vColor;
        varying float vParticleID;
        
        void main() {
            if (vParticleID < 1.0) {  
                gl_FragColor = vColor;
            } else {
                vec4 texColor = texture2D(textureSampler, vUV);
                if (texColor.a < 0.1) discard;
                gl_FragColor = texColor * vColor;
            }
        }`

        // Create Shader Material
        const shaderMaterial = new ShaderMaterial(
            'shaderMat',
            scene,
            {
                vertex: 'custom',
                fragment: 'custom'
            },
            {
                attributes: ['position', 'normal', 'uv', 'color', 'particleID'],
                uniforms: ['world', 'worldView', 'worldViewProjection'],
                samplers: ['textureSampler']
            }
        )

        shaderMaterial.setColor3('emissiveColor', new Color3(1, 1, 1))
        return shaderMaterial
    }

    /**
     * Creates a DynamicTexture by rasterising an SVG via an Image element.
     * 512x512 source with mipmaps for crisp quality at any display size.
     * @internal
     */
    private static _svgTexId = 0
    private static _makeSvgTexture(scene: Scene, iconContent: string, hovered: boolean): DynamicTexture {
        const bg = hovered ? '#505860' : '#373d43'
        const bgOp = hovered ? '0.95' : '0.85'
        const border = hovered ? '#00b8b0' : '#ffffff'
        const borderOp = hovered ? '0.6' : '0.25'
        const svg =
            `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 128 128">` +
            `<circle cx="64" cy="64" r="54" fill="${bg}" fill-opacity="${bgOp}" stroke="${border}" stroke-opacity="${borderOp}" stroke-width="2.5"/>` +
            iconContent +
            `</svg>`

        const size = 1024
        const tex = new DynamicTexture(`svgIcon_${ViewCubeBuilder._svgTexId++}`, size, scene, true)
        tex.hasAlpha = true
        tex.anisotropicFilteringLevel = 16

        const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
        const img = new Image(size, size)
        img.onload = () => {
            const ctx = tex.getContext() as unknown as CanvasRenderingContext2D
            ctx.clearRect(0, 0, size, size)
            ctx.drawImage(img, 0, 0, size, size)
            tex.update()
        }
        img.src = url
        return tex
    }

    /** @internal */
    private static _homeIconContent(hovered: boolean): string {
        const c = hovered ? '#00b8b0' : '#ffffff'
        const op = hovered ? '1' : '0.85'
        const bg = hovered ? '#505860' : '#373d43'
        // House: triangle roof + rectangular body + door cutout
        return (
            `<path d="M64 30 L34 54 L94 54 Z" fill="${c}" fill-opacity="${op}"/>` +
            `<rect x="40" y="54" width="48" height="34" fill="${c}" fill-opacity="${op}"/>` +
            `<rect x="55" y="66" width="18" height="22" rx="1" fill="${bg}"/>`
        )
    }

    /** @internal */
    private static _fitIconContent(hovered: boolean): string {
        const c = hovered ? '#00b8b0' : '#ffffff'
        const op = hovered ? '1' : '0.85'
        // Four corner brackets + center square
        return (
            `<path d="M38 50 L38 38 L50 38" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
            `<path d="M78 38 L90 38 L90 50" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
            `<path d="M38 78 L38 90 L50 90" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
            `<path d="M78 90 L90 90 L90 78" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>` +
            `<rect x="55" y="55" width="18" height="18" rx="2" fill="${c}" fill-opacity="${op}"/>`
        )
    }

    /** @internal */
    private static _viewModeIconContent(hovered: boolean, isOrtho: boolean): string {
        const c = hovered ? '#00b8b0' : '#ffffff'
        const op = hovered ? '1' : '0.85'
        if (isOrtho) {
            // Isometric cube — all depth lines parallel (orthographic)
            return (
                `<path d="M36 86 L74 86 L74 52 L36 52 Z" fill="${c}" fill-opacity="0.12" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
                `<path d="M74 52 L88 38 L88 72 L74 86" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
                `<path d="M36 52 L50 38 L88 38 L74 52" fill="${c}" fill-opacity="0.04" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>`
            )
        } else {
            // Perspective cube — depth lines converge (back face smaller)
            return (
                `<path d="M34 88 L76 88 L76 52 L34 52 Z" fill="${c}" fill-opacity="0.12" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
                `<path d="M76 52 L82 40 L82 68 L76 88" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>` +
                `<path d="M34 52 L46 40 L82 40 L76 52" fill="${c}" fill-opacity="0.04" stroke="${c}" stroke-opacity="${op}" stroke-width="3" stroke-linejoin="round"/>`
            )
        }
    }
}