Source: GrWorld.js

/*jshint esversion: 11 */
// @ts-check

/**
 * CS559 3D World Framework Code
 *
 * GrWorld - bascially a wrapper around scene, except that it uses
 *      GrObject instead of Object3D (GrObjects have Object3D)
 *
 * To make things simple, this keeps a renderer and a default camera.
 * Of course, that might complicate things if you want to have multiple
 * renderers and cameras
 *
 * Basically, this keeps some of the basic stuff you need when you use
 * three.
 * @module GrWorld 
 * */

// we need to have the BaseClass definition
import { GrObject } from "./GrObject.js";
import { insertElement } from "../CS559/inputHelpers.js";
import { SimpleGroundPlane } from "./GroundPlane.js";
import * as T from "../CS559-Three/build/three.module.js";
import { OrbitControls } from "../CS559-Three/examples/jsm/controls/OrbitControls.js";
import { FlyControls } from "../CS559-Three/examples/jsm/controls/FlyControls.js";

import { VRHelper } from './VRHelper.js'
import Stats from './Stats.js'

// alert("look at TODO in GrWorld");

/** Things to do post 2021  TODO
 * Better handling of rideable (let use controls)
 * Better documentation (make it so that GrObject parameters show up)
 * Convert to always use BufferGeometry (since Geometry is deprecated)
 * glTF loader
 * */

/**
 * Document the parameters for making a world - all are optional
 * we'll guess at something if you don't give it to us
 * @typedef GrWorldProperties
 * @property [camera] - use this camera if passed
 * @property [fov] - camera property if we make one
 * @property [near] - camera property if we make one
 * @property [far]  - camera property if we make one
 * @property [renderer] - if you don't give one, we'll make it
 * @property [renderparams] - parameters for making the renderer
 * @property [width] - canvas size
 * @property [height] - canvas size
 * @property [where] - where in the DOM to insert things
 * @property [lights] - a list of lights, or else default ones are made
 * @property [lightBrightness=.75] - brightness of the default lights
 * @property [lightColoring="cool-to-warm"] - either "c"ool-to-warm, "w"hite, or e"x"treme
 * @property [sideLightColors] - to make extreme lighting
 * @property [ambient=.5] - brightness of the ambient light in default light
 * @property [groundplane] - can be a groundplane, or False (otherwise, one gets made)
 * @property [groundplanecolor="green"] - if we create a ground plane, what color
 * @property [groundplanesize=5] - if we create a ground plane, how big
 * @property [lookfrom] - where to put the camera (only if we make the camera)
 * @property [lookat] - where to have the camera looking (only if we make the camera)
 * @property [background="black"] - color to set the background
 * @property {HTMLInputElement} [runbutton] - a checkbox (HTML) to switch things on or off (can be undefined)
 * @property {HTMLInputElement} [speedcontrol] - a slider to get the speed (must be an HTML element, not a LabelSlider)
 */

/** @class GrWorld
 *
 * The GrWorld is basically a wrapper around THREE.js's `scene`,
 * except that it keeps a list of `GrObject` rather than `Object3D`.
 *
 * It contains a `scene` (and it puts things into it for you).
 * It also contains a `renderer` and a `camera`.
 *
 * When this creates a renderer, it places it into the dom (see the `where` option).
 *
 * By default, the world is created with a reasonable default `renderer`,
 * `camera` and `groundplane`. Orbit controls are installed.
 */
export class GrWorld {
    /**
     * Construct an empty world
     * @param {GrWorldProperties} params
     */
    constructor(params = {}) {
        /** @type {Number} */
        this.objCount = 0;

        /** @type {Number} */
        this.animCount = 0;

        // mainly a set for checking object name legality
        /** @type {Object} */
        this.objNames = {};

        // this keeps a list of objects in the world
        /** @type Array<GrObject> */
        this.objects = [];

        // the GrWorld "has a" of the main things we need in three
        this.scene = new T.Scene();
        this.solo_scene = new T.Scene(); // secondary scene for showing a solo object.
        this.active_scene = this.scene; // active scene to draw.
        this.scene.background = new T.Color(params.background ? params.background : "black")

        // make a renderer if it isn't given
        /** @type THREE.WebGLRenderer */
        this.renderer =
            "renderer" in params
                ? params.renderer
                : new T.WebGLRenderer(
                    "renderparams" in params ? params.renderparams : {}
                );

        // width and height are tricky, since they can come from many places
        let width = 600;
        let height = 400;
        // if the renderer was given, get its DOM
        if ("renderer" in params) {
            width = params.renderer.domElement.width;
            height = params.renderer.domElement.height;
        } else if ("renderparams" in params && "canvas" in params.renderparams) {
            width = params.renderparams.canvas.width;
            height = params.renderparams.canvas.height;
        }
        // specified width/height overrides everything
        if ("width" in params) {
            width = params.width;
        }
        if ("height" in params) {
            height = params.height;
        }

        // get the light brightnesses
        if (!("lightBrightness" in params)) params.lightBrightness = 0.75;
        if (!("ambient" in params)) params.ambient = 0.5;

        // make things be the right size
        this.renderer.setSize(width, height);

        // make a groundplane (or install a given one)
        // we do this before we made the camera, since it's useful for placing the camera
        if ("groundplane" in params) {
            this.groundplane = params.groundplane;
        } else {
            // the default is to create a groundplane
            this.groundplane = new SimpleGroundPlane(
                params.groundplanesize || 5,
                0.2,
                params.groundplanecolor || "darkgreen"
            );
        }
        if (this.groundplane)
            this.add(this.groundplane);


        // we need this variable out here since we need to refer to it later
        let lookat = params.lookat;

        // make a camera
        /** @type {THREE.PerspectiveCamera} */
        this.camera = undefined;
        if ("camera" in params) {
            this.camera = params.camera;
        } else {
            this.camera = new T.PerspectiveCamera(
                "fov" in params ? params.fov : 45,
                width / height,
                "near" in params ? params.near : 0.1,
                "far" in params ? params.far : 2000
            );
            /* figure out a default lookat */
            if (!("lookat" in params)) {
                lookat = new T.Vector3(0, 0, 0);
            }
            let lookfrom = params.lookfrom;
            if (!("lookfrom" in params)) {
                let gpSize = this.groundplane ? this.groundplane.size : 10;
                lookfrom = new T.Vector3(gpSize / 2, gpSize, gpSize * 2);
            }
            this.camera.position.copy(lookfrom);
            this.camera.lookAt(lookat);
            this.camera.name = "World Camera";
        }
        this.active_camera = this.camera;
        // create a camera for viewing a solo object.
        // This isn't something the user should worry about, so values are set directly.
        this.solo_camera = new T.PerspectiveCamera(45, width / height, 0.1, 2000);
        this.solo_camera.name = "Solo Camera";
        this.solo_camera.position.set(1, 1, 1);

        // make the controls
        if (this.active_camera.isPerspectiveCamera) {
            this.orbit_controls = new OrbitControls(
                this.active_camera,
                this.renderer.domElement
            );
            this.orbit_controls.keys = { UP: 87, BOTTOM: 83, LEFT: 65, RIGHT: 68 };
            this.orbit_controls.target = lookat;

            // We also want a pointer to active set of controls.
            this.active_controls = this.orbit_controls;
            this.fly_controls = new FlyControls(
                this.active_camera,
                this.renderer.domElement
            );
            this.fly_controls.dragToLook = true;
            this.fly_controls.rollSpeed = 0.1;
            this.fly_controls.dispose();
            let flySaveState = function () {
                this.position0 = new T.Vector3(
                    this.object.position.x,
                    this.object.position.y,
                    this.object.position.z
                );
            };
            let flyReset = function () {
                if (this.position0) {
                    this.object.position.set(
                        this.position0.x,
                        this.position0.y,
                        this.position0.z
                    );
                }
                this.update(0.1);
            };
            let register = function () {
                function bind(scope, fn) {
                    return function () {
                        fn.apply(scope, arguments);
                    };
                }
                this.domElement.addEventListener(
                    "mousemove",
                    bind(this, this.mousemove),
                    false
                );
                this.domElement.addEventListener(
                    "mousedown",
                    bind(this, this.mousedown),
                    false
                );
                this.domElement.addEventListener(
                    "mouseup",
                    bind(this, this.mouseup),
                    false
                );

                window.addEventListener("keydown", bind(this, this.keydown), false);
                window.addEventListener("keyup", bind(this, this.keyup), false);
            };
            if (!this.fly_controls.saveState) {
                this.fly_controls.saveState = flySaveState;
                this.fly_controls.reset = flyReset;
            }
            if (!this.fly_controls.register) {
                this.fly_controls.register = register;
            }
        } // only make controls for PerspectiveCameras

        // if we either specify where things go in the DOM or we made our
        // own canvas, install it
        if (
            "where" in params ||
            !("renderer" in params) ||
            "renderparams" in params
        ) {
            insertElement(
                this.renderer.domElement,
                "where" in params ? params.where : undefined
            );
        }

        /**
         * Some Lights
         */
        if ("lights" in params) {
            if (params.lights.length) {
                params.lights.forEach(light => this.scene.add(light));
            }
            this.ambient = undefined;
        } else {
            this.ambient = new T.AmbientLight("white", params.ambient);
            this.scene.add(this.ambient);

            // colors for the three "key" lights - maybe be overridden with sideLightColors
            let leftColor = 0xffffff;
            let rightColor = 0xffffff;
            let bottomColor = 0xffffff;

            switch ((params.lightColoring || "cool-to-warm")[0]) {
                case "c": // cool to warm
                case "C":
                    leftColor = 0xfff0c0;
                    rightColor = 0xc0f0ff;
                    bottomColor = 0xffc0ff;
                    break;
                case "x": // extremely cool to wam
                case "X":
                case "e":
                case "E":
                    leftColor = 0xffe080;
                    rightColor = 0x80e0ff;
                    bottomColor = 0xff80ff;
                    break;
                case "W":
                case "w":
                    break;
                default:
                    console.log(
                        `Bad coloring ${params.lightColoring} to GrWorld - assuming white`
                    );
            }

            // three lights - all a little off white to give some contrast
            let leftLight = params.sideLightColors
                ? params.sideLightColors[0]
                : leftColor;
            let rightLight = params.sideLightColors
                ? params.sideLightColors[1]
                : rightColor;

            let dirLight1 = new T.DirectionalLight(leftLight, params.lightBrightness);
            dirLight1.position.set(1, 1, -0.4);
            this.scene.add(dirLight1);

            let dirLight2 = new T.DirectionalLight(
                rightLight,
                params.lightBrightness
            );
            dirLight2.position.set(-1, 1, -0.4);
            this.scene.add(dirLight2);

            let bottomLight = new T.DirectionalLight(
                bottomColor,
                params.lightBrightness / 3
            );
            bottomLight.position.set(0, -1, 0.1);
            this.scene.add(bottomLight);
        }
        // add a pair of lights to the "solo" scene as well.
        this.solo_scene.add(new T.AmbientLight(0xfffff8, 0.6));
        this.solo_scene.add(new T.DirectionalLight(0xf8f8ff, 1.0));

        // Keep track of rendering timings
        this.lastRenderTime = 0;
        this.lastTimeOfDay = 12;

        // Track the "active" object, which we may follow, view solo, etc.
        /**@type GrObject */
        this.active_object = undefined;
        this.solo_mode = false;
        this.view_mode = "Orbit Camera";

        // Have a switch for turning things on and off
        /** @type {HTMLInputElement} */
        this.runbutton = params.runbutton;
        /** @type {HTMLInputElement} */
        this.speedcontrol = params.speedcontrol;
    } // end of constructor

    restoreActiveObject() {
        if (this.active_object) {
            // In case we were in drive mode, make the active object visible.
            let showObject = function (ob) {
                ob.visible = true;
                ob.children.forEach(child => {
                    showObject(child);
                });
            };
            this.active_object.objects.forEach(ob => {
                showObject(ob);
            });
            // In case we were in solo mode, put the active object back in the main scene.
            this.active_object.objects.forEach(element => {
                this.scene.add(element);
            });
        }
    }

    setActiveObject(name) {
        // Restore the previous object before setting a new one.
        this.restoreActiveObject();
        // We assume each object has a unique name to search on.
        this.active_object = this.objects.find(ob => ob.name === name);
        // In case we are already in an object-centric mode, focus on the new active object.
        this.currentStateOn();
        if (this.solo_mode) {
            this.showSoloObject();
        }
    }

    currentStateOff() {
        switch (this.view_mode) {
            case "Orbit Camera":
                this.orbitControlOff();
                break;
            case "Fly Camera":
                this.flyControlOff();
                break;
            case "Follow Object":
                this.followObjectOff();
                break;
            case "Drive Object":
                this.driveObjectOff();
                break;
            default:
                break;
        }
    }

    currentStateOn() {
        switch (this.view_mode) {
            case "Orbit Camera":
                this.orbitControlOn();
                break;
            case "Fly Camera":
                this.flyControlOn();
                break;
            case "Follow Object":
                this.followObjectOn();
                break;
            case "Drive Object":
                this.driveObjectOn();
                break;
            default:
                break;
        }
    }

    setViewMode(mode) {
        // first, turn off old mode.
        if (this.active_object) {
            this.restoreActiveObject();
        }
        this.currentStateOff();
        // then, turn on new mode.
        this.view_mode = mode;
        if (this.solo_mode) {
            this.showSoloObject();
        } else {
            this.showWorld();
        }
        this.currentStateOn();
    }

    showSoloObject() {
        this.solo_mode = true;
        // put active object in solo scene, and render the solo scene.
        this.active_object.objects.forEach(element => {
            this.solo_scene.add(element);
        });
        this.orbit_controls.object = this.solo_camera;
        this.fly_controls.object = this.solo_camera;
        this.active_camera = this.solo_camera;
        this.active_scene = this.solo_scene;
        this.currentStateOn();
    }

    showWorld() {
        this.solo_mode = false;
        if (this.active_object) {
            this.active_object.objects.forEach(element => {
                this.scene.add(element);
            });
        } else {
            console.warn("No active object when expecting one!");
        }
        this.orbit_controls.object = this.camera;
        // this.orbit_controls.update();
        if (this.fly_controls) {
            this.fly_controls.object = this.camera;
        }
        this.active_camera = this.camera;
        this.active_scene = this.scene;
        this.currentStateOn();
    }

    orbitControlOn() {
        this.orbit_controls.enabled = true;
        if (this.solo_mode && this.active_object) {
            let camparams = this.active_object.lookFromLookAt();
            this.solo_camera.position.set(camparams[0], camparams[1], camparams[2]);
            this.active_camera.lookAt(camparams[3], camparams[4], camparams[5]);
            // set controls to use whatever the active camera is, and position so it can see the active object.
            this.orbit_controls.target.set(camparams[3], camparams[4], camparams[5]);
            this.orbit_controls.update();
        } else {
            // @ts-ignore
            this.orbit_controls.reset();
        }
    }

    orbitControlOff() {
        if (!this.solo_mode) {
            // @ts-ignore
            this.orbit_controls.saveState();
        }
        this.orbit_controls.enabled = false;
    }

    flyControlOn() {
        if (this.solo_mode && this.active_object) {
            let camparams = this.active_object.lookFromLookAt();
            this.solo_camera.position.set(camparams[0], camparams[1], camparams[2]);
            this.active_camera.lookAt(camparams[3], camparams[4], camparams[5]);
        } else {
            // @ts-ignore
            this.fly_controls.reset();
        }
        this.fly_controls.register();
    }

    flyControlOff() {
        if (!this.solo_mode) {
            // @ts-ignore
            this.fly_controls.saveState();
        }
        this.fly_controls.dispose();
    }

    followObjectOn() {
        if (this.active_object.rideable) {
            this.active_object.rideable.add(this.solo_camera);
            this.active_object.rideable.add(this.camera);
            let bbox = new T.Box3();
            bbox.setFromObject(this.active_object.objects[0]);
            this.camera.position.set(
                0,
                bbox.max.y - bbox.min.y,
                -1.5 * (bbox.max.z - bbox.min.z)
            );
            this.solo_camera.position.set(
                0,
                bbox.max.y - bbox.min.y,
                -1.5 * (bbox.max.z - bbox.min.z)
            );
            // Set look direction
            let target = this.active_object.objects[0].position;
            this.camera.lookAt(target);
            this.solo_camera.lookAt(target);
        } else {
            this.followObjectOff();
        }
    }

    followObjectOff() {
        this.scene.add(this.camera);
        this.solo_scene.add(this.solo_camera);
    }

    driveObjectOn() {
        if (this.active_object.rideable) {
            let hideObject = function (ob) {
                ob.visible = false;
                ob.children.forEach(child => {
                    hideObject(child);
                });
            };
            this.active_object.rideable.add(this.solo_camera);
            this.active_object.rideable.add(this.camera);
            this.camera.position.set(0, 0, 0);
            this.camera.rotation.set(0, Math.PI, 0);
            this.solo_camera.position.set(0, 0, 0);
            this.solo_camera.rotation.set(0, Math.PI, 0);
            this.active_object.objects.forEach(ob => {
                hideObject(ob);
            });
        } else {
            this.driveObjectOff();
        }
    }

    driveObjectOff() {
        this.restoreActiveObject();
        this.scene.add(this.camera);
        this.solo_scene.add(this.solo_camera);
    }

    /**
     * Add an object to the world - this takes care of putting everything
     * into the scene, as well as assigning IDs
     * @param {GrObject} grobj
     */
    add(grobj) {
        if (grobj.id) {
            console.warn(
                `Adding GrObj that already has an assigned ID. Object named "${grobj.name}"`
            );
        } else {
            grobj.id = this.objCount++;
        }

        if (grobj.name in this.objNames) {
            console.warn(
                `Adding GrObj with non-unique name. Object named "${grobj.name}"`
            );
        } else {
            this.objNames[grobj.name] = grobj;
        }

        this.objects.push(grobj);
        // be sure to add all the objects to the scene
        grobj.objects.forEach(element => {
            this.scene.add(element);
        });
    }

    /**
     * adds performance stats to the DOM
     */
    viewStats() {
        this.stats = Stats();
        this.stats.setMode(0);

        this.stats.dom.style.position = 'absolute';
        this.stats.dom.style.left = '0';
        this.stats.dom.style.top = '0';
        document.body.appendChild( this.stats.dom );
    }

    /**
     * adds VR capability
     */
    enableVR() {
        this.VRHelper = new VRHelper({
            renderer: this.renderer,
            scene: this.scene,
            camera: this.camera,
            flightSpeed: 10,
        })
    }

    /**
     * draw the default camera to the default renderer
     */
    draw() {
        this.lastRenderTime = performance.now();
        this.renderer.render(this.active_scene, this.active_camera);
    }

    /**
     * advance all of the objects
     */
    stepWorld(delta, timeOfDay) {
        this.objects.forEach(obj => obj.stepWorld(delta, timeOfDay));
    }

    /** callback list - used in 2 places, so document once 
     * @typedef {Object} WorldCallbacks
      * @property {function} [prefirst] - called before the first loop go around
      * @property {function} [prestep] - called before the step (even if there is no step)
      * @property {function} [stepWorld] - called after the step (only if there is a step)
      * @property {function} [predraw] - called before drawing (after the step - if there is one)
      * @property {function} [postdraw] - called after drawing
      * @property {function} [first] - called after the end of the first loop go-around
     * 
    */

    /**
      * perform a cycle of the animation loop - this measures the time since the
      * last redraw and advances that much before redrawing
      * 
      * because draw is part of animate, the callbacks are handled here
      * 
      * @param {WorldCallbacks} [callbacks]
      */
    animate(callbacks = {}) {
        if (!this.animCount && callbacks.prefirst) { callbacks.prefirst(this); }
        if (callbacks.prestep) callbacks.prestep(this);

        if (!this.runbutton || this.runbutton.checked) {
            let delta = performance.now() - this.lastRenderTime;
            let speed = this.speedcontrol ? Number(this.speedcontrol.value) : 1.0;
            this.stepWorld(delta * speed, this.lastTimeOfDay);
            if (callbacks.stepWorld) callbacks.stepWorld(this);
        }
        // since we're already running an animation loop, update view controls here.
        // Pass in a delta since that's what fly controls want. Orbit controls can just ignore.
        if ((this.view_mode == "Orbit Camera") && this.orbit_controls) {
            this.orbit_controls.update();
        }
        else if ((this.view_mode == "Fly Camera") && this.fly_controls) {
            this.fly_controls.update(0.1);
        }
        this.VRHelper?.update()

        if (callbacks.predraw) callbacks.predraw(this);
        this.draw();
        if (callbacks.postdraw) callbacks.postdraw(this);

        if (!this.animCount && callbacks.first) { callbacks.first(this); }
        this.animCount += 1;
    }

    /**
     * start an (endless) animation loop - this just keeps going
     *
     * 
     * @param {WorldCallbacks} callbacks 
     */
    go(callbacks = {}) {
        let count = 0;
        // remember, this gets redefined (it doesn't follow scope rules)
        let self = this;
        function loop() {

            self.stats?.begin()

            self.animate(callbacks);

            count += 1;

            self.stats?.end()

            // self.draw();     // animate does the draw
            self.renderer.setAnimationLoop(loop)
        }
        loop();
    }
}