index.js

import {
  AudioComponent,
  ColliderComponent,
  Context,
  ExternalScriptComponent,
  Object3D as GameObject3D,
  GameScriptComponent,
  RenderComponent,
} from '@ud-viz/game_shared';
import { AssetManager, RenderController } from '@ud-viz/game_browser';
import {
  Object3D,
  Box3,
  Vector3,
  Quaternion,
  Mesh,
  BoxGeometry,
  MeshBasicMaterial,
  CircleGeometry,
  Color,
  Raycaster,
  Vector2,
  SphereGeometry,
  Scene,
  OrthographicCamera,
  WebGLRenderer,
  AmbientLight,
  MathUtils,
  DoubleSide,
} from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TransformControls } from 'three/examples/jsm/controls/TransformControls';
import { ConvexGeometry } from 'three/examples/jsm/geometries/ConvexGeometry';

import './style.css';
import {
  cameraFitRectangle,
  createLabelInput,
  createLocalStorageSlider,
  RequestAnimationFrameProcess,
  Vector3Input,
} from '@ud-viz/utils_browser';
import {
  arrayPushOnce,
  objectParseNumeric,
  removeFromArray,
  throttle,
} from '@ud-viz/utils_shared';

const COLLIDER_MATERIAL = new MeshBasicMaterial({
  color: 'green',
  side: DoubleSide,
});
const COLLIDER_MATERIAL_SELECTED = new MeshBasicMaterial({
  color: 'red',
  side: DoubleSide,
});
const COLLIDER_POINT_MATERIAL = new MeshBasicMaterial({ color: 'yellow' });

import { ObjectInput } from './objectInput/ObjectInput';
export { ObjectInput };

import * as nativeGameScriptVariablesInput from './objectInput/scriptVariables/game/game';
import * as nativeExternalScriptVariablesInputs from './objectInput/scriptVariables/external/external';
import * as nativeUserDataInputs from './objectInput/userData/userData';
export { nativeUserDataInputs };
export { nativeExternalScriptVariablesInputs };
export { nativeGameScriptVariablesInput };
export * from './DebugCollision';

/** @class Provides functionality for editing and manipulating 3D objects in a web-based environment.*/
export class Editor {
  /**
   *
   * @param {import("@ud-viz/frame3d").Planar|import("@ud-viz/frame3d").Base} frame3D - frame 3d
   * @param {AssetManager} assetManager - asset manager
   * @param {object} options - options
   * @param {Array} options.externalScriptVariablesInputs - input to edit ExternalScriptComponent variables
   * @param {Array} options.gameScriptVariablesInputs - input to edit GameScriptComponent variables
   * @param {Array} options.userDataInputs - input to edit .userData
   * @param {Array} options.object3DModels - models of object3D
   * @param {Array} options.possibleExternalScriptIds - ids that can be added to a gameobject3d ExternalScriptComponent
   * @param {Array} options.possibleGameScriptIds - ids that can be added to a gameobject3d GameScriptComponent
   * @param {object} options.userData - user data
   */
  constructor(frame3D, assetManager, options = {}) {
    /** @type {import("@ud-viz/frame3d").Planar|import("@ud-viz/frame3d").Base} */
    this.frame3D = frame3D;

    /** @type {AssetManager} */
    this.assetManager = assetManager;

    /** @type {object} */
    this.userData = options.userData || {};

    // ui init
    {
      /** @type {HTMLElement} */
      this.leftPan = document.createElement('div');
      this.leftPan.setAttribute('id', 'left_pan');
      this.frame3D.domElementUI.appendChild(this.leftPan);

      const leftPanWidthInput = createLocalStorageSlider(
        'editor_left_width_range_key',
        'Taille UI ',
        this.leftPan,
        {
          min: 100,
          max: 700,
          defaultValue: 300,
        }
      );
      const updateLeftWidth = () => {
        this.leftPan.style.width = leftPanWidthInput.value + 'px';
      };
      leftPanWidthInput.onchange = updateLeftWidth;
      updateLeftWidth();

      /** @type {HTMLElement} */
      this.currentGODomelement = document.createElement('div');
      this.currentGODomelement.setAttribute('id', 'current_game_object_3d');
      this.leftPan.appendChild(this.currentGODomelement);

      /** @type {HTMLElement} */
      this.toolsDomElement = document.createElement('div');
      this.toolsDomElement.setAttribute('id', 'editor_tools');
      this.leftPan.appendChild(this.toolsDomElement);
    }

    // add object model
    {
      const selectObject3DModel = document.createElement('select');
      this.toolsDomElement.appendChild(selectObject3DModel);

      const buffer = new Map();

      // add a default one
      const defaultOption = document.createElement('option');
      defaultOption.innerText = 'Empty';
      const uuid = MathUtils.generateUUID();
      defaultOption.value = uuid;
      buffer.set(uuid, { name: 'GameObject3D' });
      selectObject3DModel.appendChild(defaultOption);

      // fill with ones pass at construction
      if (options.object3DModels) {
        options.object3DModels.forEach((model) => {
          const option = document.createElement('option');
          option.innerText = model.name;
          const uuid = MathUtils.generateUUID();
          option.value = uuid;
          buffer.set(uuid, model);
          selectObject3DModel.appendChild(option);
        });
      }

      const addObject3DModelToSelectedGameObject3D =
        document.createElement('button');
      addObject3DModelToSelectedGameObject3D.innerText = 'Add gameobject3D';
      this.toolsDomElement.appendChild(addObject3DModelToSelectedGameObject3D);

      addObject3DModelToSelectedGameObject3D.onclick = () => {
        const objectToAdd = new GameObject3D(
          JSON.parse(
            JSON.stringify(
              buffer.get(selectObject3DModel.selectedOptions[0].value)
            )
          )
        );

        // reset everything TODO: optimize
        this.gameObjectInput.gameObject3D.add(objectToAdd);
        this.setCurrentGameObject3DJSON(this.currentGameObject3D.toJSON());
        this.selectGameObject3D(
          this.currentGameObject3D.getFirst((o) => o.uuid == objectToAdd.uuid)
        );
      };
    }

    // remove gameobject
    {
      const deleteCurrentGameObject3D = document.createElement('button');
      deleteCurrentGameObject3D.innerText = 'Remove gameobject3D selected';
      this.toolsDomElement.appendChild(deleteCurrentGameObject3D);

      deleteCurrentGameObject3D.onclick = () => {
        if (!this.gameObjectInput.gameObject3D.parent.isGameObject3D) {
          alert('you cant delete root of your game');
          return;
        }
        // reset everything TODO: optimize
        this.gameObjectInput.gameObject3D.removeFromParent();
        this.setCurrentGameObject3DJSON(this.currentGameObject3D.toJSON());
      };
    }

    const possibleIdRenderData = [null]; // idRenderData can be null
    for (const id in assetManager.renderData) possibleIdRenderData.push(id);
    const possibleIdSounds = [];
    for (const id in assetManager.sounds) possibleIdSounds.push(id);

    /** @type {Array<import("./objectInput/ObjectInput")>} */
    const externalScriptVariablesInputs =
      options.externalScriptVariablesInputs || [];
    for (const className in nativeExternalScriptVariablesInputs)
      externalScriptVariablesInputs.push(
        nativeExternalScriptVariablesInputs[className]
      );

    /** @type {Array<import("./objectInput/ObjectInput")>} */
    const gameScriptVariablesInputs = options.gameScriptVariablesInputs || [];
    for (const className in nativeGameScriptVariablesInput)
      gameScriptVariablesInputs.push(nativeGameScriptVariablesInput[className]);

    /** @type {Array<import("./objectInput/ObjectInput")>} */
    const userDataInputs = options.userDataInputs || [];
    for (const className in nativeUserDataInputs)
      userDataInputs.push(nativeUserDataInputs[className]);

    /** @type {GameObject3DInput} */
    this.gameObjectInput = new GameObject3DInput(
      possibleIdRenderData,
      possibleIdSounds,
      options.possibleGameScriptIds,
      options.possibleExternalScriptIds,
      gameScriptVariablesInputs,
      externalScriptVariablesInputs,
      userDataInputs
    );
    this.gameObjectInput.setAttribute('id', 'select_game_object_3d');
    this.leftPan.appendChild(this.gameObjectInput);

    // update when input transform changed
    this.gameObjectInput.addEventListener(
      GameObject3DInput.EVENT.TRANSFORM_CHANGED,
      () => {
        this.updateCollider();
        this.updateBox3();
      }
    );

    // update when id render data changed
    this.gameObjectInput.addEventListener(
      GameObject3DInput.EVENT.ID_RENDER_DATA_CHANGED,
      () => {
        this.updateBox3();
      }
    );

    // update when component is add/remove
    {
      this.gameObjectInput.addEventListener(
        GameObject3DInput.EVENT.COMPONENT_ADD,
        (event) => {
          let newComponent = null;
          switch (event.detail.type) {
            case RenderComponent.TYPE:
              newComponent = new RenderComponent();
              this.gameObjectInput.gameObject3D.components[
                RenderComponent.TYPE
              ] = newComponent;
              newComponent.initController(
                new RenderController(
                  newComponent.model,
                  this.gameObjectInput.gameObject3D,
                  this.assetManager
                )
              );
              break;
            case AudioComponent.TYPE:
              this.gameObjectInput.gameObject3D.components[
                AudioComponent.TYPE
              ] = new AudioComponent();
              break;
            case GameScriptComponent.TYPE:
              this.gameObjectInput.gameObject3D.components[
                GameScriptComponent.TYPE
              ] = new GameScriptComponent();
              break;
            case ExternalScriptComponent.TYPE:
              this.gameObjectInput.gameObject3D.components[
                ExternalScriptComponent.TYPE
              ] = new ExternalScriptComponent();
              break;
            case ColliderComponent.TYPE:
              this.gameObjectInput.gameObject3D.components[
                ColliderComponent.TYPE
              ] = new ColliderComponent();
              break;
            default:
              throw new Error('Unknown component type');
          }

          this.selectGameObject3D(this.gameObjectInput.gameObject3D, true); // refresh
        }
      );
      this.gameObjectInput.addEventListener(
        GameObject3DInput.EVENT.COMPONENT_REMOVE,
        (event) => {
          if (event.detail.type == RenderComponent.TYPE) {
            const renderComponent =
              this.gameObjectInput.gameObject3D.getComponent(
                RenderComponent.TYPE
              );
            renderComponent.controller.dispose(); // the only one with a controller
          } else if (
            (this.currentObjectInput &&
              event.detail.type == GameScriptComponent.TYPE &&
              this.currentObjectInput.type == ObjectInput.TYPE.GAME_SCRIPT) ||
            (this.currentObjectInput &&
              event.detail.type == ExternalScriptComponent.TYPE &&
              this.currentObjectInput.type == ObjectInput.TYPE.EXTERNAL_SCRIPT)
          ) {
            // was editing the concerned component
            this.currentObjectInput.dispose();
          }

          delete this.gameObjectInput.gameObject3D.components[
            event.detail.type
          ];
          this.selectGameObject3D(this.gameObjectInput.gameObject3D, true); // refresh
        }
      );
    }

    /** @type {OrbitControls} */
    this.orbitControls = new OrbitControls(
      this.frame3D.camera,
      this.frame3D.domElementWebGL
    );

    /** @type {TransformControls} */
    this.transformControls = new TransformControls(
      this.frame3D.camera,
      this.frame3D.domElementWebGL
    );
    // transform controls
    {
      this.frame3D.scene.add(this.transformControls);

      this.transformControls.addEventListener('dragging-changed', (event) => {
        this.orbitControls.enabled = !event.value;
      });

      this.transformControls.addEventListener('change', () => {
        this.gameObjectInput.updateTransform();
      });
      this.transformControls.addEventListener('mouseUp', () => {
        if (!this.shapeContext.pointMesh) {
          // editing go transform
          this.updateCollider();
          this.updateBox3();
        } else {
          if (
            this.shapeContext.pointMesh.userData.shapeJSON.type ==
            ColliderComponent.SHAPE_TYPE.POLYGON
          ) {
            // editing point shape in model
            this.shapeContext.pointMesh.userData.shapeJSON.points[
              this.shapeContext.pointMesh.userData.index
            ] = {
              x: this.shapeContext.pointMesh.position.x,
              y: this.shapeContext.pointMesh.position.y,
              z: this.shapeContext.pointMesh.position.z,
            };
            const indexPointSelected =
              this.shapeContext.pointMesh.userData.index;
            this.updateShapeSelected();
            this.selectPointMesh(
              this.pointsParent.children[indexPointSelected]
            );
          } else {
            // circle center edited
            this.shapeContext.pointMesh.userData.shapeJSON.center = {
              x: this.shapeContext.pointMesh.position.x,
              y: this.shapeContext.pointMesh.position.y,
              z: this.shapeContext.pointMesh.position.z,
            };
            this.updateShapeSelected();
          }
        }
      });
    }

    // gizmo mode ui
    {
      const addButtonMode = (mode) => {
        const buttonMode = document.createElement('button');
        buttonMode.innerText = mode;
        this.toolsDomElement.appendChild(buttonMode);

        buttonMode.onclick = () => {
          this.transformControls.setMode(mode);
        };
      };
      addButtonMode('translate');
      addButtonMode('rotate');
      addButtonMode('scale');
    }

    /** @type {HTMLElement} */
    this.buttonTargetGameObject3D = document.createElement('button');
    this.buttonTargetGameObject3D.innerText = 'Target';
    this.toolsDomElement.appendChild(this.buttonTargetGameObject3D);

    /** @type {GameObject3D|null} */
    this.currentGameObject3D = null;

    /** @type {Box3} */
    this.currentGameObjectMeshBox3 = new Mesh(
      new BoxGeometry(),
      new MeshBasicMaterial({ color: 'black', wireframe: true })
    );
    {
      this.currentGameObjectMeshBox3.name = 'currentGameObjectMeshBox3';
      this.frame3D.scene.add(this.currentGameObjectMeshBox3);
      this.gameObjectInput.addEventListener(
        GameObject3DInput.EVENT.TRANSFORM_CHANGED,
        () => {
          this.updateBox3();
          this.updateCollider();
        }
      );
    }

    // camera move
    {
      const selectCameraPOV = document.createElement('select');
      this.toolsDomElement.appendChild(selectCameraPOV);

      const buffer = new Map();
      const addOption = (label, callback) => {
        const option = document.createElement('option');
        option.innerText = label;
        option.value = label;
        buffer.set(label, callback);
        selectCameraPOV.appendChild(option);
      };

      selectCameraPOV.oninput = () => {
        const radius = this.frame3D.camera.position.distanceTo(
          this.orbitControls.target
        );
        this.frame3D.camera.position.copy(this.orbitControls.target);
        buffer.get(selectCameraPOV.selectedOptions[0].value)(radius);
        this.frame3D.camera.updateMatrixWorld();
        this.orbitControls.update();
      };

      addOption('+X', (radius) => {
        this.frame3D.camera.position.x += radius;
      });
      addOption('-X', (radius) => {
        this.frame3D.camera.position.x -= radius;
      });
      addOption('+Y', (radius) => {
        this.frame3D.camera.position.y += radius;
      });
      addOption('-Y', (radius) => {
        this.frame3D.camera.position.y -= radius;
      });
      addOption('+Z', (radius) => {
        this.frame3D.camera.position.z += radius;
      });
      addOption('-Z', (radius) => {
        this.frame3D.camera.position.z -= radius;
      });
    }

    // collider object3d
    /** @type {Object3D} */
    this.colliderParent = new Object3D();
    this.colliderParent.name = 'colliderParent';
    this.frame3D.scene.add(this.colliderParent);

    this.gameObjectInput.addEventListener(
      GameObject3DInput.EVENT.SHAPE_ADDED,
      (event) => {
        this.updateCollider();
        this.selectShape(event.detail.shapeIndexCreated);
      }
    );

    /** @type {object} */
    this.shapeContext = {
      mesh: null,
      deleteButton: null,
      radiusUI: null,
      pointMesh: null,
    };
    this.pointsParent = new Object3D();
    {
      this.frame3D.scene.add(this.pointsParent);

      const raycaster = new Raycaster();
      window.addEventListener('keydown', (event) => {
        if (event.key == 'Escape') this.selectShape(-1);
        else if (event.key == 'Delete' && this.shapeContext.pointMesh) {
          // can only remove point of polygon
          if (
            this.shapeContext.pointMesh.userData.shapeJSON.type !=
            ColliderComponent.SHAPE_TYPE.POLYGON
          )
            return;

          if (
            this.shapeContext.pointMesh.userData.shapeJSON.points.length < 5
          ) {
            alert('a polygon must have at least 4 points');
          } else {
            // delete this point
            this.shapeContext.pointMesh.userData.shapeJSON.points.splice(
              this.shapeContext.pointMesh.userData.index,
              1
            );
            this.updateShapeSelected();
          }
        }
      });
      this.frame3D.domElementWebGL.addEventListener('click', (event) => {
        const mouse = new Vector2(
          (event.clientX / this.frame3D.domElementWebGL.clientWidth) * 2 - 1,
          -(event.clientY / this.frame3D.domElementWebGL.clientHeight) * 2 + 1
        );
        raycaster.setFromCamera(mouse, this.frame3D.camera);

        if (!this.shapeContext.mesh) {
          // look for intersect with shape mesh
          const intersects = raycaster.intersectObject(
            this.colliderParent,
            true
          );
          if (intersects.length) {
            const index = this.colliderParent.children.indexOf(
              intersects[0].object
            );
            this.selectShape(index);
          }
        } else {
          if (event.ctrlKey) {
            // add a point
            const intersects = raycaster.intersectObject(
              this.gameObjectInput.gameObject3D,
              true
            );
            if (intersects.length) {
              const point = intersects[0].point;
              const index = this.colliderParent.children.indexOf(
                this.shapeContext.mesh
              );
              const colliderComp =
                this.gameObjectInput.gameObject3D.getComponent(
                  ColliderComponent.TYPE
                );
              // in gameobject referential
              const invMatrixWorld = this.pointsParent.matrixWorld
                .clone()
                .invert();
              point.applyMatrix4(invMatrixWorld);

              colliderComp.model.shapesJSON[index].points.push({
                x: point.x,
                y: point.y,
                z: point.z,
              });
              this.updateShapeSelected();
            }
          } else {
            // look for intersect with points shape
            const intersects = raycaster.intersectObject(
              this.pointsParent,
              true
            );
            if (intersects.length) {
              this.selectPointMesh(intersects[0].object);
            }
          }
        }
      });
    }

    // object input
    {
      /** @type {import("./objectInput/ObjectInput")|null} */
      this.currentObjectInput = null;

      // object input creation
      this.gameObjectInput.addEventListener(
        GameObject3DInput.EVENT.OBJECT_INPUT_CREATION,
        (event) => {
          if (this.currentObjectInput) this.currentObjectInput.dispose(); // only one script input at once

          let domElement, object;
          switch (event.detail.typeObjectInput) {
            case ObjectInput.TYPE.USER_DATA:
              domElement = this.gameObjectInput.userDataInputDomElement;
              object = this.gameObjectInput.gameObject3D.userData;
              break;
            case ObjectInput.TYPE.GAME_SCRIPT:
              domElement = this.gameObjectInput.gameScriptInputDomElement;
              object = this.gameObjectInput.gameObject3D.getComponent(
                GameScriptComponent.TYPE
              ).model.variables;
              break;
            case ObjectInput.TYPE.EXTERNAL_SCRIPT:
              domElement = this.gameObjectInput.externalScriptInputDomElement;
              object = this.gameObjectInput.gameObject3D.getComponent(
                ExternalScriptComponent.TYPE
              ).model.variables;
              break;
            default:
              throw new Error('Unknown object input type');
          }

          this.currentObjectInput = new event.detail.ClassObjectInput(
            event.detail.typeObjectInput,
            this,
            object,
            domElement
          );
          this.currentObjectInput.init();
        }
      );

      this.gameObjectInput.addEventListener(
        GameObject3DInput.EVENT.SCRIPT_DELETED,
        (event) => {
          if (
            this.currentObjectInput && // one current object input
            this.currentObjectInput.condition(event.detail.id) // its a script variables object input + one that's the one editing the deleted script
          )
            this.currentObjectInput.dispose(); // dispose it
        }
      );
    }

    /** @type {RequestAnimationFrameProcess} */
    this.process = new RequestAnimationFrameProcess(30);

    // scale point mesh to have constant size on screen
    this.scaleShapePoints = () => {
      this.pointsParent.traverse((point) => {
        if (point.geometry) {
          const scale =
            this.frame3D.camera.position.distanceTo(
              point.position.clone().add(this.pointsParent.position)
            ) / 80;
          point.scale.set(scale, scale, scale);
        }
      });
    };

    const scaleShapePointsThrottle = throttle(
      this.scaleShapePoints.bind(this),
      100
    );

    this.process.start((dt) => {
      scaleShapePointsThrottle();
      this.transformControls.updateMatrixWorld();
      this.frame3D.render();
      if (this.currentObjectInput) this.currentObjectInput.tick(dt);
    });

    // move game object 3d in hierarchy
    {
      // select game object 3d move
      this.selectParentGameObject3DMove = document.createElement('select');

      // move button
      const moveButton = document.createElement('button');
      moveButton.innerText = 'Move selected gameobject3D to';
      this.toolsDomElement.appendChild(moveButton);
      this.toolsDomElement.appendChild(this.selectParentGameObject3DMove);

      moveButton.onclick = () => {
        const selectedGameObject3DUUID = this.gameObjectInput.gameObject3D.uuid;
        this.gameObjectInput.gameObject3D.removeFromParent();
        const parent = this.currentGameObject3D.getFirst(
          (o) =>
            o.uuid == this.selectParentGameObject3DMove.selectedOptions[0].value
        );
        parent.add(this.gameObjectInput.gameObject3D);
        this.setCurrentGameObject3DJSON(this.currentGameObject3D.toJSON());
        this.selectGameObject3D(
          this.currentGameObject3D.getFirst(
            (o) => o.uuid == selectedGameObject3DUUID
          )
        );
      };
    }

    // shortcut
    {
      window.addEventListener('keydown', (event) => {
        if (event.key == 'f')
          this.setOrbitControlsTargetTo(this.gameObjectInput.gameObject3D);
      });
    }
  }

  /**
   *
   * @param {object} gameObject3DJSON - json of the gameobject to set
   */
  setCurrentGameObject3DJSON(gameObject3DJSON) {
    const gameObject3D = new GameObject3D(objectParseNumeric(gameObject3DJSON));

    console.log('editor open ', gameObject3D);
    if (this.currentGameObject3D) {
      this.currentGameObject3D.removeFromParent();
    }

    this.currentGameObject3D = gameObject3D;

    // init render gameobject3d
    this.currentGameObject3D.traverse((child) => {
      if (!child.isGameObject3D) return;
      child.matrixAutoUpdate = true; // disable .static optimization

      const renderComp = child.getComponent(RenderComponent.TYPE);
      if (renderComp) {
        renderComp.initController(
          new RenderController(renderComp.getModel(), child, this.assetManager)
        );
      }
    });

    this.frame3D.scene.add(this.currentGameObject3D);

    const createGameObject3DUI = (go, indent = 0) => {
      if (!go.isGameObject3D) return null;

      let result = null;

      const hasChildrenGameObject =
        go.children.filter((el) => el.isGameObject3D).length != 0;
      const _this = this;

      if (hasChildrenGameObject) {
        result = document.createElement('details');
        const summary = document.createElement('summary');
        summary.classList.add('editor_clickable');
        summary.innerText = go.name;
        this.gameObjectInput.addEventListener(
          GameObject3DInput.EVENT.NAME_CHANGED,
          () => {
            summary.innerText = go.name;
          }
        );

        result.style.marginLeft = 10 * indent + 'px';
        result.appendChild(summary);

        summary.onclick = function (event) {
          if (this == event.target) {
            _this.selectGameObject3D(go);
          }
        };

        indent++;
        go.children.forEach((child) => {
          const childResult = createGameObject3DUI(child, indent);
          if (!childResult) return;
          result.appendChild(childResult);
        });
      } else {
        result = document.createElement('div');
        result.classList.add('editor_clickable');
        result.innerText = go.name;
        this.gameObjectInput.addEventListener(
          GameObject3DInput.EVENT.NAME_CHANGED,
          () => {
            result.innerText = go.name;
          }
        );

        result.style.marginLeft = 20 * indent + 'px';
        result.onclick = function (event) {
          if (this == event.target) {
            _this.selectGameObject3D(go);
          }
        };
      }

      return result;
    };

    while (this.currentGODomelement.firstChild) {
      this.currentGODomelement.firstChild.remove();
    }
    this.currentGODomelement.appendChild(
      createGameObject3DUI(this.currentGameObject3D)
    );

    this.selectGameObject3D(this.currentGameObject3D);
  }

  /**
   * Move camera to focus current game object 3d
   */
  focusCurrentGameObject3D() {
    // move camera to fit the scene
    const bb = Editor.computeBox3GameObject3D(this.currentGameObject3D);
    const center = new Vector3();
    bb.getCenter(center);
    cameraFitRectangle(this.frame3D.camera, bb.min, bb.max, bb.max.z);
    this.setOrbitControlsTargetTo(this.currentGameObject3D);
  }

  /**
   *
   * @param {Object3D} obj - object 3d to target
   */
  setOrbitControlsTargetTo(obj) {
    const bb = Editor.computeBox3GameObject3D(obj);
    const center = new Vector3();
    bb.getCenter(center);
    this.orbitControls.target.copy(center);
    this.orbitControls.update();
  }

  /**
   *
   * @param {GameObject3D} go - game object 3d to select for edition
   * @param {boolean} force - force even if the selected gameobject3d is the same
   */
  selectGameObject3D(go, force = false) {
    if (go == this.gameObjectInput.gameObject3D && !force) return;

    if (this.currentObjectInput) this.currentObjectInput.dispose();

    // game input dom element
    this.gameObjectInput.setGameObject3D(go);
    // bind
    this.buttonTargetGameObject3D.onclick = this.setOrbitControlsTargetTo.bind(
      this,
      go
    );
    this.transformControls.attach(go);
    this.updateBox3();
    this.updateCollider();
    this.updateSelectGameObjectMoveParent();
  }

  updateSelectGameObjectMoveParent() {
    while (this.selectParentGameObject3DMove.firstChild)
      this.selectParentGameObject3DMove.firstChild.remove();

    // root gameobject3D cant me move
    if (!this.gameObjectInput.gameObject3D.parent.isGameObject3D) return;

    this.currentGameObject3D.traverse((child) => {
      if (
        !child.isGameObject3D ||
        child == this.gameObjectInput.gameObject3D ||
        child == this.gameObjectInput.gameObject3D.parent
      )
        return; // cant move to itself or to the current one

      const option = document.createElement('option');
      option.innerText = child.name;
      option.value = child.uuid;
      this.selectParentGameObject3DMove.appendChild(option);
    });
  }

  /**
   * Update collider shapes in the 3D scene
   */
  updateCollider() {
    for (let i = this.colliderParent.children.length - 1; i >= 0; i--) {
      this.colliderParent.children[i].removeFromParent();
    }
    this.selectShape(-1);

    /** @type {GameObject3D} */
    const go = this.gameObjectInput.gameObject3D;

    const colliderComp = go.getComponent(ColliderComponent.TYPE);
    if (colliderComp) {
      const worldPosition = new Vector3();
      const worldQuaternion = new Quaternion();
      const worldScale = new Vector3();

      go.matrixWorld.decompose(worldPosition, worldQuaternion, worldScale);

      this.colliderParent.position.copy(worldPosition);
      this.colliderParent.quaternion.copy(worldQuaternion);
      this.colliderParent.scale.copy(worldScale);

      colliderComp.model.shapesJSON.forEach((shape) => {
        let geometry = null;

        switch (shape.type) {
          case ColliderComponent.SHAPE_TYPE.CIRCLE:
            geometry = new CircleGeometry(shape.radius, 32);
            geometry.translate(shape.center.x, shape.center.y, shape.center.z);
            break;
          case ColliderComponent.SHAPE_TYPE.POLYGON:
            geometry = new ConvexGeometry(
              shape.points.map((el) => new Vector3(el.x, el.y, el.z))
            );
            break;
          default:
            throw new Error('unknown shape type');
        }
        this.colliderParent.add(new Mesh(geometry, COLLIDER_MATERIAL));
      });
    }
  }

  /**
   *
   * @param {number} shapeIndex - index of the shape to select
   */
  selectShape(shapeIndex) {
    // reset state
    if (this.shapeContext.mesh) {
      this.shapeContext.mesh.material = COLLIDER_MATERIAL;
      this.shapeContext.mesh = null;
    }
    if (this.shapeContext.deleteButton) {
      this.shapeContext.deleteButton.remove();
      this.shapeContext.deleteButton = null;
    }
    if (this.shapeContext.radiusUI) {
      this.shapeContext.radiusUI.parent.remove();
      this.shapeContext.radiusUI = null;
    }
    this.shapeContext.pointMesh = null;

    for (let i = this.pointsParent.children.length - 1; i >= 0; i--) {
      this.pointsParent.children[i].removeFromParent();
    }

    // assign new index
    this.shapeContext.mesh = this.colliderParent.children[shapeIndex];

    // set new stateContext state
    if (this.shapeContext.mesh) {
      this.setOrbitControlsTargetTo(this.shapeContext.mesh);
      this.shapeContext.mesh.material = COLLIDER_MATERIAL_SELECTED;

      const colliderComp = this.gameObjectInput.gameObject3D.getComponent(
        ColliderComponent.TYPE
      );

      this.shapeContext.deleteButton = document.createElement('button');
      this.shapeContext.deleteButton.innerText = 'delete shape';
      this.shapeContext.deleteButton.onclick = () => {
        colliderComp.model.shapesJSON.splice(shapeIndex, 1);
        this.updateCollider();
      };

      if (
        colliderComp.model.shapesJSON[shapeIndex].type ==
        ColliderComponent.SHAPE_TYPE.CIRCLE
      ) {
        // add ui to set radius
        this.shapeContext.radiusUI = createLabelInput('Radius ', 'number');
        this.gameObjectInput.detailsCollider.appendChild(
          this.shapeContext.radiusUI.parent
        );

        // init
        this.shapeContext.radiusUI.input.value =
          colliderComp.model.shapesJSON[shapeIndex].radius;

        this.shapeContext.radiusUI.input.onchange = () => {
          const newRadius = this.shapeContext.radiusUI.input.valueAsNumber;
          if (newRadius < 0.01) {
            alert('radius must superior at 0.01');
          } else {
            colliderComp.model.shapesJSON[shapeIndex].radius = newRadius;
            this.updateShapeSelected();
          }
        };
      }

      this.gameObjectInput.detailsCollider.appendChild(
        this.shapeContext.deleteButton
      );

      this.updateShapeSelected(false); // no need to rebuild shape geometry
    } else {
      this.transformControls.attach(this.gameObjectInput.gameObject3D);
    }
  }

  /**
   *
   * @param {Mesh} mesh - point mesh to select with transform controls
   */
  selectPointMesh(mesh) {
    this.shapeContext.pointMesh = mesh;
    this.transformControls.attach(this.shapeContext.pointMesh);
  }

  /**
   * Update shape selected (when a property of the shape has changed)
   *
   * @param {boolean} rebuildShapeGeometry - shape selected needs to rebuild its geometry
   */
  updateShapeSelected(rebuildShapeGeometry = true) {
    // remove all old point meshes
    for (let i = this.pointsParent.children.length - 1; i >= 0; i--) {
      this.pointsParent.children[i].removeFromParent();
    }

    // reset transform controls
    this.transformControls.detach();

    // unreference this.shapeContext.pointMesh
    this.shapeContext.pointMesh = null;

    // transform pointParent in referential of the current gameobject3D
    const worldPosition = new Vector3();
    const worldQuaternion = new Quaternion();
    const worldScale = new Vector3();
    this.gameObjectInput.gameObject3D.matrixWorld.decompose(
      worldPosition,
      worldQuaternion,
      worldScale
    );

    this.pointsParent.position.copy(worldPosition);
    this.pointsParent.quaternion.copy(worldQuaternion);
    this.pointsParent.scale.copy(worldScale);

    // retrieve shapeJSON
    const colliderComp = this.gameObjectInput.gameObject3D.getComponent(
      ColliderComponent.TYPE
    );
    const index = this.colliderParent.children.indexOf(this.shapeContext.mesh);
    const shapeJSON = colliderComp.model.shapesJSON[index];

    // rebuild shape + point mesh
    if (shapeJSON.type == ColliderComponent.SHAPE_TYPE.POLYGON) {
      if (rebuildShapeGeometry) {
        this.shapeContext.mesh.geometry = new ConvexGeometry(
          shapeJSON.points.map((el) => new Vector3(el.x, el.y, el.z))
        );
        console.trace('shape rebuilded with ', shapeJSON);
      }

      shapeJSON.points.forEach((point, index) => {
        const pointMesh = new Mesh(
          new SphereGeometry(0.6),
          COLLIDER_POINT_MATERIAL
        );
        pointMesh.position.set(point.x, point.y, point.z);
        pointMesh.userData.index = index;
        pointMesh.userData.shapeJSON = shapeJSON; // userdata used at the end of transform controls
        this.pointsParent.add(pointMesh);
      });
    } else if (shapeJSON.type == ColliderComponent.SHAPE_TYPE.CIRCLE) {
      if (rebuildShapeGeometry) {
        this.shapeContext.mesh.geometry = new CircleGeometry(
          shapeJSON.radius,
          32
        );
        this.shapeContext.mesh.geometry.translate(
          shapeJSON.center.x,
          shapeJSON.center.y,
          shapeJSON.center.z
        );
        console.trace('shape rebuilded with ', shapeJSON);
      }
      const pointMesh = new Mesh(
        new SphereGeometry(0.6),
        COLLIDER_POINT_MATERIAL
      );
      pointMesh.position.set(
        shapeJSON.center.x,
        shapeJSON.center.y,
        shapeJSON.center.z
      );
      pointMesh.userData.shapeJSON = shapeJSON; // userdata used at the end of transform controls
      this.pointsParent.add(pointMesh);
      this.selectPointMesh(pointMesh);
    }

    if (rebuildShapeGeometry) this.scaleShapePoints();
  }

  /**
   * Update box3 wrapping selected game object 3d
   */
  updateBox3() {
    this.gameObjectInput.gameObject3D.updateMatrixWorld();
    const worldQuaternion = new Quaternion();
    this.gameObjectInput.gameObject3D.matrixWorld.decompose(
      new Vector3(),
      worldQuaternion,
      new Vector3()
    );

    const inverseWorldQuaternion = worldQuaternion.clone().invert();

    // cancel quaternion
    this.gameObjectInput.gameObject3D.quaternion.multiply(
      inverseWorldQuaternion
    );

    const bbScale = Editor.computeBox3GameObject3D(
      this.gameObjectInput.gameObject3D
    );
    this.currentGameObjectMeshBox3.scale.copy(
      bbScale.max.clone().sub(bbScale.min)
    );

    // restore quaternion
    this.gameObjectInput.gameObject3D.quaternion.multiply(worldQuaternion);

    const bbPosition = Editor.computeBox3GameObject3D(
      this.gameObjectInput.gameObject3D
    );
    bbPosition.getCenter(this.currentGameObjectMeshBox3.position);

    this.currentGameObjectMeshBox3.quaternion.copy(worldQuaternion);
    this.currentGameObjectMeshBox3.updateMatrixWorld();
  }

  /**
   *
   * @param {Object3D} obj - object 3d to compute box3
   * @returns {Box3} - box 3 of the object 3d
   */
  static computeBox3GameObject3D(obj) {
    const bb = new Box3().setFromObject(obj);

    // avoid bug if no renderdata on this gameobject
    const checkIfCoordInfinite = function (value) {
      return value === Infinity || value === -Infinity;
    };
    const checkIfVectorHasCoordInfinite = function (vector) {
      return (
        checkIfCoordInfinite(vector.x) ||
        checkIfCoordInfinite(vector.y) ||
        checkIfCoordInfinite(vector.z)
      );
    };

    if (
      checkIfVectorHasCoordInfinite(bb.min) ||
      checkIfVectorHasCoordInfinite(bb.max)
    ) {
      // cube 1,1,1
      bb.min.set(-0.5, -0.5, -0.5);
      bb.max.set(0.5, 0.5, 0.5);

      bb.applyMatrix4(obj.matrixWorld);
    }
    return bb;
  }
}

/** @class Custom HTML element that provides a user interface for editing various components and properties of a 3D game object.*/
class GameObject3DInput extends HTMLElement {
  /**
   *
   * @param {Array<string>} idRenderDatas - possible id render datas to set in RenderComponent
   * @param {Array<string>} idSounds - possible id sound to set in AudioComponent
   * @param {Array<string>} idGameScripts - possible id game script to set in GameScriptComponent
   * @param {Array<string>} idExternalScripts - possible id external script to set in ExternalScriptComponent
   * @param {Array<string>} gameScriptVariablesInputs - object inputs to edit .variables of GameScriptComponent
   * @param {Array<string>} externalScriptVariablesInputs - object inputs to edit .variables of ExternalScriptComponent
   * @param {Array<string>} userDataInputs - object inputs to edit .userData
   */
  constructor(
    idRenderDatas,
    idSounds,
    idGameScripts,
    idExternalScripts,
    gameScriptVariablesInputs,
    externalScriptVariablesInputs,
    userDataInputs
  ) {
    super();

    /** @type {Array} */
    this.idRenderDatas = idRenderDatas;

    /** @type {Array} */
    this.idSounds = idSounds;

    /** @type {Array} */
    this.idGameScripts = idGameScripts || [];

    /** @type {Array} */
    this.idExternalScripts = idExternalScripts || [];

    /** @type {Array} */
    this.gameScriptVariablesInputs = gameScriptVariablesInputs || [];

    /** @type {Array} */
    this.externalScriptVariablesInputs = externalScriptVariablesInputs || [];

    /** @type {Array} */
    this.userDataInputs = userDataInputs || [];

    /** @type {GameObject3D|null} */
    this.gameObject3D = null;

    // Name
    this.nameLabelInput = createLabelInput('Name: ', 'text');
    this.appendChild(this.nameLabelInput.parent);

    this.nameLabelInput.input.onchange = () => {
      this.gameObject3D.name = this.nameLabelInput.input.value;
      this.dispatchEvent(new CustomEvent(GameObject3DInput.EVENT.NAME_CHANGED));
    };

    // Static
    this.static = createLabelInput('Static: ', 'checkbox');
    this.appendChild(this.static.parent);

    this.static.input.onchange = () => {
      this.gameObject3D.static = this.static.input.checked;
    };

    // Visible
    this.visible = createLabelInput('Visible: ', 'checkbox');
    this.appendChild(this.visible.parent);

    this.visible.input.onchange = () => {
      this.gameObject3D.visible = this.visible.input.checked;
    };

    // transform
    const detailsTransform = document.createElement('details');
    this.appendChild(detailsTransform);
    const summaryTransform = document.createElement('summary');
    summaryTransform.innerText = 'Transform';
    detailsTransform.appendChild(summaryTransform);

    this.position = new Vector3Input('Position', 0.1);
    detailsTransform.appendChild(this.position);

    this.rotation = new Vector3Input('Rotation', 0.01);
    detailsTransform.appendChild(this.rotation);

    this.scale = new Vector3Input('Scale', 0.1);
    detailsTransform.appendChild(this.scale);

    this.position.addEventListener('change', () => {
      this.gameObject3D.position.set(
        this.position.x.input.valueAsNumber,
        this.position.y.input.valueAsNumber,
        this.position.z.input.valueAsNumber
      );
      this.dispatchEvent(
        new CustomEvent(GameObject3DInput.EVENT.TRANSFORM_CHANGED)
      );
    });
    this.rotation.addEventListener('change', () => {
      this.gameObject3D.rotation.set(
        this.rotation.x.input.valueAsNumber,
        this.rotation.y.input.valueAsNumber,
        this.rotation.z.input.valueAsNumber
      );
      this.dispatchEvent(
        new CustomEvent(GameObject3DInput.EVENT.TRANSFORM_CHANGED)
      );
    });
    this.scale.addEventListener('change', () => {
      this.gameObject3D.scale.set(
        this.scale.x.input.valueAsNumber,
        this.scale.y.input.valueAsNumber,
        this.scale.z.input.valueAsNumber
      );
      this.dispatchEvent(
        new CustomEvent(GameObject3DInput.EVENT.TRANSFORM_CHANGED)
      );
    });

    // userdata
    this.detailsUserData = document.createElement('details');
    this.appendChild(this.detailsUserData);
    this.userDataInputDomElement = null;

    // collider
    this.detailsCollider = document.createElement('details');
    this.appendChild(this.detailsCollider);

    // render
    this.detailsRender = document.createElement('details');
    this.appendChild(this.detailsRender);

    // audio
    this.detailsAudio = document.createElement('details');
    this.appendChild(this.detailsAudio);

    // game script
    this.detailsGameScript = document.createElement('details');
    this.appendChild(this.detailsGameScript);
    /** @type {HTMLElement} */
    this.gameScriptInputDomElement = null;

    // external script
    this.detailsExternalScript = document.createElement('details');
    this.appendChild(this.detailsExternalScript);
    this.externalScriptInputDomElement = null;

    // no gameobject3d set at the construction
    this.hidden = true;
  }

  /**
   *
   * @param {GameObject3D} go - go to select in the game object 3d input
   */
  setGameObject3D(go) {
    if (!go) {
      this.hidden = true;
      return;
    }
    this.hidden = false;

    this.gameObject3D = go;

    this.nameLabelInput.input.value = go.name;

    this.static.input.checked = go.static;

    this.visible.input.checked = go.visible;

    // transform
    this.updateTransform();

    // userdata
    this.updateUserData();

    // collider
    this.updateCollider();

    // render
    this.updateRender();

    // audio
    this.updateAudio();

    // game script
    this.updateGameScript();

    // external script
    this.updateExternalScript();
  }

  /**
   * Update userData edition of the current game object 3d
   */
  updateUserData() {
    let CurrentClassObjectInput = null;
    for (let index = 0; index < this.userDataInputs.length; index++) {
      const ClassObjectInput = this.userDataInputs[index];
      // userdata inputs take an gameobject3D as condition
      if (ClassObjectInput.condition(this.gameObject3D)) {
        CurrentClassObjectInput = ClassObjectInput;
        break;
      }
    }

    if (CurrentClassObjectInput) {
      this.detailsUserData.hidden = false;

      while (this.detailsUserData.firstChild)
        this.detailsUserData.firstChild.remove();

      const summary = document.createElement('summary');
      summary.innerText = 'userData';
      this.detailsUserData.appendChild(summary);

      const editButton = document.createElement('button');
      editButton.innerText = 'Edit';
      this.detailsUserData.appendChild(editButton);

      this.userDataInputDomElement = document.createElement('div');
      this.detailsUserData.appendChild(this.userDataInputDomElement);

      editButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.OBJECT_INPUT_CREATION, {
            detail: {
              ClassObjectInput: CurrentClassObjectInput,
              typeObjectInput: ObjectInput.TYPE.USER_DATA,
            },
          })
        );
      };
    } else {
      this.detailsUserData.hidden = true;
    }
  }

  /**
   * Update GameScript component edition of the current game object 3d
   */
  updateGameScript() {
    this.updateScriptComponent(GameScriptComponent.TYPE);
  }

  /**
   * Update ExternalScript component edition of the current game object 3d
   */
  updateExternalScript() {
    this.updateScriptComponent(ExternalScriptComponent.TYPE);
  }

  /**
   * Update Script component edition of the current game object 3d
   *
   * @param {string} scriptComponentType - can ExternalScriptComponent.TYPE or GameScriptComponent.TYPE
   */
  updateScriptComponent(scriptComponentType) {
    const scriptComponent = this.gameObject3D.getComponent(scriptComponentType);
    const detailsParent =
      scriptComponentType == GameScriptComponent.TYPE
        ? this.detailsGameScript
        : this.detailsExternalScript;
    const summaryText =
      scriptComponentType == GameScriptComponent.TYPE
        ? 'GameScript'
        : 'ExternalScript';
    const idScripts =
      scriptComponentType == GameScriptComponent.TYPE
        ? this.idGameScripts
        : this.idExternalScripts;
    const objectInputs =
      scriptComponentType == GameScriptComponent.TYPE
        ? this.gameScriptVariablesInputs
        : this.externalScriptVariablesInputs;

    while (detailsParent.firstChild) detailsParent.firstChild.remove();
    // rebuild domelement
    const summaryAudio = document.createElement('summary');
    summaryAudio.innerText = summaryText;
    detailsParent.appendChild(summaryAudio);

    if (scriptComponent) {
      // delete component button
      const deleteComponentButton = document.createElement('button');
      deleteComponentButton.innerText = 'Delete component';
      detailsParent.appendChild(deleteComponentButton);

      deleteComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_REMOVE, {
            detail: { type: scriptComponentType },
          })
        );
      };

      const listScript = document.createElement('ul');
      detailsParent.appendChild(listScript);

      const divObjectInput = document.createElement('div');
      detailsParent.appendChild(divObjectInput);

      if (scriptComponentType == GameScriptComponent.TYPE) {
        this.gameScriptInputDomElement = divObjectInput;
      } else if (scriptComponentType == ExternalScriptComponent.TYPE) {
        this.externalScriptInputDomElement = divObjectInput;
      }

      const updateList = () => {
        while (listScript.firstChild) listScript.firstChild.remove();

        scriptComponent.model.scriptParams.forEach((param, index) => {
          const li = document.createElement('li');
          li.innerText = param.id;
          listScript.appendChild(li);

          for (let index = 0; index < objectInputs.length; index++) {
            const ClassObjectInput = objectInputs[index];
            // scriptvariablesinput take an id in their condition
            if (ClassObjectInput.condition(param.id)) {
              const editButton = document.createElement('button');
              editButton.innerText = 'Edit';
              li.appendChild(editButton);
              editButton.onclick = () => {
                this.dispatchEvent(
                  new CustomEvent(
                    GameObject3DInput.EVENT.OBJECT_INPUT_CREATION,
                    {
                      detail: {
                        ClassObjectInput: ClassObjectInput,
                        typeObjectInput:
                          scriptComponentType == GameScriptComponent.TYPE
                            ? ObjectInput.TYPE.GAME_SCRIPT
                            : ObjectInput.TYPE.EXTERNAL_SCRIPT,
                      },
                    }
                  )
                );
              };

              break;
            }
          }

          const deleteButton = document.createElement('button');
          deleteButton.innerText = 'delete';
          li.appendChild(deleteButton);

          deleteButton.onclick = () => {
            scriptComponent.model.scriptParams.splice(index, 1);
            this.dispatchEvent(
              new CustomEvent(GameObject3DInput.EVENT.SCRIPT_DELETED, {
                detail: { id: param.id },
              })
            );
            updateList();
          };

          const priority = createLabelInput('Priorité: ', 'number');
          li.appendChild(priority.parent);
          priority.input.value = !isNaN(param.priority) ? param.priority : 0;
          priority.input.onchange = () => {
            const newPriority = Math.round(priority.input.value);
            if (isNaN(newPriority)) return;
            priority.input.value = newPriority; // the rounded one
            param.priority = newPriority;
          };
        });
      };
      updateList();

      // scripts that can be added
      const selectIdScript = document.createElement('select');
      detailsParent.appendChild(selectIdScript);

      idScripts.forEach((id) => {
        const option = document.createElement('option');
        option.innerText = id;
        option.value = id;
        selectIdScript.appendChild(option);
      });

      const addScriptButton = document.createElement('button');
      addScriptButton.innerText = 'Add ' + summaryText;
      detailsParent.appendChild(addScriptButton);

      addScriptButton.onclick = () => {
        const idToAdd = selectIdScript.selectedOptions[0].value;
        const alreadyThere =
          scriptComponent.model.scriptParams.filter(
            (param) => param.id == idToAdd
          ).length != 0;
        if (!alreadyThere) {
          scriptComponent.model.scriptParams.push({ id: idToAdd, priority: 0 });
          updateList();
        }
      };
    } else {
      const addComponentButton = document.createElement('button');
      addComponentButton.innerText = 'Add component';
      detailsParent.appendChild(addComponentButton);
      addComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_ADD, {
            detail: { type: scriptComponentType },
          })
        );
      };
    }
  }

  /**
   * Update Audio component edition of the current game object 3d
   */
  updateAudio() {
    const audioComp = this.gameObject3D.getComponent(AudioComponent.TYPE);
    while (this.detailsAudio.firstChild) this.detailsAudio.firstChild.remove();

    // rebuild domelement
    const summaryAudio = document.createElement('summary');
    summaryAudio.innerText = 'Audio';
    this.detailsAudio.appendChild(summaryAudio);
    if (audioComp) {
      // delete component button
      const deleteComponentButton = document.createElement('button');
      deleteComponentButton.innerText = 'Delete component';
      this.detailsAudio.appendChild(deleteComponentButton);

      deleteComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_REMOVE, {
            detail: { type: AudioComponent.TYPE },
          })
        );
      };

      // sounds
      const listIdSounds = document.createElement('ul');
      this.detailsAudio.appendChild(listIdSounds);

      const updateList = () => {
        while (listIdSounds.firstChild) listIdSounds.firstChild.remove();

        audioComp.model.soundsJSON.forEach((idSound) => {
          const li = document.createElement('li');
          li.innerText = idSound;
          listIdSounds.appendChild(li);

          const deleteButton = document.createElement('button');
          deleteButton.innerText = 'delete';
          li.appendChild(deleteButton);

          deleteButton.onclick = () => {
            removeFromArray(audioComp.model.soundsJSON, idSound);
            updateList();
          };
        });
      };
      updateList();

      // sounds
      const selectIdSound = document.createElement('select');
      this.detailsAudio.appendChild(selectIdSound);
      this.idSounds.forEach((idSound) => {
        const option = document.createElement('option');
        option.value = idSound;
        option.innerText = idSound;
        selectIdSound.appendChild(option);
      });

      const addIdSound = document.createElement('button');
      this.detailsAudio.appendChild(addIdSound);
      addIdSound.innerText = 'Add sound';
      addIdSound.onclick = () => {
        if (
          arrayPushOnce(
            audioComp.model.soundsJSON,
            selectIdSound.selectedOptions[0].value
          )
        ) {
          // has been added
          updateList();
        }
      };

      // conf audio

      // loop
      const loop = createLabelInput('Loop: ', 'checkbox');
      this.detailsAudio.appendChild(loop.parent);
      loop.input.checked = audioComp.model.conf.loop;
      loop.input.onchange = () => {
        audioComp.model.conf.loop = loop.input.checked;
      };

      // autoplay
      const autoplay = createLabelInput('Autoplay: ', 'checkbox');
      this.detailsAudio.appendChild(autoplay.parent);
      autoplay.input.checked = audioComp.model.conf.autoplay;
      autoplay.input.onchange = () => {
        audioComp.model.conf.autoplay = autoplay.input.checked;
      };

      // spatialized
      const spatialized = createLabelInput('Spatialized: ', 'checkbox');
      this.detailsAudio.appendChild(spatialized.parent);
      spatialized.input.checked = audioComp.model.conf.spatialized;
      spatialized.input.onchange = () => {
        audioComp.model.conf.spatialized = spatialized.input.checked;
      };

      // volume
      const volume = createLabelInput('Volume: ', 'range');
      this.detailsAudio.appendChild(volume.parent);
      volume.input.min = 0;
      volume.input.max = 1;
      volume.input.step = 'any';
      volume.input.value = isNaN(audioComp.model.conf.volume)
        ? 1
        : audioComp.model.conf.volume;
      volume.input.onchange = () => {
        audioComp.model.conf.volume = volume.input.valueAsNumber;
      };
    } else {
      const addComponentButton = document.createElement('button');
      addComponentButton.innerText = 'Add component';
      this.detailsAudio.appendChild(addComponentButton);
      addComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_ADD, {
            detail: { type: AudioComponent.TYPE },
          })
        );
      };
    }
  }

  /**
   * Update Render component edition of the current game object 3d
   */
  updateRender() {
    const renderComp = this.gameObject3D.getComponent(RenderComponent.TYPE);
    while (this.detailsRender.firstChild)
      this.detailsRender.firstChild.remove();

    // rebuild domelement
    const summaryRender = document.createElement('summary');
    summaryRender.innerText = 'Render';
    this.detailsRender.appendChild(summaryRender);
    if (renderComp) {
      // delete component button
      const deleteComponentButton = document.createElement('button');
      deleteComponentButton.innerText = 'Delete component';
      this.detailsRender.appendChild(deleteComponentButton);

      deleteComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_REMOVE, {
            detail: { type: RenderComponent.TYPE },
          })
        );
      };

      // color
      const color = createLabelInput('Couleur: ', 'color');
      this.detailsRender.appendChild(color.parent);
      color.input.value =
        '#' + new Color().fromArray(renderComp.model.color).getHexString();

      // opacity
      const opacity = createLabelInput('Opacité ', 'range');
      opacity.input.min = 0;
      opacity.input.max = 1;
      opacity.input.step = 'any';
      this.detailsRender.appendChild(opacity.parent);
      opacity.input.value = renderComp.model.color[3];

      const updateColor = () => {
        renderComp.controller.setColor([
          ...new Color(color.input.value).toArray(),
          opacity.input.valueAsNumber,
        ]);
      };

      opacity.input.onchange = updateColor;

      color.input.onchange = updateColor;

      // id model
      const selectIdRenderData = document.createElement('select');
      this.detailsRender.appendChild(selectIdRenderData);
      this.idRenderDatas.forEach((id) => {
        const option = document.createElement('option');
        option.innerText = id || 'none';
        option.value = id;
        selectIdRenderData.appendChild(option);
        if (renderComp.model.idRenderData == id) {
          selectIdRenderData.value = id;
        }
      });

      selectIdRenderData.onchange = () => {
        renderComp.controller.setIdRenderData(
          selectIdRenderData.selectedOptions[0].value
        );
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.ID_RENDER_DATA_CHANGED)
        );
      };
    } else {
      const addComponentButton = document.createElement('button');
      addComponentButton.innerText = 'Add component';
      this.detailsRender.appendChild(addComponentButton);
      addComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_ADD, {
            detail: { type: RenderComponent.TYPE },
          })
        );
      };
    }
  }

  /**
   * Update Collider component edition of the current game object 3d
   */
  updateCollider() {
    const colliderComp = this.gameObject3D.getComponent(ColliderComponent.TYPE);
    while (this.detailsCollider.firstChild)
      this.detailsCollider.firstChild.remove();

    // rebuild domelement
    const summaryCollider = document.createElement('summary');
    summaryCollider.innerText = 'Collider';
    this.detailsCollider.appendChild(summaryCollider);
    if (colliderComp) {
      // delete component button
      const deleteComponentButton = document.createElement('button');
      deleteComponentButton.innerText = 'Delete component';
      this.detailsCollider.appendChild(deleteComponentButton);

      deleteComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_REMOVE, {
            detail: { type: ColliderComponent.TYPE },
          })
        );
      };

      // edit body attr
      const bodyCheckbox = createLabelInput('body', 'checkbox');
      bodyCheckbox.input.checked = colliderComp.model.body;
      bodyCheckbox.input.onchange = () =>
        (colliderComp.model.body = bodyCheckbox.input.checked);
      this.detailsCollider.appendChild(bodyCheckbox.parent);

      // add a polygon
      const addPolygonButton = document.createElement('button');
      addPolygonButton.innerText = 'Add Polygon';
      this.detailsCollider.appendChild(addPolygonButton);

      addPolygonButton.onclick = () => {
        // add a square
        colliderComp.model.shapesJSON.push({
          type: ColliderComponent.SHAPE_TYPE.POLYGON,
          points: [
            { x: -5, y: -5, z: 0 },
            { x: 5, y: -5, z: 0 },
            { x: 5, y: 5, z: 0 },
            { x: -5, y: 5, z: 0 },
          ],
        });
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.SHAPE_ADDED, {
            detail: {
              shapeIndexCreated: colliderComp.model.shapesJSON.length - 1,
            },
          })
        );
      };

      // add a circle
      const addCircleButton = document.createElement('button');
      addCircleButton.innerText = 'Add Circle';
      this.detailsCollider.appendChild(addCircleButton);

      addCircleButton.onclick = () => {
        // add a circle
        colliderComp.model.shapesJSON.push({
          type: ColliderComponent.SHAPE_TYPE.CIRCLE,
          radius: 2.5,
          center: { x: 0, y: 0, z: 0 },
        });
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.SHAPE_ADDED, {
            detail: {
              shapeIndexCreated: colliderComp.model.shapesJSON.length - 1,
            },
          })
        );
      };

      const visualization2DImage = document.createElement('img');
      this.detailsCollider.appendChild(visualization2DImage);

      // visualize collision in 2d
      const visualizeCollision2DButton = document.createElement('button');
      visualizeCollision2DButton.innerText = 'Update visualize 2D collision';
      this.detailsCollider.appendChild(visualizeCollision2DButton);
      const drawVisualization2D = () => {
        const scene = new Scene();
        scene.add(new AmbientLight('white', 0.6));

        // move gameobject to another scene
        const oldParent = this.gameObject3D.parent;
        this.gameObject3D.removeFromParent();

        scene.add(this.gameObject3D);
        this.gameObject3D.updateMatrixWorld();

        // compute bb with collider points
        const bb = new Box3();

        const colliderComp = this.gameObject3D.getComponent(
          ColliderComponent.TYPE
        );
        colliderComp.model.shapesJSON.forEach((shape) => {
          if (shape.type == ColliderComponent.SHAPE_TYPE.POLYGON) {
            shape.points.forEach((point) => {
              bb.expandByPoint(
                new Vector3(point.x, point.y, point.z).applyMatrix4(
                  this.gameObject3D.matrixWorld
                )
              );
            });
          } else {
            bb.expandByPoint(
              new Vector3(
                shape.center.x + shape.radius,
                shape.center.y + shape.radius,
                shape.center.z
              )
                .multiply(this.gameObject3D.scale)
                .add(this.gameObject3D.position)
            );
            bb.expandByPoint(
              new Vector3(
                shape.center.x - shape.radius,
                shape.center.y - shape.radius,
                shape.center.z
              )
                .multiply(this.gameObject3D.scale)
                .add(this.gameObject3D.position)
            );
          }
        });

        const maxDim = Math.max(bb.max.x - bb.min.x, bb.max.y - bb.min.y);
        const halfSize = maxDim * 0.5;
        const camera = new OrthographicCamera(
          -halfSize,
          halfSize,
          halfSize,
          -halfSize,
          0.001,
          10000
        );
        bb.getCenter(camera.position);
        camera.position.z = 1000;
        camera.updateProjectionMatrix();
        const renderer = new WebGLRenderer({
          canvas: document.createElement('canvas'),
          antialias: true,
          alpha: true,
        });

        const size = 512;

        renderer.setSize(size, size);
        renderer.setClearColor(0xffffff, 0);
        renderer.render(scene, camera);

        // compute offset to translate collisions after
        const offset = bb
          .getCenter(new Vector3())
          .sub(this.gameObject3D.position);

        this.gameObject3D.removeFromParent();
        oldParent.add(this.gameObject3D);

        // draw image
        const image = new Image();
        image.src = renderer.domElement.toDataURL();

        image.onload = async () => {
          // render collisions
          const json = this.gameObject3D.toJSON();
          delete json.children; // remove children (they should be removed also in rendering above ?)
          delete json.components.GameScript; // remove GameScript only Collider is needed
          const context = new Context({}, new GameObject3D(json));
          await context.load();

          const canvas2D = document.createElement('canvas');
          canvas2D.width = renderer.domElement.width;
          canvas2D.height = renderer.domElement.height;
          const ctx = canvas2D.getContext('2d');

          ctx.drawImage(image, 0, 0);

          ctx.save();
          ctx.translate(
            (0.5 - offset.x / maxDim) * renderer.domElement.width,
            (0.5 + offset.y / maxDim) * renderer.domElement.height
          );
          ctx.scale(
            (this.gameObject3D.scale.x * renderer.domElement.width) / maxDim,
            (this.gameObject3D.scale.y * -renderer.domElement.height) / maxDim
          );
          context.collisions.draw(ctx);
          ctx.fillStyle = 'green';
          ctx.fill();
          ctx.restore();

          visualization2DImage.src = canvas2D.toDataURL();
        };
      };
      visualizeCollision2DButton.onclick = drawVisualization2D;
      drawVisualization2D();
    } else {
      const addComponentButton = document.createElement('button');
      addComponentButton.innerText = 'Add component';
      this.detailsCollider.appendChild(addComponentButton);
      addComponentButton.onclick = () => {
        this.dispatchEvent(
          new CustomEvent(GameObject3DInput.EVENT.COMPONENT_ADD, {
            detail: { type: ColliderComponent.TYPE },
          })
        );
      };
    }
  }

  /**
   * Update Transform component edition of the current game object 3d
   */
  updateTransform() {
    this.position.x.input.value = this.gameObject3D.position.x;
    this.position.y.input.value = this.gameObject3D.position.y;
    this.position.z.input.value = this.gameObject3D.position.z;
    this.rotation.x.input.value = this.gameObject3D.rotation.x;
    this.rotation.y.input.value = this.gameObject3D.rotation.y;
    this.rotation.z.input.value = this.gameObject3D.rotation.z;
    this.scale.x.input.value = this.gameObject3D.scale.x;
    this.scale.y.input.value = this.gameObject3D.scale.y;
    this.scale.z.input.value = this.gameObject3D.scale.z;
  }

  /**
   *
   * @returns {object} - EVENT enum of the game object 3d input
   */
  static get EVENT() {
    return {
      NAME_CHANGED: 'name_changed',
      TRANSFORM_CHANGED: 'transform_changed',
      SHAPE_ADDED: 'polygon_added',
      OBJECT_INPUT_CREATION: 'object_input_creation',
      SCRIPT_DELETED: 'script_deleted',
      COMPONENT_ADD: 'component_add',
      COMPONENT_REMOVE: 'component_remove',
      ID_RENDER_DATA_CHANGED: 'id_render_data_changed',
    };
  }
}
window.customElements.define('game-object-3d-input', GameObject3DInput); // mandatory to extends HTMLElement