Base.js

import * as THREE from 'three';
import { CSS3DRenderer } from 'three/examples/jsm/renderers/CSS3DRenderer';
import { DomElement3D } from './DomElement3D';
import {
  defaultConfigScene,
  initScene,
  checkParentChild,
} from '@ud-viz/utils_browser';

/** @class*/
export class Base {
  /**
   * Basic Frame3D wrap different html element to handle CSS3D rendering {@link CSS3DRenderer}.
   * It's possible to add {@link DomElement3D} to this.
   * Composed with {@link THREE.Scene} + {@link THREE.PerspectiveCamera} + {@link THREE.WebGLRenderer}.
   *
   * @param {object} options - options to configure frame3Dbase
   * @param {string} [options.domElementClass] - dom element class name
   * @param {HTMLElement} [options.parentDomElement=document.body] - dom parent element of domElement frame3DBase
   * @param {boolean} [options.catchEventsCSS3D=false] - event are catch by css3D element (ie {@link DomElement3D})
   * @param {import('@ud-viz/utils_browser').SceneConfig} [options.sceneConfig] - scene config
   * @param {boolean} [init3D=true] - {@link THREE.Scene} + {@link THREE.PerspectiveCamera} + {@link THREE.WebGLRenderer} should be init
   */
  constructor(options = {}, init3D = true) {
    /**  
     * root html
     *
      @type {HTMLDivElement} */
    this.domElement = document.createElement('div');

    /**
     * `this.domElement` has be added to the DOM in order to compute its dimension
     * this is necessary because the itowns.PlanarView need these dimension in order to be initialized correctly
     */
    if (options.parentDomElement instanceof HTMLElement) {
      options.parentDomElement.appendChild(this.domElement);
    } else {
      document.body.appendChild(this.domElement);
    }

    /**
     * root webgl (where canvas is added)
     *
      @type {HTMLDivElement}  */
    this.domElementWebGL = document.createElement('div');

    /**
     * root css (where css3Delement are added)
     *
      @type {HTMLDivElement}  */
    this.domElementCss = document.createElement('div');

    /** 
     * where ui element should be added (note that you have to handle manually z-index element composing ui, should it be automatically ?) 
     *  
     @type {HTMLDivElement}*/
    this.domElementUI = document.createElement('div');

    // add dom layer
    this.domElement.appendChild(this.domElementUI);
    this.domElement.appendChild(this.domElementCss);
    this.domElement.appendChild(this.domElementWebGL);

    if (typeof options.domElementClass == 'string') {
      this.domElementWebGL.classList.add(options.domElementClass);
      this.domElementCss.classList.add(options.domElementClass);
      this.domElementUI.classList.add(options.domElementClass);
    }

    /**
     * reference resize listener to remove it on dispose
     *
      @type {Function} */
    this.resizeListener = this.onResize.bind(this);
    window.addEventListener('resize', this.resizeListener);

    /**
     * flag to stop rendering 3D
     *
      @type {boolean} */
    this.isRendering = true;

    /**
     * canvas scene 3D
     *
      @type {THREE.Scene} */
    this.scene = null;

    /**
     * canvas renderer
     *
      @type {THREE.WebGLRenderer} */
    this.renderer = null;

    /**
     * camera 3D
     *
      @type {THREE.PerspectiveCamera} */
    this.camera = null;

    /**
     * css renderer
     *
      @type {CSS3DRenderer} */
    this.css3DRenderer = null;

    /**
     * css scene
     *
      @type {THREE.Scene} */
    this.css3DScene = null;

    /**
     * current domElements 3D in frame3D
     *
      @type {DomElement3D[]} */
    this.domElement3DArray = [];

    // Default catch events
    const catchEventsCSS3D = options.catchEventsCSS3D || false;
    this.catchEventsCSS3D(catchEventsCSS3D);

    /**
     * listeners of {@link Base.EVENT}
     *
      @type {Object<string,Function[]>} */
    this.listeners = {};
    for (const key in Base.EVENT) {
      this.listeners[Base.EVENT[key]] = [];
    }

    /** @type {import('../THREEUtil').SceneConfig} */
    this.sceneConfig = options.sceneConfig || defaultConfigScene;

    /** @type {THREE.DirectionalLight|null} */
    this.directionalLight = null;

    if (init3D) {
      // Initialize 3D

      THREE.Object3D.DEFAULT_UP.set(0, 0, 1);

      this.scene = new THREE.Scene();
      const canvas = document.createElement('canvas');
      if (options.domElementClass)
        canvas.classList.add(options.domElementClass);
      this.domElementWebGL.appendChild(canvas);
      this.renderer = new THREE.WebGLRenderer({
        canvas: canvas,
        antialias: true,
        logarithmicDepthBuffer: true,
        alpha: true,
      });
      this.camera = new THREE.PerspectiveCamera(60, 1, 1, 1000); // Default params
      this.scene.add(this.camera);

      // init with sceneconfig
      this.directionalLight = initScene(
        this.camera,
        this.renderer,
        this.scene,
        this.sceneConfig
      );
    }
  }

  /**
   * Register a listener on a {@link Base.EVENT}
   *
   * @param {string} eventID - event to add listener {@link Base.EVENT}
   * @param {Function} listener - callback to call on eventID
   */
  on(eventID, listener) {
    if (!this.listeners[eventID])
      throw new Error('this event is not a Base.EVENT');
    this.listeners[eventID].push(listener);
  }

  /**
   * Init the css3D renderer
   */
  initCSS3D() {
    // CSS3DRenderer
    this.css3DRenderer = new CSS3DRenderer();

    // Add html el
    this.domElementCss.appendChild(this.css3DRenderer.domElement);

    // Create a new scene for the css3D renderer
    this.css3DScene = new THREE.Scene();

    // Listen to switch mode between css3D and webgl controls
    const raycaster = new THREE.Raycaster();

    // check if enter css3D event
    this.domElementWebGL.onmousedown = (event) => {
      if (this.isCatchingEventsCSS3D()) return;
      if (checkParentChild(event.target, this.domElementUI)) return; // Do not propagate if it's the ui that has been clicked

      const el = this.domElementWebGL;

      const mouse = new THREE.Vector2(
        -1 + (2 * event.offsetX) / (el.clientWidth - parseInt(el.offsetLeft)),
        1 - (2 * event.offsetY) / (el.clientHeight - parseInt(el.offsetTop))
      );

      raycaster.setFromCamera(mouse, this.camera);

      for (let index = 0; index < this.domElement3DArray.length; index++) {
        const element = this.domElement3DArray[index];

        const i = raycaster.intersectObject(element.maskObject);
        if (i.length) {
          this.catchEventsCSS3D(true);
          element.select(true);
          return;
        }
      }
    };

    // check if enter canvas webgl event
    this.domElementCss.onmousedown = (event) => {
      if (!this.isCatchingEventsCSS3D()) return;

      let onDomElement3D = false;

      // compatible chrome & firefox
      const path = event.path || (event.composedPath && event.composedPath());

      if (path.length) {
        const firstHoverEl = path[0];

        for (let index = 0; index < this.domElement3DArray.length; index++) {
          const element = this.domElement3DArray[index];
          if (element.domElement == firstHoverEl) {
            onDomElement3D = true;
            break;
          }
        }
      }
      if (!onDomElement3D) {
        this.catchEventsCSS3D(false);
        this.domElement3DArray.forEach(function (b) {
          b.select(false);
        });
      }
    };

    // need an async call to resize
    setTimeout(this.resizeListener, 10);
  }

  /**
   * Render css3D
   *
   * @returns {void}
   */
  renderCSS3D() {
    if (!this.isRendering || !this.css3DRenderer) return;
    this.css3DRenderer.render(this.css3DScene, this.camera);
  }

  /**
   * Customize how to render the frame3D
   *
   * @param {Function} f - custom rendering function
   */
  setRender(f) {
    this.render = () => {
      if (!this.isRendering) return; // encapsulate to stop with isRendering flag
      f();
    };
  }

  /**
   * Render scene3D
   *
   * @returns {void}
   */
  render() {
    // Default Render
    if (!this.isRendering) return;
    this.renderer.clear();
    this.renderer.render(this.scene, this.camera);

    this.renderCSS3D();
  }

  /**
   *
   * @returns {boolean} - false if root webgl is catching events, true if it's root css
   */
  isCatchingEventsCSS3D() {
    return this.domElementWebGL.style.pointerEvents === 'none';
  }

  /**
   *
   * @param {boolean} value - if true allow css3D html elements to catch user events, otherwise no
   */
  catchEventsCSS3D(value) {
    if (value) {
      this.domElementWebGL.style.pointerEvents = 'none';
    } else {
      this.domElementWebGL.style.pointerEvents = '';
    }
  }

  /**
   *
   * @param {DomElement3D} domElement3D - domElement3D to add in frame3D
   * @param {THREE.Object3D} parent - parent of the maskElement
   */
  appendDomElement3D(domElement3D, parent = this.scene) {
    if (!this.css3DRenderer) this.initCSS3D();
    parent.add(domElement3D);
    this.css3DScene.add(domElement3D.css3DObject);
    this.domElement3DArray.push(domElement3D);
  }

  /**
   *
   * @param {DomElement3D} domElement3D - domElement3D to remove
   */
  removeDomElement3D(domElement3D) {
    domElement3D.parent.remove(domElement3D);
    this.css3DScene.remove(domElement3D.css3DObject);
    const index = this.domElement3DArray.indexOf(domElement3D);
    this.domElement3DArray.splice(index, 1);
  }

  /**
   * Resize frame3D
   *
   * @param {boolean} [updateTHREEVariables=true] - camera and renderer should be updated
   */
  onResize(updateTHREEVariables = true) {
    if (this.css3DRenderer)
      this.css3DRenderer.setSize(
        this.domElementCss.clientWidth,
        this.domElementCss.clientHeight
      );

    if (updateTHREEVariables) {
      this.camera.aspect =
        this.domElementWebGL.clientWidth / this.domElementWebGL.clientHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(
        this.domElementWebGL.clientWidth,
        this.domElementWebGL.clientHeight
      );
    }

    this.listeners[Base.EVENT.RESIZE].forEach((listener) => {
      listener();
    });
  }

  /**
   * Remove html from the DOM and stop listeners
   */
  dispose() {
    window.removeEventListener('resize', this.resizeListener);
    this.domElement.remove();

    this.listeners[Base.EVENT.DISPOSE].forEach((listener) => {
      listener();
    });
  }
}

/**
 * Events triggered by {@link Base}
 */
Base.EVENT = {
  DISPOSE: 'dispose',
  RESIZE: 'resize',
};