Source: src/project.js

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GroundedSkybox } from 'three/addons/objects/GroundedSkybox.js';
import { RectAreaLightHelper } from 'three/addons/helpers/RectAreaLightHelper.js';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
import { Context } from '/js/src/context.js';
import { Handlers } from './handlers';
import { LoaderOverlay } from './LoaderOverlay.js';

/**
 * The Project Module.
 * @module Project
 */
const Project = (() => {
  /**
   * The Context constant provides a neutral scope to use as part of the state handling tasks.
   * @name module:Project.context
   * @private
   */
  const context = Context;

  /**
   * The Stage set function prepares the three js environment that will run the scenes.
   * @alias module:Project.setStage
   * @returns {THREE.Scene} scene
   */
  const stage_set = () => {
    console.group('stage_set');
    // Scene
    const scene = new THREE.Scene();

    // Name the main scene
    scene.name = 'Main scene';

    scene.background = new THREE.Color(0x443333);
    scene.fog = new THREE.Fog(0xcdcdcd, 0.3, 22);

    // Add props
    // const stage_add_props;
    // Ground
    // const plane = new THREE.Mesh(
    //   new THREE.PlaneGeometry(8, 8),
    //   new THREE.MeshPhongMaterial({ color: 0xcbcbcb, specular: 0x101010 })
    // );
    // plane.rotation.x = - Math.PI / 2;
    // plane.position.y = -0.0015;
    // plane.receiveShadow = true;
    //scene.add( plane );

    // Spotlight
    const spotLight = new THREE.SpotLight();
    spotLight.intensity = 0.7;
    spotLight.angle = Math.PI * 2;
    spotLight.penumbra = 0.5;
    spotLight.castShadow = true;
    spotLight.position.set(1, 2, 2);
    spotLight.lookAt(0, 0, 0);
    scene.add(spotLight);
    const spotLight2 = new THREE.SpotLight();
    spotLight2.intensity = 0.7;
    spotLight2.angle = Math.PI * 2;
    spotLight2.penumbra = 0.5;
    spotLight2.castShadow = true;
    spotLight2.position.set(-1, 2, -2);
    spotLight2.lookAt(0, 0, 0);
    scene.add(spotLight2);
    const spotLight3 = new THREE.SpotLight();
    spotLight3.intensity = 0.7;
    spotLight3.angle = Math.PI * 2;
    spotLight3.penumbra = 0.5;
    spotLight3.castShadow = true;
    spotLight3.position.set(2, 2, 1);
    spotLight3.lookAt(0, 0, 0);
    scene.add(spotLight3);
    const spotLight4 = new THREE.SpotLight();
    spotLight4.intensity = 0.7;
    spotLight4.angle = Math.PI * 2;
    spotLight4.penumbra = 0.5;
    spotLight4.castShadow = true;
    spotLight4.position.set(-1, 2, 2);
    spotLight4.lookAt(0, 0, 0);
    scene.add(spotLight4);

    // // Ambient light
    const AmgLight = new THREE.AmbientLight(0xffffff, 1);
    scene.add(AmgLight);

    // Rect Area Light
    const width = 10;
    const height = 10;
    const intensity = 1.5;
    const rectLight = new THREE.RectAreaLight(0xffffff, intensity, width, height);
    rectLight.position.set(5, 5, 0);
    rectLight.lookAt(0, 1.5, 0);
    scene.add(rectLight);
    // const rectLightHelper = new RectAreaLightHelper(rectLight);
    // rectLight.add(rectLightHelper);

    const EMLight = new THREE.HemisphereLight(0xffacac, 0xcdcdcd, 1);
    EMLight.position.set(2, 5, 2);
    EMLight.lookAt(0, 0, 0);
    scene.add(EMLight);

    // Directional Light(s)
    const dirLight1 = new THREE.DirectionalLight(0xffacac, 3);
    dirLight1.position.set(4, 4, 4);
    dirLight1.lookAt(0, 0.5, 0);
    dirLight1.castShadow = true;
    //Set up shadow properties for the light
    dirLight1.shadow.mapSize.width = 4096; // default
    dirLight1.shadow.mapSize.height = 4096; // default
    dirLight1.shadow.camera.near = 0.001; // default
    dirLight1.shadow.camera.far = 100000; // default
    dirLight1.shadow.radius = 15;
    dirLight1.shadow.blurSamples = 25;
    scene.add(dirLight1);
    // const helper = new THREE.DirectionalLightHelper(dirLight1,3);
    // scene.add(helper);

    // const dirLight2 = new THREE.DirectionalLight(0xcdcdcd, .7);
    // dirLight2.position.set(-1, 3, 1);
    // dirLight2.position.set(-1, 3, 1);
    // dirLight2.lookAt(0,0,0);
    // scene.add(dirLight2);

    // Add to context: scene
    context.add({ name: 'scene', obj: scene });

    stage_set_skydome();

    stage_set_props();

    console.groupEnd();
    return scene;
  };

  /**
   * Set dynamic or otherwise procedural props meshes and  items used in scenes.
   * @private
   * @function
   */
  const stage_set_props = () => {
    console.group('stage_set_props');
    // Sea shader (ocean_water) removed for now. The Water prop + its per-frame
    // uniforms.time custom-render callback used to live here; restore from git
    // history / legacy/ when the water returns.
    console.groupEnd();
  };

  /**
   * Custom Render call that can get rules attached to execute as stored callbacks per render iteration.
   *
   */
  const render = () => {
    // if enabled
    if (context.get.config.customRender) {
      for (const renderItem of context.get.config.customRenderStack) {
        if (typeof renderItem === 'function') {
          renderItem();
        }
      }
    }
  };

  /**
   * In the early setup the change sky Handler is still not capable of adapting scene elements, we must use direct calls.
   * @private
   * @function
   */
  const stage_set_skydome = () => {
    console.group('stage_set_skydome');
    const tex_src =
      context.get.config.skydomeTexturesDefaultDir +
      context.get.config.skydome.textures[context.get.config.sceneDefaultStage];

    console.log('stage_set_skydome started', tex_src);
    // EnvMap and skybox
    try {
      // Load an equirect and set
      new THREE.TextureLoader().load(
        tex_src,
        //'/hdri/kloofendal_48d_partly_cloudy_puresky_4k_web.jpg',
        (texture) => {
          texture.name = 'Original skydome';
          console.log(`start ${texture.name}`);
          texture.wrapS = THREE.RepeatWrapping;
          texture.repeat.x = -1;
          texture.mapping = THREE.EquirectangularReflectionMapping;
          texture.colorSpace = THREE.SRGBColorSpace;
          texture.flipY = true;
          context.get.scene.background = texture;
          context.get.scene.environment = texture;

          // Use skybox addon (GroundedSkybox takes height + radius as constructor args)
          const skybox = new GroundedSkybox(texture, 20, 200);
          skybox.name = 'Original Skybox';
          skybox.scale.setScalar(100);
          skybox.visible = false;
          context.get.scene.add(skybox);

          // Add to context: envMap
          context.add({ name: 'envMap', obj: texture });

          // Add to context: skybox
          context.add({ name: 'skybox', obj: skybox });
        }
      );
    } catch (e) {
      console.log(`Environment Map: ${e.message}`);
    }
    console.groupEnd();
  };

  /**
   * The camera is created from standarized defaults, See {@link module:Context}.
   * @alias module:Project.setCamera
   * @returns {THREE.Camera} camera
   */
  const camera_set = () => {
    console.group('camera_set');
    // Import from context: config
    const config = context.get.config;

    // Import from context: coords
    const coords = context.coords;

    // Camera
    const camera = new THREE.PerspectiveCamera(
      config.cameraFov,
      config.cameraRatio,
      config.cameraNearFrustum,
      config.cameraFarFrustum
    );

    // Camera offset
    const camera_pos = new THREE.Vector3().addVectors(coords.cam_offset, coords.target);
    console.log('Camera POS:', camera_pos);

    // Camera Position
    const { x, y, z } = camera_pos;
    camera.position.set(x, y, z);

    // Export camera
    window.THREE_camera = camera;

    // Add to context: camera
    context.add({ name: 'camera', obj: camera });

    console.groupEnd();
    return camera;
  };
  /**
   * The Orbit Control system is used and instantiated from default values, See {@link module:Context}.
   * @alias module:Project.setControls
   * @returns {OrbitControls} controls
   * @async
   */
  const controls_set = async () => {
    console.group('controls_set');
    const controls_promise = new Promise((resolve, reject) => {
      console.log('controls start');
      const controls = new OrbitControls(context.get.camera, context.get.renderer.domElement);
      controls.listenToKeyEvents(window); // Optional.
      controls.enableDamping = true;
      controls.enablePan = false;
      controls.dampingFactor = 0.05;
      controls.screenSpacePanning = false;
      controls.minDistance = context.get.config.controlsMinDistance;
      controls.maxDistance = context.get.config.controlsMaxDistance;
      controls.maxPolarAngle = context.get.config.controlsMaxPolarAngle;
      controls.target = context.coords.target;
      controls.autoRotateSpeed = 3;
      controls.autoRotate = false;

      console.log('controls promise');
      if (controls) {
        resolve(controls);
      } else {
        reject((error) => {
          console.log(error.message);
        });
      }
    });

    controls_promise
      .then((controls) => {
        console.log(['Controls Set', controls]);

        // Add event listeners
        controls.addEventListener('change', (e) => {
          Handlers.onControlsChange(e);
        });
        controls.addEventListener('start', (e) => {
          Handlers.onControlsStart(e);
        });
        controls.addEventListener('end', (e) => {
          Handlers.onControlsEnd(e);
        });

        // Export controls
        window.gltf_controls = controls;

        // Add to context
        context.add({ name: 'controls', obj: controls });
      })
      .catch((e) => console.log(e.message));

    console.groupEnd();
    // Return controls promise
    return controls_promise;
  };

  /**
   * The Rederer is built from default values, See {@link module:Context}.
   * @alias module:Project.setRenderer
   * @returns {THREE.Renderer} renderer
   * @async
   */
  const renderer_set = async () => {
    console.group('renderer_set');
    const renderer_promise = new Promise((resolve, reject) => {
      const renderer = new THREE.WebGLRenderer();
      renderer.setPixelRatio(window.devicePixelRatio);
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.toneMapping = THREE.ACESFilmicToneMapping;
      renderer.toneMappingExposure = 1;
      renderer.shadowMap.enabled = true;
      renderer.shadowMap.type = THREE.PCFShadowMap;

      const r_target = document.querySelector('#canvasroot');
      r_target.appendChild(renderer.domElement);
      console.log(`renderer domElement: ${renderer.domElement}`);

      if (renderer) {
        resolve(renderer);
      } else {
        reject((error) => {
          console.log(error.message);
        });
      }
    });

    renderer_promise
      .then((renderer) => {
        // Export renderer
        window.gltf_renderer = renderer;
        context.add({ name: 'renderer', obj: renderer });

        // On window resize.
        console.log(`Adding onWindowResize to window listeners`);
        window.addEventListener('resize', Handlers.onWindowResize);

        // reference self class
        const self = Project;
        // Recurse into self promise for Controls.
        self.setControls();
      })
      .catch((e) => {
        console.log(e.message);
      });

    console.groupEnd();
    return renderer_promise;
  };

  /**
   * @typedef {Object} ret
   * @property {THREE.gltf} gltf - The loaded instance of a gltf file.
   * @property {THREE.AnimationMixer} mixer - The animation clip mixer.
   * @property {THREE.GLTFLoader} loader - Instance that can load additional gltf files.
   */
  /**
   * The GLTF file holds props and animations for one or more scenes.  We wait for a promise of the gltf to apply the mixer.
   * @alias module:Project.loadGltfScene
   * @returns {ret} Items created in the promise response.
   * @async
   */
  const gltf_scene_load = async () => {
    console.group('gltf_scene_load');
    // Loading gate — shared LoaderOverlay synced from drupal-three-js-theme
    // (src/shared/LoaderOverlay.ts). Do not edit here; fix upstream + re-sync.
    const gate = new LoaderOverlay({
      title: 'VirtuaBooth',
      message: 'loading configurator',
      namespace: 'vb-loader',
      backgroundColor: '#101418',
      color: '#d8dee6',
    });
    const gltf_load_promise = new Promise((resolve, reject) => {
      try {
        // GLTF Loader
        console.log('Loader Starts');
        const loader = new GLTFLoader();
        // // Optional: Provide a DRACOLoader instance to decode compressed mesh data
        // const dracoLoader = new DRACOLoader();
        // dracoLoader.setDecoderPath( '/examples/jsm/libs/draco/' );
        // loader.setDRACOLoader( dracoLoader );

        loader.load(
          '/gltf/animated/animated.gltf',
          (gltf) => {
            // GLTF Scene
            const model = gltf.scene;

            // Name the Model
            model.name = 'Product Scene';

            // Mixer
            const mixer = new THREE.AnimationMixer(model);

            //Add shadow receive/cast to all meshes
            model.traverse((node) => {
              if (node.isMesh) {
                if (node.name === 'Sky') {
                  node.material.side = THREE.BackSide;
                } else {
                  //Culling aware material
                  node.material.side = THREE.FrontSide;
                  node.doubleSided = true;
                }
                // Apply Shadows
                node.castShadow = true;
                node.receiveShadow = true;
              }
            });

            // Make the model content
            context.get.scene.add(model);

            console.log(`Loader gltf ready for ${model.name}`);

            gate.hide();
            resolve({ loader, gltf, mixer });
          },
          // called while loading is progressing
          (xhr) => {
            gate.setProgress(xhr.loaded, xhr.total);
          },
          // called when loading has errors
          (error) => {
            gate.dispose();
            console.log(`There was an Error Loading the GLTF: ${error.message}`, error);
          }
        );
      } catch (e) {
        gate.dispose();
        console.log(e.message);
        reject(e);
      }
    });

    gltf_load_promise
      .then((ret) => {
        // Destruct promise resolve
        const { loader, gltf, mixer } = ret;

        // Add to context: mixer
        context.add({ name: 'mixer', obj: mixer });
        // Add to context: gltf
        context.add({ name: 'gltf', obj: gltf });
        // Add to context: loader
        context.add({ name: 'loader', obj: loader });

        // Add Event Listteners for mixer.
        mixer.addEventListener('finished', (e) => {
          Handlers.onMixerFinished(e);
        });
        mixer.addEventListener('loop', (e) => {
          Handlers.onMixerLoop(e);
        });

        // Init props and stage to default scene
        Handlers.sceneStageAnim('icosphere');
      })
      .catch((e) => console.log(e.message));
    console.groupEnd();
  };

  // Project return
  return {
    setStage: () => stage_set(),
    setCamera: () => camera_set(),
    setRenderer: () => renderer_set(),
    setControls: () => controls_set(),
    loadGltfScene: () => gltf_scene_load(),
    render: () => render(),
  };
})();

export { Project };