import { AnimationAction, Box3, Box3Helper, Camera, Color, DepthTexture, Euler, GridHelper, Layers, Material, Mesh, MeshStandardMaterial, Object3D, OrthographicCamera, PerspectiveCamera, PlaneGeometry, Quaternion, Scene, ShadowMaterial, Texture, Uniform, Vector2Like, Vector3, WebGLRenderTarget } from "three";
import { ShaderMaterial, WebGLRenderer } from "three";
import { GroundedSkybox } from "three/examples/jsm/objects/GroundedSkybox.js";

import { useForAutoFit } from "./engine_camera.js";
import { Mathf } from "./engine_math.js"
import { Vec3 } from "./engine_types.js";
import { CircularBuffer } from "./engine_utils.js";

/**
 * Slerp between two vectors
 */
export function slerp(vec: Vector3, end: Vector3, t: number) {
    const len1 = vec.length();
    const len2 = end.length();
    const targetLen = Mathf.lerp(len1, len2, t);
    return vec.lerp(end, t).normalize().multiplyScalar(targetLen);
}

const _tempQuat = new Quaternion();
const flipYQuat: Quaternion = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);

export function lookAtInverse(obj: Object3D, target: Vector3) {
    obj.lookAt(target);
    obj.quaternion.multiply(flipYQuat);
}


/** Better lookAt
 * @param object the object that the lookAt should be applied to
 * @param target the target to look at
 * @param keepUpDirection if true the up direction will be kept
 * @param copyTargetRotation if true the target rotation will be copied so the rotation is not skewed
 */
export function lookAtObject(object: Object3D, target: Object3D, keepUpDirection: boolean = true, copyTargetRotation: boolean = false) {
    if (object === target) return;
    _tempQuat.copy(object.quaternion);

    const lookTarget = getWorldPosition(target);
    const lookFrom = getWorldPosition(object);

    if (copyTargetRotation) {
        setWorldQuaternion(object, getWorldQuaternion(target));
        // look at forward again so we don't get any roll
        if (keepUpDirection) {
            const ypos = lookFrom.y;
            const forwardPoint = lookFrom.sub(getWorldDirection(object));
            forwardPoint.y = ypos;
            object.lookAt(forwardPoint);
            object.quaternion.multiply(flipYQuat);
        }

        // sanitize – three.js sometimes returns NaN values when parents are non-uniformly scaled
        if (Number.isNaN(object.quaternion.x)) {
            object.quaternion.copy(_tempQuat);
        }

        return;
    }

    if (keepUpDirection) {
        lookTarget.y = lookFrom.y;
    }

    object.lookAt(lookTarget);

    // sanitize – three.js sometimes returns NaN values when parents are non-uniformly scaled
    if (Number.isNaN(object.quaternion.x)) {
        object.quaternion.copy(_tempQuat);
    }
}


/**
 * Look at a 2D point in screen space
 * @param object the object to look at the point
 * @param target the target point in 2D screen space XY e.g. from a mouse event
 * @param camera the camera to use for the lookAt
 * @param factor the factor to multiply the distance from the camera to the object. Default is 1
 * @returns the target point in world space
 * 
 * @example Needle Engine Component
 * ```ts 
 * export class MyLookAtComponent extends Behaviour {      
 *   update() {     
 *     lookAtScreenPoint(this.gameObject, this.context.input.mousePosition, this.context.mainCamera);   
 *   }   
 * }   
 * ```
 * 
 * @example Look at from browser mouse move event
 * ```ts
 * window.addEventListener("mousemove", (e) => {
 *   lookAtScreenPoint(object, new Vector2(e.clientX, e.clientY), camera);
 *  });
 * ```
 */
export function lookAtScreenPoint(object: Object3D, target: Vector2Like, camera: Camera, factor: number = 1): Vector3 | null {

    if (camera) {
        const pos = getTempVector(0, 0, 0);
        const ndcx = (target.x / window.innerWidth) * 2 - 1;
        const ndcy = -(target.y / window.innerHeight) * 2 + 1;
        pos.set(
            ndcx,
            ndcy,
            0
        );
        pos.unproject(camera);
        // get distance from object to camera
        const camPos = camera.worldPosition;
        const dist = object.worldPosition.distanceTo(camPos);
        // Create direction from camera through cursor point
        const dir = pos.sub(camPos);
        dir.multiplyScalar(factor * 3.6 * dist);
        const targetPoint = camera.worldPosition.add(dir);
        object.lookAt(targetPoint);
        return targetPoint;
    }
    return null;
}



const _tempVecs = new CircularBuffer(() => new Vector3(), 100);

/** Gets a temporary vector. If a vector is passed in it will be copied to the temporary vector    
 * Temporary vectors are cached and reused internally. Don't store them!  
 * @param vec3 the vector to copy or the x value
 * @param y the y value
 * @param z the z value
 * @returns a temporary vector
 * 
 * @example
 * ``` javascript
 * const vec = getTempVector(1, 2, 3);
 * const vec2 = getTempVector(vec);
 * const vec3 = getTempVector(new Vector3(1, 2, 3));
 * const vec4 = getTempVector(new DOMPointReadOnly(1, 2, 3));
 * const vec5 = getTempVector();
 * ```
 */
export function getTempVector(): Vector3;
export function getTempVector(vec3: Vector3): Vector3;
export function getTempVector(vec3: [number, number, number]): Vector3;
export function getTempVector(vec3: Vec3): Vector3;
export function getTempVector(dom: DOMPointReadOnly): Vector3;
export function getTempVector(x: number): Vector3;
export function getTempVector(x: number, y: number, z: number): Vector3;
export function getTempVector(vecOrX?: Vec3 | Vector3 | [number, number, number] | DOMPointReadOnly | number, y?: number, z?: number): Vector3 {
    const vec = _tempVecs.get();
    vec.set(0, 0, 0); // initialize with default values
    if (vecOrX instanceof Vector3) vec.copy(vecOrX);
    else if (Array.isArray(vecOrX)) vec.set(vecOrX[0], vecOrX[1], vecOrX[2]);
    else if (vecOrX instanceof DOMPointReadOnly) vec.set(vecOrX.x, vecOrX.y, vecOrX.z);
    else {
        if (typeof vecOrX === "number") {
            vec.x = vecOrX;
            vec.y = y !== undefined ? y : vec.x;
            vec.z = z !== undefined ? z : vec.x;
        }
        else if (typeof vecOrX === "object") {
            vec.x = vecOrX.x;
            vec.y = vecOrX.y;
            vec.z = vecOrX.z;
        }
    }
    return vec;
}


const _tempColors = new CircularBuffer(() => new Color(), 30);
export function getTempColor(color?: Color) {
    const col = _tempColors.get();
    if (color) col.copy(color);
    else {
        col.set(0, 0, 0);
    }
    return col;
}


const _tempQuats = new CircularBuffer(() => new Quaternion(), 100);

/**
 * Gets a temporary quaternion. If a quaternion is passed in it will be copied to the temporary quaternion  
 * Temporary quaternions are cached and reused internally. Don't store them!
 * @param value the quaternion to copy
 * @returns a temporary quaternion
 */
export function getTempQuaternion(value?: Quaternion | DOMPointReadOnly | { x: number, y: number, z: number, w: number }): Quaternion;
export function getTempQuaternion(x: number, y: number, z: number, w: number): Quaternion;
export function getTempQuaternion(value?: Quaternion | DOMPointReadOnly | { x: number, y: number, z: number, w: number } | number, y?: number, z?: number, w?: number): Quaternion {
    const val = _tempQuats.get();
    val.identity();
    if (value instanceof Quaternion) val.copy(value);
    else if (value instanceof DOMPointReadOnly) val.set(value.x, value.y, value.z, value.w);
    else {
        if (typeof value === "number" && y !== undefined && z !== undefined && w !== undefined) {
            val.set(value, y, z, w);
        }
        else if (typeof value === "object" && 'x' in value && 'y' in value && 'z' in value && 'w' in value) {
            val.set(value.x, value.y, value.z, value.w);
        }
    }
    return val;
}


const _worldPositions = new CircularBuffer(() => new Vector3(), 100);
const _lastMatrixWorldUpdateKey = Symbol("lastMatrixWorldUpdateKey");

/**
 * Get the world position of an object
 * @param obj the object to get the world position from
 * @param vec a vector to store the result in. If not passed in a temporary vector will be used
 * @param updateParents if true the parents will be updated before getting the world position
 * @returns the world position
 */
export function getWorldPosition(obj: Object3D, vec: Vector3 | null = null, updateParents: boolean = true): Vector3 {
    const wp = vec ?? _worldPositions.get();
    if (!obj) return wp.set(0, 0, 0);
    if (!obj.parent) return wp.copy(obj.position);
    if (updateParents)
        obj.updateWorldMatrix(true, false);
    if (obj.matrixWorldNeedsUpdate && obj[_lastMatrixWorldUpdateKey] !== Date.now()) {
        obj[_lastMatrixWorldUpdateKey] = Date.now();
        obj.updateMatrixWorld();
    }
    wp.setFromMatrixPosition(obj.matrixWorld);
    return wp;
}

/**
 * Set the world position of an object
 * @param obj the object to set the world position of
 * @param val the world position to set
 */
export function setWorldPosition(obj: Object3D, val: Vector3): Object3D {
    if (!obj) return obj;
    const wp = _worldPositions.get();
    if (val !== wp)
        wp.copy(val);

    if (obj.parent !== null)
         obj.parent.worldToLocal(wp);

    obj.position.set(wp.x, wp.y, wp.z);
    return obj;
}

/**
 * Set the world position of an object
 * @param obj the object to set the world position of
 * @param x the x position
 * @param y the y position
 * @param z the z position
 */
export function setWorldPositionXYZ(obj: Object3D, x: number, y: number, z: number): Object3D {
    const wp = _worldPositions.get();
    wp.set(x, y, z);
    setWorldPosition(obj, wp);
    return obj;
}


const _worldQuaternions = new CircularBuffer(() => new Quaternion(), 100);
const _worldQuaternionBuffer: Quaternion = new Quaternion();
const _tempQuaternionBuffer2: Quaternion = new Quaternion();

export function getWorldQuaternion(obj: Object3D, target: Quaternion | null = null): Quaternion {
    if (!obj) return _worldQuaternions.get().identity();
    const quat = target ?? _worldQuaternions.get();
    if (!obj.parent) return quat.copy(obj.quaternion);
    obj.getWorldQuaternion(quat);
    return quat;
}
export function setWorldQuaternion(obj: Object3D, val: Quaternion) {
    if (!obj) return;
    if (val !== _worldQuaternionBuffer)
        _worldQuaternionBuffer.copy(val);
    const tempVec = _worldQuaternionBuffer;
    const parent = obj?.parent;
    parent?.getWorldQuaternion(_tempQuaternionBuffer2);
    _tempQuaternionBuffer2.invert();
    const q = _tempQuaternionBuffer2.multiply(tempVec);
    // console.log(tempVec);
    obj.quaternion.set(q.x, q.y, q.z, q.w);
    // console.error("quaternion world to local is not yet implemented");
}
export function setWorldQuaternionXYZW(obj: Object3D, x: number, y: number, z: number, w: number) {
    _worldQuaternionBuffer.set(x, y, z, w);
    setWorldQuaternion(obj, _worldQuaternionBuffer);
}

const _worldScaleBuffer = new CircularBuffer(() => new Vector3(), 100);
const _worldScale: Vector3 = new Vector3();

export function getWorldScale(obj: Object3D, vec: Vector3 | null = null): Vector3 {
    if (!vec)
        vec = _worldScaleBuffer.get();
    if (!obj) return vec.set(0, 0, 0);
    if (!obj.parent) return vec.copy(obj.scale);
    obj.getWorldScale(vec);
    return vec;
}

export function setWorldScale(obj: Object3D, vec: Vector3) {
    if (!obj) return;
    if (!obj.parent) {
        obj.scale.copy(vec);
        return;
    }
    const tempVec = _worldScale;
    const obj2 = obj.parent;
    obj2.getWorldScale(tempVec);
    obj.scale.copy(vec);
    obj.scale.divide(tempVec);
}

const _forward = new Vector3();
const _forwardQuat = new Quaternion();
export function forward(obj: Object3D): Vector3 {
    getWorldQuaternion(obj, _forwardQuat);
    return _forward.set(0, 0, 1).applyQuaternion(_forwardQuat);
}

const _worldDirectionBuffer = new CircularBuffer(() => new Vector3(), 100);
const _worldDirectionQuat = new Quaternion();
/** Get the world direction. Returns world forward if nothing is passed in.
 * Pass in a relative direction to get it converted to world space (e.g. dir = new Vector3(0, 1, 1))
 * The returned vector will not be normalized
 */
export function getWorldDirection(obj: Object3D, dir?: Vector3) {
    // If no direction is passed in set the direction to the forward vector
    if (!dir) dir = _worldDirectionBuffer.get().set(0, 0, 1);
    getWorldQuaternion(obj, _worldDirectionQuat);
    return dir.applyQuaternion(_worldDirectionQuat);
}


const _worldEulerBuffer: Euler = new Euler();
const _worldEuler: Euler = new Euler();
const _worldRotation: Vector3 = new Vector3();



// world euler (in radians)
export function getWorldEuler(obj: Object3D): Euler {
    const quat = _worldQuaternions.get();
    obj.getWorldQuaternion(quat);
    _worldEuler.setFromQuaternion(quat);
    return _worldEuler;
}

// world euler (in radians)
export function setWorldEuler(obj: Object3D, val: Euler) {
    const quat = _worldQuaternions.get();
    setWorldQuaternion(obj, quat.setFromEuler(val));;
}

// returns rotation in degrees
export function getWorldRotation(obj: Object3D): Vector3 {
    const rot = getWorldEuler(obj);
    const wr = _worldRotation;
    wr.set(rot.x, rot.y, rot.z);
    wr.x = Mathf.toDegrees(wr.x);
    wr.y = Mathf.toDegrees(wr.y);
    wr.z = Mathf.toDegrees(wr.z);
    return wr;
}

export function setWorldRotation(obj: Object3D, val: Vector3) {
    setWorldRotationXYZ(obj, val.x, val.y, val.z, true);
}

export function setWorldRotationXYZ(obj: Object3D, x: number, y: number, z: number, degrees: boolean = true) {
    if (degrees) {
        x = Mathf.toRadians(x);
        y = Mathf.toRadians(y);
        z = Mathf.toRadians(z);
    }
    _worldEulerBuffer.set(x, y, z);
    _worldQuaternionBuffer.setFromEuler(_worldEulerBuffer);
    setWorldQuaternion(obj, _worldQuaternionBuffer);
}





// from https://github.com/mrdoob/js/pull/10995#issuecomment-287614722
export function logHierarchy(root: Object3D | null | undefined, collapsible: boolean = true) {
    if (!root) return;
    if (collapsible) {
        (function printGraph(obj: Object3D) {
            console.groupCollapsed((obj.name ? obj.name : '(no name : ' + obj.type + ')') + ' %o', obj);
            obj.children.forEach(printGraph);
            console.groupEnd();
        }(root));

    } else {
        root.traverse(function (obj: Object3D) {
            var s = '|___';
            var obj2 = obj;
            while (obj2.parent !== null) {
                s = '\t' + s;
                obj2 = obj2.parent;
            }
            console.log(s + obj.name + ' <' + obj.type + '>');
        });
    };
}

export function getParentHierarchyPath(obj: Object3D): string {
    let path = obj?.name || "";
    if (!obj) return path;
    let parent = obj.parent;
    while (parent) {
        path = parent.name + "/" + path;
        parent = parent.parent;
    }
    return path;
}


export function isAnimationAction(obj: object) {
    if (obj) {
        // this doesnt work :(
        // return obj instanceof AnimationAction;
        // instead we do this:
        const act = obj as AnimationAction;
        return act.blendMode !== undefined && act.clampWhenFinished !== undefined && act.enabled !== undefined && act.fadeIn !== undefined && act.getClip !== undefined;
    }
    return false;
}




class BlitMaterial extends ShaderMaterial {
    static vertex = `
varying vec2 vUv;
void main(){
    vUv = uv;
    gl_Position = vec4(position.xy, 0., 1.0);
}`;

    constructor() {
        super({
            vertexShader: BlitMaterial.vertex,
            uniforms: {
                map: new Uniform(null),
                flipY: new Uniform(true),
                writeDepth: new Uniform(false),
                depthTexture: new Uniform(null)
            },
            fragmentShader: `
uniform sampler2D map;
uniform bool flipY;
uniform bool writeDepth;
uniform sampler2D depthTexture;

varying vec2 vUv;

void main(){ 
    vec2 uv = vUv;
    if (flipY) uv.y = 1.0 - uv.y;
    gl_FragColor = texture2D(map, uv);

    if (writeDepth) {
        float depth = texture2D(depthTexture, uv).r;
        gl_FragDepth = depth;

        // float linearDepth = (depth - 0.99) * 100.0; // Enhance near 1.0 values
        // gl_FragColor = vec4(linearDepth, linearDepth, linearDepth, 1.0);
    }
}`
        });
    }

    reset() {
        this.uniforms.map.value = null;
        this.uniforms.flipY.value = true;
        this.uniforms.writeDepth.value = false;
        this.uniforms.depthTexture.value = null;
        this.needsUpdate = true;
        this.uniformsNeedUpdate = true;
    }
}

/**
 * Utility class to perform various graphics operations like copying textures to canvas
 */
export class Graphics {
    private static readonly planeGeometry = new PlaneGeometry(2, 2, 1, 1);
    private static readonly renderer: WebGLRenderer = new WebGLRenderer({ antialias: false, alpha: true });
    private static readonly perspectiveCam = new PerspectiveCamera();
    private static readonly orthographicCam = new OrthographicCamera();
    private static readonly scene = new Scene();
    private static readonly blitMaterial: BlitMaterial = new BlitMaterial();
    private static readonly mesh: Mesh = new Mesh(Graphics.planeGeometry, Graphics.blitMaterial);


    /** 
     * Copy a texture to a new texture
     * @param texture the texture to copy
     * @param blitMaterial the material to use for copying (optional)
     * @returns the newly created, copied texture
    */
    static copyTexture(texture: Texture, blitMaterial?: ShaderMaterial): Texture {

        // ensure that a blit material exists
        if (!blitMaterial) {
            blitMaterial = this.blitMaterial;
        };
        this.blitMaterial.reset();

        const material = blitMaterial || this.blitMaterial;

        // TODO: reset the uniforms...
        material.uniforms.map.value = texture;
        material.needsUpdate = true;
        material.uniformsNeedUpdate = true;


        // ensure that the blit material has the correct vertex shader
        const origVertexShader = material.vertexShader;
        material.vertexShader = BlitMaterial.vertex;

        const mesh = this.mesh;
        mesh.material = material;
        mesh.frustumCulled = false;
        this.scene.children.length = 0;
        this.scene.add(mesh);
        this.renderer.setSize(texture.image.width, texture.image.height);
        this.renderer.clear();
        this.renderer.render(this.scene, this.perspectiveCam);
        const tex = new Texture(this.renderer.domElement);
        tex.name = "Copy";
        tex.needsUpdate = true; // < important!

        // reset vertex shader
        material.vertexShader = origVertexShader;

        return tex;
    }

    static blit(src: Texture, target: WebGLRenderTarget, options?: {
        renderer?: WebGLRenderer,
        blitMaterial?: ShaderMaterial,
        flipY?: boolean,
        depthTexture?: DepthTexture | null,
        depthTest?: boolean,
        depthWrite?: boolean,
    }) {
        const {
            renderer = this.renderer,
            blitMaterial = this.blitMaterial,
            flipY = false,
            depthTexture = null,
            depthTest = true,
            depthWrite = true,
        } = options || {};

        this.blitMaterial.reset();

        if (blitMaterial.uniforms.map) blitMaterial.uniforms.map.value = src;
        if (blitMaterial.uniforms.flipY) blitMaterial.uniforms.flipY.value = flipY;

        if (depthTexture) {
            blitMaterial.uniforms.writeDepth = new Uniform(true);
            blitMaterial.uniforms.depthTexture.value = depthTexture;
        }
        else {
            blitMaterial.uniforms.writeDepth = new Uniform(false);
            blitMaterial.uniforms.depthTexture.value = null;
        }
        blitMaterial.needsUpdate = true;
        blitMaterial.uniformsNeedUpdate = true;

        const mesh = this.mesh;
        mesh.material = blitMaterial;
        mesh.frustumCulled = false;
        this.scene.children.length = 0;
        this.scene.add(mesh);

        const renderTarget = renderer.getRenderTarget();

        // Set state
        const gl = renderer.getContext();
        if (depthTest) gl.enable(gl.DEPTH_TEST);
        else gl.disable(gl.DEPTH_TEST);
        renderer.state.buffers.depth.setMask(depthWrite);

        renderer.setClearColor(new Color(0, 0, 0), 0);
        renderer.setRenderTarget(target);
        renderer.clear();
        renderer.render(this.scene, this.perspectiveCam);

        // Restore defaults (not using gl.getParameter to save/restore — it causes a synchronous GPU-CPU stall)
        renderer.setRenderTarget(renderTarget);
        gl.enable(gl.DEPTH_TEST);
        renderer.state.buffers.depth.setMask(true);
    }

    /**
     * Copy a texture to a HTMLCanvasElement
     * @param texture the texture convert
     * @param force if true the texture will be copied to a new texture before converting
     * @returns the HTMLCanvasElement with the texture or null if the texture could not be copied
     */
    static textureToCanvas(texture: Texture, force: boolean = false): HTMLCanvasElement | null {
        if (!texture) {
            return null;
        }

        if (force === true || texture["isCompressedTexture"] === true) {
            texture = copyTexture(texture);
        }
        const image = texture.image;
        if (isImageBitmap(image)) {
            const canvas = document.createElement('canvas');
            canvas.width = image.width;
            canvas.height = image.height;

            const context = canvas.getContext('2d');
            if (!context) {
                console.error("Failed getting canvas 2d context");
                return null;
            }
            context.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height);
            return canvas;
        }

        return null;
    }
}

/**@obsolete use Graphics.copyTexture */
export function copyTexture(texture: Texture): Texture {
    return Graphics.copyTexture(texture);
}

/**@obsolete use Graphics.textureToCanvas */
export function textureToCanvas(texture: Texture, force: boolean = false): HTMLCanvasElement | null {
    return Graphics.textureToCanvas(texture, force);
}

declare class OffscreenCanvas { };

function isImageBitmap(image) {
    return (typeof HTMLImageElement !== 'undefined' && image instanceof HTMLImageElement) ||
        (typeof HTMLCanvasElement !== 'undefined' && image instanceof HTMLCanvasElement) ||
        (typeof OffscreenCanvas !== 'undefined' && image instanceof OffscreenCanvas) ||
        (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap);
}



function isMesh(obj: Object3D): obj is Mesh {
    const type = obj.type;
    // Note: we don't use the instanceof check here because it's unreliable in certain minification scenarios where it returns false
    return type === "Mesh" || type === "SkinnedMesh";
}

// for contact shadows
export function setVisibleInCustomShadowRendering(obj: Object3D, enabled: boolean) {
    if (enabled)
        obj["needle:rendercustomshadow"] = true;
    else {
        obj["needle:rendercustomshadow"] = false;
    }
}
export function getVisibleInCustomShadowRendering(obj: Object3D): boolean {
    if (obj) {
        if (obj["needle:rendercustomshadow"] === true) {
            return true;
        }
        else if (obj["needle:rendercustomshadow"] == undefined) {
            return true;
        }
    }
    return false;
}

/**
 * Get the axis-aligned bounding box of a list of objects.
 * @param objects The objects to get the bounding box from.
 * @param ignore Objects to ignore when calculating the bounding box. Objects that are invisible (gizmos, helpers, etc.) are excluded by default.
 * @param layers The layers to include. Typically the main camera's layers.
 * @param result The result box to store the bounding box in. Returns a new box if not passed in.
 */
export function getBoundingBox(objects: Object3D | Object3D[], ignore: ((obj: Object3D) => void | boolean) | Array<Object3D | null | undefined> | undefined = undefined, layers: Layers | undefined | null = undefined, result: Box3 | undefined = undefined): Box3 {
    const box = result || new Box3();
    box.makeEmpty();

    const emptyChildren = [];
    function expandByObjectRecursive(obj: Object3D) {
        let allowExpanding = true;
        // we dont want to check invisible objects
        if (!obj.visible) return;
        if (useForAutoFit(obj) === false) return;
        if (obj.type === "TransformControlsGizmo" || obj.type === "TransformControlsPlane") return;
        // ignore Box3Helpers
        if (obj instanceof Box3Helper) allowExpanding = false;
        if (obj instanceof GridHelper) allowExpanding = false;
        // ignore GroundProjectedEnv
        if (obj instanceof GroundedSkybox) allowExpanding = false;
        if ((obj as any).isGizmo === true) allowExpanding = false;
        // // Ignore shadow catcher geometry
        if ((obj as Mesh).material instanceof ShadowMaterial) allowExpanding = false;
        // ONLY fit meshes
        if (!(isMesh(obj))) allowExpanding = false;
        // Layer test, typically with the main camera
        if (layers && obj.layers.test(layers) === false) allowExpanding = false;
        if (allowExpanding) {
            // Ignore things parented to the camera + ignore the camera
            if (ignore && Array.isArray(ignore) && ignore?.includes(obj)) return;
            else if (typeof ignore === "function") {
                if (ignore(obj) === true) return;
            }
        }
        // We don't want to fit UI objects
        if (obj["isUI"] === true) return;
        // If we encountered some geometry that should be ignored
        // Then we don't want to use that for expanding the view
        if (allowExpanding) {
            // Temporary override children
            const children = obj.children;
            obj.children = emptyChildren;
            // TODO: validate that object doesn't contain NaN values
            const pos = obj.position;
            const scale = obj.scale;
            if (Number.isNaN(pos.x) || Number.isNaN(pos.y) || Number.isNaN(pos.z)) {
                console.warn(`Object \"${obj.name}\" has NaN values in position or scale.... will ignore it`, pos, scale);
                return;
            }
            // Sanitize for the three.js method that only checks for undefined here
            // @ts-ignore
            if (obj.geometry === null) obj.geometry = undefined;
            box.expandByObject(obj, true);
            obj.children = children;
        }
        for (const child of obj.children) {
            expandByObjectRecursive(child);
        }
    }
    let hasAnyObject = false;

    if (!Array.isArray(objects))
        objects = [objects];

    for (const object of objects) {
        if (!object) continue;
        hasAnyObject = true;
        object.updateMatrixWorld();
        expandByObjectRecursive(object);
    }
    if (!hasAnyObject) {
        console.warn("No objects to fit camera to...");
        return box;
    }

    return box;
}

/**
 * Fits an object into a bounding volume. The volume is defined by a Box3 in world space.
 * @param obj the object to fit
 * @param volume the volume to fit the object into
 * @param opts options for fitting
 */
export function fitObjectIntoVolume(obj: Object3D, volume: Box3, opts?: {
    /** Objects to ignore when calculating the obj's bounding box */
    ignore?: Object3D[],
    /** when `true` aligns the objects position to the volume ground 
     * @default true
    */
    position?: boolean
    /** when `true` scales the object to fit the volume 
     * @default true
    */
    scale?: boolean,
}): {
    /** The object's bounding box before fitting */
    boundsBefore: Box3,
    /** The scale that was applied to the object */
    scale: Vector3,
} {
    const box = getBoundingBox([obj], opts?.ignore);

    const boundsSize = new Vector3();
    box.getSize(boundsSize);
    const boundsCenter = new Vector3();
    box.getCenter(boundsCenter);

    const targetSize = new Vector3();
    volume.getSize(targetSize);
    const targetCenter = new Vector3();
    volume.getCenter(targetCenter);

    const scale = new Vector3();
    scale.set(targetSize.x / boundsSize.x, targetSize.y / boundsSize.y, targetSize.z / boundsSize.z);
    const minScale = Math.min(scale.x, scale.y, scale.z);
    const useScale = opts?.scale !== false;
    if (useScale) {
        setWorldScale(obj, getWorldScale(obj).multiplyScalar(minScale));
    }

    if (opts?.position !== false) {
        const boundsBottomPosition = new Vector3();
        box.getCenter(boundsBottomPosition);
        boundsBottomPosition.y = box.min.y;
        const targetBottomPosition = new Vector3();
        volume.getCenter(targetBottomPosition);
        targetBottomPosition.y = volume.min.y;
        const offset = targetBottomPosition.clone().sub(boundsBottomPosition);
        if (useScale) offset.multiplyScalar(minScale);
        setWorldPosition(obj, getWorldPosition(obj).add(offset));
    }

    return {
        boundsBefore: box,
        scale,
    }
}


declare type PlaceOnSurfaceResult = {
    /** The offset from the object bounds to the pivot */
    offset: Vector3,
    /** The object's bounding box */
    bounds: Box3
}

/**
 * Place an object on a surface. This will calculate the object bounds which might be an expensive operation for complex objects. 
 * The object will be visually placed on the surface (the object's pivot will be ignored).
 * @param obj the object to place on the surface
 * @param point the point to place the object on
 * @returns the offset from the object bounds to the pivot
 */
export function placeOnSurface(obj: Object3D, point: Vector3): PlaceOnSurfaceResult {
    const bounds = getBoundingBox([obj]);
    const center = new Vector3();
    bounds.getCenter(center);
    center.y = bounds.min.y;
    const offset = point.clone().sub(center);
    const worldPos = getWorldPosition(obj);
    setWorldPosition(obj, worldPos.add(offset));
    return {
        offset,
        bounds,
    }
}

/**
 * Postprocesses the material of an object loaded by {@link FBXLoader}.
 * It will apply necessary color conversions, remap shininess to roughness, and turn everything into {@link MeshStandardMaterial} on the object.   
 * This ensures consistent lighting and shading, including environment effects.
 */
export function postprocessFBXMaterials(obj: Mesh, material: Material | Material[], index?: number, array?: Material[]): boolean {

    if (Array.isArray(material)) {
        let success = true;
        for (let i = 0; i < material.length; i++) {
            const res = postprocessFBXMaterials(obj, material[i], i, material);
            if (!res) success = false;
        }
        return success;
    }

    // ignore if the material is already a MeshStandardMaterial
    if (material.type === "MeshStandardMaterial" || material.type === "MeshBasicMaterial") {
        return false;
    }
    // check if the material was already processed
    else if (material["material:fbx"] != undefined) {
        return true;
    }

    const newMaterial = new MeshStandardMaterial();
    newMaterial["material:fbx"] = material;

    const oldMaterial = material as any;

    if (oldMaterial) {
        // If a map is present then the FBX color should be ignored
        // Tested e.g. in Unity and Substance Stager
        // https://docs.unity3d.com/2020.1/Documentation/Manual/FBXImporter-Materials.html#:~:text=If%20a%20diffuse%20Texture%20is%20set%2C%20it%20ignores%20the%20diffuse%20color%20(this%20matches%20how%20it%20works%20in%20Autodesk®%20Maya®%20and%20Autodesk®%203ds%20Max®)
        if (!oldMaterial.map)
            newMaterial.color.copyLinearToSRGB(oldMaterial.color);
        else newMaterial.color.set(1, 1, 1);

        newMaterial.emissive.copyLinearToSRGB(oldMaterial.emissive);

        newMaterial.emissiveIntensity = oldMaterial.emissiveIntensity;
        newMaterial.opacity = oldMaterial.opacity;
        newMaterial.displacementScale = oldMaterial.displacementScale;
        newMaterial.transparent = oldMaterial.transparent;
        newMaterial.bumpMap = oldMaterial.bumpMap;
        newMaterial.aoMap = oldMaterial.aoMap;
        newMaterial.map = oldMaterial.map;
        newMaterial.displacementMap = oldMaterial.displacementMap;
        newMaterial.emissiveMap = oldMaterial.emissiveMap;
        newMaterial.normalMap = oldMaterial.normalMap;
        newMaterial.envMap = oldMaterial.envMap;
        newMaterial.alphaMap = oldMaterial.alphaMap;
        newMaterial.metalness = oldMaterial.reflectivity;
        newMaterial.vertexColors = oldMaterial.vertexColors;
        if (oldMaterial.shininess) {
            // from blender source code 
            // https://github.com/blender/blender-addons/blob/5e66092bcbe0df6855b3fa814b4826add8b01360/io_scene_fbx/import_fbx.py#L1442
            // https://github.com/blender/blender-addons/blob/main/io_scene_fbx/import_fbx.py#L2060
            newMaterial.roughness = 1.0 - (Math.sqrt(oldMaterial.shininess) / 10.0);
        }
        newMaterial.needsUpdate = true;
    }

    if (index === undefined) {
        obj.material = newMaterial;
    }
    else {
        array![index] = newMaterial;
    }
    return true;
}


