/* @license
 * Copyright 2019 Google LLC. All Rights Reserved.
 * Licensed under the Apache License, Version 2.0 (the 'License');
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an 'AS IS' BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import { AnimationMixer, Box3, Matrix3, Object3D, PerspectiveCamera, Raycaster, Scene, Vector3 } from 'three';
import { USE_OFFSCREEN_CANVAS } from '../constants.js';
import { $renderer } from '../model-viewer-base.js';
import { Damper, SETTLING_TIME } from './Damper.js';
import { Hotspot } from './Hotspot.js';
import { reduceVertices } from './ModelUtils.js';
import { Shadow } from './Shadow.js';
export const IlluminationRole = {
    Primary: 'primary',
    Secondary: 'secondary'
};
export const DEFAULT_FOV_DEG = 45;
const DEFAULT_HALF_FOV = (DEFAULT_FOV_DEG / 2) * Math.PI / 180;
export const SAFE_RADIUS_RATIO = Math.sin(DEFAULT_HALF_FOV);
export const DEFAULT_TAN_FOV = Math.tan(DEFAULT_HALF_FOV);
const view = new Vector3();
const target = new Vector3();
const normalWorld = new Vector3();
const raycaster = new Raycaster();
const vector3 = new Vector3();
/**
 * A THREE.Scene object that takes a Model and CanvasHTMLElement and
 * constructs a framed scene based off of the canvas dimensions.
 * Provides lights and cameras to be used in a renderer.
 */
export class ModelScene extends Scene {
    constructor({ canvas, element, width, height }) {
        super();
        this.context = null;
        this.width = 1;
        this.height = 1;
        this.aspect = 1;
        this.isDirty = false;
        this.renderCount = 0;
        // These default camera values are never used, as they are reset once the
        // model is loaded and framing is computed.
        this.camera = new PerspectiveCamera(45, 1, 0.1, 100);
        this.url = null;
        this.target = new Object3D();
        this.modelContainer = new Object3D();
        this.animationNames = [];
        this.boundingBox = new Box3();
        this.size = new Vector3();
        this.idealCameraDistance = 0;
        this.fieldOfViewAspect = 0;
        this.framedFieldOfView = DEFAULT_FOV_DEG;
        this.shadow = null;
        this.shadowIntensity = 0;
        this.shadowSoftness = 1;
        this.exposure = 1;
        this.canScale = true;
        this.tightBounds = false;
        this.goalTarget = new Vector3();
        this.targetDamperX = new Damper();
        this.targetDamperY = new Damper();
        this.targetDamperZ = new Damper();
        this._currentGLTF = null;
        this.cancelPendingSourceChange = null;
        this.animationsByName = new Map();
        this.currentAnimationAction = null;
        this.name = 'ModelScene';
        this.element = element;
        this.canvas = canvas;
        // These default camera values are never used, as they are reset once the
        // model is loaded and framing is computed.
        this.camera = new PerspectiveCamera(45, 1, 0.1, 100);
        this.camera.name = 'MainCamera';
        this.activeCamera = this.camera;
        this.add(this.target);
        this.setSize(width, height);
        this.target.name = 'Target';
        this.modelContainer.name = 'ModelContainer';
        this.target.add(this.modelContainer);
        this.mixer = new AnimationMixer(this.modelContainer);
    }
    /**
     * Function to create the context lazily, as when there is only one
     * <model-viewer> element, the renderer's 3D context can be displayed
     * directly. This extra context is necessary to copy the renderings into when
     * there are more than one.
     */
    createContext() {
        if (USE_OFFSCREEN_CANVAS) {
            this.context = this.canvas.getContext('bitmaprenderer');
        }
        else {
            this.context = this.canvas.getContext('2d');
        }
    }
    /**
     * Pass in a THREE.Object3D to be controlled
     * by this model.
     */
    async setObject(model) {
        this.reset();
        this.modelContainer.add(model);
        await this.setupScene();
    }
    /**
     * Sets the model via URL.
     */
    async setSource(url, progressCallback) {
        if (!url || url === this.url) {
            if (progressCallback) {
                progressCallback(1);
            }
            return;
        }
        // If we have pending work due to a previous source change in progress,
        // cancel it so that we do not incur a race condition:
        if (this.cancelPendingSourceChange != null) {
            this.cancelPendingSourceChange();
            this.cancelPendingSourceChange = null;
        }
        let gltf;
        try {
            gltf = await new Promise(async (resolve, reject) => {
                this.cancelPendingSourceChange = () => reject();
                try {
                    const result = await this.element[$renderer].loader.load(url, this.element, progressCallback);
                    resolve(result);
                }
                catch (error) {
                    reject(error);
                }
            });
        }
        catch (error) {
            if (error == null) {
                // Loading was cancelled, so silently return
                return;
            }
            throw error;
        }
        this.reset();
        this.url = url;
        this._currentGLTF = gltf;
        if (gltf != null) {
            this.modelContainer.add(gltf.scene);
        }
        const { animations } = gltf;
        const animationsByName = new Map();
        const animationNames = [];
        for (const animation of animations) {
            animationsByName.set(animation.name, animation);
            animationNames.push(animation.name);
        }
        this.animations = animations;
        this.animationsByName = animationsByName;
        this.animationNames = animationNames;
        await this.setupScene();
    }
    async setupScene() {
        this.updateBoundingBox();
        let target = null;
        if (this.tightBounds === true) {
            await this.element.requestUpdate('cameraTarget');
            target = this.getTarget();
        }
        this.updateFraming(target);
        this.frameModel();
        this.setShadowIntensity(this.shadowIntensity);
        this.isDirty = true;
        this.dispatchEvent({ type: 'model-load', url: this.url });
    }
    reset() {
        this.url = null;
        const gltf = this._currentGLTF;
        // Remove all current children
        if (gltf != null) {
            for (const child of this.modelContainer.children) {
                this.modelContainer.remove(child);
            }
            gltf.dispose();
            this._currentGLTF = null;
        }
        if (this.currentAnimationAction != null) {
            this.currentAnimationAction.stop();
            this.currentAnimationAction = null;
        }
        this.mixer.stopAllAction();
        this.mixer.uncacheRoot(this);
    }
    get currentGLTF() {
        return this._currentGLTF;
    }
    /**
     * Updates the ModelScene for a new container size in CSS pixels.
     */
    setSize(width, height) {
        if (this.width === width && this.height === height) {
            return;
        }
        this.width = Math.max(width, 1);
        this.height = Math.max(height, 1);
        this.aspect = this.width / this.height;
        this.frameModel();
        this.isDirty = true;
    }
    updateBoundingBox() {
        this.target.remove(this.modelContainer);
        if (this.tightBounds === true) {
            const bound = (box, vertex) => {
                return box.expandByPoint(vertex);
            };
            this.boundingBox = reduceVertices(this.modelContainer, bound, new Box3());
        }
        else {
            this.boundingBox.setFromObject(this.modelContainer);
        }
        this.boundingBox.getSize(this.size);
        this.target.add(this.modelContainer);
    }
    /**
     * Calculates the idealCameraDistance and fieldOfViewAspect that allows the 3D
     * object to be framed tightly in a 2D window of any aspect ratio without
     * clipping at any camera orbit. The camera's center target point can be
     * optionally specified. If no center is specified, it defaults to the center
     * of the bounding box, which means asymmetric models will tend to be tight on
     * one side instead of both. Proper choice of center can correct this.
     */
    updateFraming(center = null) {
        this.target.remove(this.modelContainer);
        if (center == null) {
            center = this.boundingBox.getCenter(new Vector3);
        }
        const radiusSquared = (value, vertex) => {
            return Math.max(value, center.distanceToSquared(vertex));
        };
        const framedRadius = Math.sqrt(reduceVertices(this.modelContainer, radiusSquared, 0));
        this.idealCameraDistance = framedRadius / SAFE_RADIUS_RATIO;
        const horizontalFov = (value, vertex) => {
            vertex.sub(center);
            const radiusXZ = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z);
            return Math.max(value, radiusXZ / (this.idealCameraDistance - Math.abs(vertex.y)));
        };
        this.fieldOfViewAspect =
            reduceVertices(this.modelContainer, horizontalFov, 0) / DEFAULT_TAN_FOV;
        this.target.add(this.modelContainer);
    }
    /**
     * Set's the framedFieldOfView based on the aspect ratio of the window in
     * order to keep the model fully visible at any camera orientation.
     */
    frameModel() {
        const vertical = DEFAULT_TAN_FOV * Math.max(1, this.fieldOfViewAspect / this.aspect);
        this.framedFieldOfView = 2 * Math.atan(vertical) * 180 / Math.PI;
    }
    /**
     * Returns the size of the corresponding canvas element.
     */
    getSize() {
        return { width: this.width, height: this.height };
    }
    /**
     * Returns the current camera.
     */
    getCamera() {
        return this.activeCamera;
    }
    /**
     * Sets the passed in camera to be used for rendering.
     */
    setCamera(camera) {
        this.activeCamera = camera;
    }
    /**
     * Sets the point in model coordinates the model should orbit/pivot around.
     */
    setTarget(modelX, modelY, modelZ) {
        this.goalTarget.set(-modelX, -modelY, -modelZ);
    }
    /**
     * Gets the point in model coordinates the model should orbit/pivot around.
     */
    getTarget() {
        return vector3.copy(this.goalTarget).multiplyScalar(-1);
    }
    /**
     * Shifts the model to the target point immediately instead of easing in.
     */
    jumpToGoal() {
        this.updateTarget(SETTLING_TIME);
    }
    /**
     * This should be called every frame with the frame delta to cause the target
     * to transition to its set point.
     */
    updateTarget(delta) {
        const goal = this.goalTarget;
        const target = this.target.position;
        if (!goal.equals(target)) {
            const radius = this.idealCameraDistance;
            let { x, y, z } = target;
            x = this.targetDamperX.update(x, goal.x, delta, radius);
            y = this.targetDamperY.update(y, goal.y, delta, radius);
            z = this.targetDamperZ.update(z, goal.z, delta, radius);
            this.target.position.set(x, y, z);
            this.target.updateMatrixWorld();
            this.setShadowRotation(this.yaw);
            this.isDirty = true;
        }
    }
    /**
     * Yaw the +z (front) of the model toward the indicated world coordinates.
     */
    pointTowards(worldX, worldZ) {
        const { x, z } = this.position;
        this.yaw = Math.atan2(worldX - x, worldZ - z);
    }
    /**
     * Yaw is the scene's orientation about the y-axis, around the rotation
     * center.
     */
    set yaw(radiansY) {
        this.rotation.y = radiansY;
        this.updateMatrixWorld(true);
        this.setShadowRotation(radiansY);
        this.isDirty = true;
    }
    get yaw() {
        return this.rotation.y;
    }
    set animationTime(value) {
        this.mixer.setTime(value);
    }
    get animationTime() {
        if (this.currentAnimationAction != null) {
            return this.currentAnimationAction.time;
        }
        return 0;
    }
    get duration() {
        if (this.currentAnimationAction != null &&
            this.currentAnimationAction.getClip()) {
            return this.currentAnimationAction.getClip().duration;
        }
        return 0;
    }
    get hasActiveAnimation() {
        return this.currentAnimationAction != null;
    }
    /**
     * Plays an animation if there are any associated with the current model.
     * Accepts an optional string name of an animation to play. If no name is
     * provided, or if no animation is found by the given name, always falls back
     * to playing the first animation.
     */
    playAnimation(name = null, crossfadeTime = 0) {
        if (this._currentGLTF == null) {
            return;
        }
        const { animations } = this;
        if (animations == null || animations.length === 0) {
            console.warn(`Cannot play animation (model does not have any animations)`);
            return;
        }
        let animationClip = null;
        if (name != null) {
            animationClip = this.animationsByName.get(name);
        }
        if (animationClip == null) {
            animationClip = animations[0];
        }
        try {
            const { currentAnimationAction: lastAnimationAction } = this;
            this.currentAnimationAction =
                this.mixer.clipAction(animationClip, this).play();
            this.currentAnimationAction.enabled = true;
            if (lastAnimationAction != null &&
                this.currentAnimationAction !== lastAnimationAction) {
                this.currentAnimationAction.crossFadeFrom(lastAnimationAction, crossfadeTime, false);
            }
        }
        catch (error) {
            console.error(error);
        }
    }
    stopAnimation() {
        if (this.currentAnimationAction != null) {
            this.currentAnimationAction.stop();
            this.currentAnimationAction.reset();
            this.currentAnimationAction = null;
        }
        this.mixer.stopAllAction();
    }
    updateAnimation(step) {
        this.mixer.update(step);
    }
    /**
     * Call if the object has been changed in such a way that the shadow's shape
     * has changed (not a rotation about the Y axis).
     */
    updateShadow() {
        const shadow = this.shadow;
        if (shadow != null) {
            const side = this.element.arPlacement === 'wall' ? 'back' : 'bottom';
            shadow.setScene(this, this.shadowSoftness, side);
        }
    }
    /**
     * Sets the shadow's intensity, lazily creating the shadow as necessary.
     */
    setShadowIntensity(shadowIntensity) {
        let shadow = this.shadow;
        const side = this.element.arPlacement === 'wall' ? 'back' : 'bottom';
        if (shadow != null) {
            shadow.setIntensity(shadowIntensity);
            shadow.setScene(this, this.shadowSoftness, side);
        }
        else if (shadowIntensity > 0) {
            shadow = new Shadow(this, this.shadowSoftness, side);
            shadow.setIntensity(shadowIntensity);
            this.shadow = shadow;
        }
    }
    /**
     * Sets the shadow's softness by mapping a [0, 1] softness parameter to the
     * shadow's resolution. This involves reallocation, so it should not be
     * changed frequently. Softer shadows are cheaper to render.
     */
    setShadowSoftness(softness) {
        this.shadowSoftness = softness;
        const shadow = this.shadow;
        if (shadow != null) {
            shadow.setSoftness(softness);
        }
    }
    /**
     * The shadow must be rotated manually to match any global rotation applied to
     * this model. The input is the global orientation about the Y axis.
     */
    setShadowRotation(radiansY) {
        const shadow = this.shadow;
        if (shadow != null) {
            shadow.setRotation(radiansY);
        }
    }
    /**
     * Call to check if the shadow needs an updated render; returns true if an
     * update is needed and resets the state.
     */
    isShadowDirty() {
        const shadow = this.shadow;
        if (shadow == null) {
            return false;
        }
        else {
            const { needsUpdate } = shadow;
            shadow.needsUpdate = false;
            return needsUpdate;
        }
    }
    /**
     * Shift the floor vertically from the bottom of the model's bounding box by
     * offset (should generally be negative).
     */
    setShadowScaleAndOffset(scale, offset) {
        const shadow = this.shadow;
        if (shadow != null) {
            shadow.setScaleAndOffset(scale, offset);
        }
    }
    /**
     * This method returns the world position and model-space normal of the point
     * on the mesh corresponding to the input pixel coordinates given relative to
     * the model-viewer element. If the mesh is not hit, the result is null.
     */
    positionAndNormalFromPoint(pixelPosition, object = this) {
        raycaster.setFromCamera(pixelPosition, this.getCamera());
        const hits = raycaster.intersectObject(object, true);
        if (hits.length === 0) {
            return null;
        }
        const hit = hits[0];
        if (hit.face == null) {
            return null;
        }
        hit.face.normal.applyNormalMatrix(new Matrix3().getNormalMatrix(hit.object.matrixWorld));
        return { position: hit.point, normal: hit.face.normal };
    }
    /**
     * The following methods are for operating on the set of Hotspot objects
     * attached to the scene. These come from DOM elements, provided to slots by
     * the Annotation Mixin.
     */
    addHotspot(hotspot) {
        this.target.add(hotspot);
    }
    removeHotspot(hotspot) {
        this.target.remove(hotspot);
    }
    /**
     * Helper method to apply a function to all hotspots.
     */
    forHotspots(func) {
        const { children } = this.target;
        for (let i = 0, l = children.length; i < l; i++) {
            const hotspot = children[i];
            if (hotspot instanceof Hotspot) {
                func(hotspot);
            }
        }
    }
    /**
     * Update the CSS visibility of the hotspots based on whether their normals
     * point toward the camera.
     */
    updateHotspots(viewerPosition) {
        this.forHotspots((hotspot) => {
            view.copy(viewerPosition);
            target.setFromMatrixPosition(hotspot.matrixWorld);
            view.sub(target);
            normalWorld.copy(hotspot.normal)
                .transformDirection(this.target.matrixWorld);
            if (view.dot(normalWorld) < 0) {
                hotspot.hide();
            }
            else {
                hotspot.show();
            }
        });
    }
    /**
     * Rotate all hotspots to an absolute orientation given by the input number of
     * radians. Zero returns them to upright.
     */
    orientHotspots(radians) {
        this.forHotspots((hotspot) => {
            hotspot.orient(radians);
        });
    }
    /**
     * Set the rendering visibility of all hotspots. This is used to hide them
     * during transitions and such.
     */
    setHotspotsVisibility(visible) {
        this.forHotspots((hotspot) => {
            hotspot.visible = visible;
        });
    }
}
//# sourceMappingURL=ModelScene.js.map