namespace gdjs {
  const logger = new gdjs.Logger('In-Game editor');

  /**
   * A minimal utility to define DOM elements.
   * Also copied in InGameDebugger.tsx.
   */
  function h<K extends keyof HTMLElementTagNameMap>(
    tag: K,
    attrs: {
      style?: Partial<CSSStyleDeclaration>;
      onClick?: () => void;
    },
    ...nodes: (HTMLElement | string)[]
  ): HTMLElement {
    const node = document.createElement(tag);
    Object.keys(attrs).forEach((key) => {
      if (key === 'style') {
        for (const [styleName, value] of Object.entries(attrs.style!)) {
          node.style[styleName] = value;
        }
      } else if (key === 'onClick') {
        node.addEventListener('click', attrs[key]!);
      } else {
        node.setAttribute(key, '' + attrs[key]);
      }
    });

    node.append(...nodes);
    return node;
  }

  /**
   * Adapt the Three.js TransformControls gizmos so that the axis are in the same direction as in GDevelop.
   * This does not change the way the controls work (notably when dragged), it's only a visual adaptation.
   */
  const patchAxesOnTransformControlsGizmos = (
    controls: THREE_ADDONS.TransformControls
  ) => {
    const gizmoRoot = controls && (controls as any)._gizmo;
    if (!gizmoRoot) return;

    // Flip gizmo (visual), picker (raycast), and helper (guide lines)
    const groupsToFlip = ['gizmo', 'picker', 'helper'];

    // Bake axis reflections into geometry so per-frame handle scaling
    // inside TransformControlsGizmo.updateMatrixWorld() won't undo it.
    const flipY = new THREE.Matrix4().makeScale(1, -1, 1);
    const flipX = new THREE.Matrix4().makeScale(-1, 1, 1);

    // For translate mode: flip Y-axis handles
    const shouldFlipYByName = (name) =>
      name === 'Y' || name === 'XY' || name === 'YZ';

    // For scale mode: flip X-axis handles
    const shouldFlipXByName = (name) =>
      name === 'X' || name === 'XY' || name === 'XZ';

    // Process translate mode (flip Y)
    for (const group of groupsToFlip) {
      const root = gizmoRoot[group] && gizmoRoot[group]['translate'];
      if (!root) continue;

      root.traverse((obj) => {
        if (!obj || !obj.geometry) return;
        const name = obj.name || '';
        if (!shouldFlipYByName(name)) return;

        // Bake the Y flip directly into the geometry
        obj.geometry.applyMatrix4(flipY);

        // Keep raycasting bounds correct after mutation
        if (typeof obj.geometry.computeBoundingBox === 'function') {
          obj.geometry.computeBoundingBox();
        }
        if (typeof obj.geometry.computeBoundingSphere === 'function') {
          obj.geometry.computeBoundingSphere();
        }
      });
    }

    // Process scale mode (flip X)
    for (const group of groupsToFlip) {
      const root = gizmoRoot[group] && gizmoRoot[group]['scale'];
      if (!root) continue;

      root.traverse((obj) => {
        if (!obj || !obj.geometry) return;
        const name = obj.name || '';
        if (!shouldFlipXByName(name)) return;

        // Bake the X flip directly into the geometry
        obj.geometry.applyMatrix4(flipX);

        // Keep raycasting bounds correct after mutation
        if (typeof obj.geometry.computeBoundingBox === 'function') {
          obj.geometry.computeBoundingBox();
        }
        if (typeof obj.geometry.computeBoundingSphere === 'function') {
          obj.geometry.computeBoundingSphere();
        }
      });
    }
  };

  const patchColorsOnTransformControlsGizmos = (
    controls: THREE_ADDONS.TransformControls
  ) => {
    const gizmoRoot = controls && (controls as any)._gizmo;
    if (!gizmoRoot) return;

    const colorMap = {
      x: 0xf53e63,
      y: 0xa4e507,
      z: 0x36a9f5,
      e: 0xffff00,
      xyz: 0xffffff,
      xyze: 0x787878,
      highlight: 0xeeeeee,
    };
    const modes = ['translate', 'rotate', 'scale'];
    const groups = ['gizmo', 'helper'];

    // Helper to determine which color to use based on handle name
    const getColorForHandle = (name) => {
      const nameLower = (name || '').toLowerCase();

      if (nameLower === 'xyze') return colorMap.xyze;
      if (nameLower === 'xyz') return colorMap.xyz;
      if (nameLower === 'e') return colorMap.e;

      // For plane handles (XY, YZ, XZ), use the color of the missing axis
      if (nameLower === 'xy') return colorMap.z;
      if (nameLower === 'yz') return colorMap.x;
      if (nameLower === 'xz') return colorMap.y;

      // Single axis handles
      if (nameLower === 'x' || nameLower.includes('x')) return colorMap.x;
      if (nameLower === 'y' || nameLower.includes('y')) return colorMap.y;
      if (nameLower === 'z' || nameLower.includes('z')) return colorMap.z;

      return null;
    };

    // Apply colors to all gizmo materials
    for (const mode of modes) {
      for (const group of groups) {
        const root = gizmoRoot[group] && gizmoRoot[group][mode];
        if (!root) continue;

        root.traverse((obj) => {
          if (!obj || !obj.material) return;

          const color = getColorForHandle(obj.name);
          if (color === null) return;

          // Update the material color and store it as the base color
          obj.material.color.setHex(color);
          obj.material._color = obj.material.color.clone();
          obj.material._opacity = obj.material.opacity;
        });
      }
    }

    // Patch the gizmo's updateMatrixWorld to use custom highlight color
    if (!gizmoRoot._originalUpdateMatrixWorld) {
      gizmoRoot._originalUpdateMatrixWorld =
        gizmoRoot.updateMatrixWorld.bind(gizmoRoot);
      gizmoRoot._customHighlightColor = colorMap.highlight;

      gizmoRoot.updateMatrixWorld = function (force) {
        // Call original update first
        this._originalUpdateMatrixWorld(force);

        // Override the highlight color if an axis is selected
        if (this.enabled && this.axis) {
          const modes = ['translate', 'rotate', 'scale'];
          const groups = ['gizmo', 'helper'];

          for (const mode of modes) {
            for (const group of groups) {
              const root = this[group] && this[group][mode];
              if (!root) continue;

              root.traverse((obj) => {
                if (!obj || !obj.material) return;

                // Check if this handle should be highlighted
                const shouldHighlight =
                  obj.name === this.axis ||
                  this.axis.split('').some((a) => obj.name === a);

                if (shouldHighlight) {
                  // Apply custom highlight color
                  obj.material.color.setHex(this._customHighlightColor);
                  obj.material.opacity = 1.0;
                }
              });
            }
          }
        }
      };
    } else {
      // Update the stored highlight color if already patched
      gizmoRoot._customHighlightColor = colorMap.highlight;
    }
  };

  function patchNegativeAxisHandlesOnTransformControlsGizmos(
    controls: THREE_ADDONS.TransformControls
  ) {
    const gizmo = (controls as any)._gizmo;
    if (!gizmo) return;

    // Helper function to remove specific children from gizmo groups
    function removeNegativeHandles(gizmoGroup, mode) {
      if (!gizmoGroup || !gizmoGroup.children) return;

      const toRemove: Array<THREE.Mesh> = [];
      const seenAxes = { X: [], Y: [], Z: [] };

      // First pass: catalog all children by axis
      gizmoGroup.children.forEach((child) => {
        if (child.name === 'X' || child.name === 'Y' || child.name === 'Z') {
          seenAxes[child.name].push(child);
        }
      });

      // Second pass: mark negative handles for removal
      Object.keys(seenAxes).forEach((axisName) => {
        const axisChildren = seenAxes[axisName];

        if (mode === 'translate' && axisChildren.length >= 2) {
          // In translate: [positive arrow at index 0, negative arrow at index 1, line at index 2]
          // Remove the negative arrow (index 1)
          toRemove.push(axisChildren[1]);
        } else if (mode === 'scale' && axisChildren.length >= 3) {
          // In scale: [negative cube at index 0, line at index 1, positive cube at index 2]
          // Remove the negative cube (index 2)
          toRemove.push(axisChildren[0]);
        }
      });

      // Remove marked children
      toRemove.forEach((child) => {
        gizmoGroup.remove(child);
        if (child.geometry) child.geometry.dispose();
        if (child.material && Array.isArray(child.material)) {
          child.material.forEach((material) => material.dispose());
        } else if (child.material) {
          child.material.dispose();
        }
      });
    }

    // Only process the visual gizmos, NOT the pickers (which handle interaction)
    if (gizmo.gizmo && gizmo.gizmo['translate']) {
      removeNegativeHandles(gizmo.gizmo['translate'], 'translate');
    }

    if (gizmo.gizmo && gizmo.gizmo['scale']) {
      removeNegativeHandles(gizmo.gizmo['scale'], 'scale');
    }

    // Keep the pickers intact for interaction - they're invisible anyway
  }

  const getSvgIconUrl = (game: RuntimeGame, resourceName: string) => {
    const resource = game.getResourceLoader().getResource(resourceName);
    if (!resource) return '';
    return game.getResourceLoader().getFullUrl(resource.file);
  };

  const LEFT_KEY = 37;
  const UP_KEY = 38;
  const RIGHT_KEY = 39;
  const DOWN_KEY = 40;
  const ALT_KEY = 18;
  const DEL_KEY = 46;
  const BACKSPACE_KEY = 8;
  const LEFT_ALT_KEY = gdjs.InputManager.getLocationAwareKeyCode(ALT_KEY, 1);
  const RIGHT_ALT_KEY = gdjs.InputManager.getLocationAwareKeyCode(ALT_KEY, 2);
  const SHIFT_KEY = 16;
  const LEFT_SHIFT_KEY = gdjs.InputManager.getLocationAwareKeyCode(
    SHIFT_KEY,
    1
  );
  const RIGHT_SHIFT_KEY = gdjs.InputManager.getLocationAwareKeyCode(
    SHIFT_KEY,
    2
  );
  const SPACE_KEY = 32;
  const CTRL_KEY = 17;
  const LEFT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 1);
  const RIGHT_CTRL_KEY = gdjs.InputManager.getLocationAwareKeyCode(CTRL_KEY, 2);
  const LEFT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(91, 1);
  const RIGHT_META_KEY = gdjs.InputManager.getLocationAwareKeyCode(93, 2);
  const W_KEY = 87;
  const A_KEY = 65;
  const C_KEY = 67;
  const S_KEY = 83;
  const D_KEY = 68;
  const Q_KEY = 81;
  const E_KEY = 69;
  const F_KEY = 70;
  const O_KEY = 79;
  const V_KEY = 86;
  const X_KEY = 88;
  const Y_KEY = 89;
  const Z_KEY = 90;
  const ESC_KEY = 27;
  const EQUAL_KEY = 187;
  const MINUS_KEY = 189;
  const KEY_DIGIT_1 = 49;
  const KEY_DIGIT_2 = 50;
  const KEY_DIGIT_3 = 51;
  const ROTATION_SNAP_DEGREES = 45;

  const exceptionallyGetKeyCodeFromLocationAwareKeyCode = (
    locationAwareKeyCode: number
  ): number => {
    return locationAwareKeyCode % 1000;
  };

  // See same factors in `newIDE/app/src/Utils/ZoomUtils.js`.
  const zoomInFactor = Math.pow(2, (2 * 1) / 16);
  const zoomOutFactor = Math.pow(2, (-2 * 1) / 16);

  const instanceStateFlag = {
    selected: 1,
    locked: 2,
    hovered: 4,
  };
  const instanceWireframeColor = {
    [instanceStateFlag.hovered]: '#54daff',
    [instanceStateFlag.selected]: '#f2a63c',
    [instanceStateFlag.selected | instanceStateFlag.hovered]: '#ffd200',
    [instanceStateFlag.locked | instanceStateFlag.hovered]: '#b02715',
    [instanceStateFlag.locked | instanceStateFlag.selected]: '#b87f7f',
    [instanceStateFlag.locked |
    instanceStateFlag.selected |
    instanceStateFlag.hovered]: '#f51e02',
  };

  const editorCameraFov = 45;

  /** @category In-Game Editor */
  export type InGameEditorSettings = {
    theme: {
      iconButtonSelectedBackgroundColor: string;
      iconButtonSelectedColor: string;
      toolbarBackgroundColor: string;
      toolbarSeparatorColor: string;
      textColorPrimary: string;
    };
  };

  const defaultInGameEditorSettings: InGameEditorSettings = {
    theme: {
      iconButtonSelectedBackgroundColor: 'black',
      iconButtonSelectedColor: 'black',
      toolbarBackgroundColor: 'black',
      toolbarSeparatorColor: 'black',
      textColorPrimary: 'black',
    },
  };

  let hasWindowFocus = true;
  if (typeof window !== 'undefined') {
    window.addEventListener('focus', () => {
      hasWindowFocus = true;
    });
    window.addEventListener('blur', () => {
      hasWindowFocus = false;
    });
  }

  function isDefined<T>(value: T | null | undefined): value is NonNullable<T> {
    return value !== null && value !== undefined;
  }

  type Point3D = [float, float, float];

  type RuntimeObjectWith3D = RuntimeObject &
    Base3DHandler &
    Resizable &
    Scalable &
    Flippable & {
      getCenterZInScene(): float;
    };

  const is3D = (object: gdjs.RuntimeObject): object is RuntimeObjectWith3D => {
    return gdjs.Base3DHandler.is3D(object);
  };

  type AABB3D = {
    min: Point3D;
    max: Point3D;
  };

  const defaultEffectsData: EffectData[] = [
    {
      effectType: 'Scene3D::HemisphereLight',
      name: 'Default Light for in-game editor',
      doubleParameters: { elevation: 45, intensity: 1, rotation: 0 },
      stringParameters: {
        groundColor: '64;64;64',
        skyColor: '255;255;255',
        top: 'Y-',
      },
      booleanParameters: {},
    },
  ];

  // TODO: factor this?
  const isMacLike =
    typeof navigator !== 'undefined' &&
    navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)
      ? true
      : false;

  const isControlOrCmdPressed = (inputManager: gdjs.InputManager) => {
    // On macOS, meta key (Apple/Command key) acts as Control key on Windows/Linux.
    return (
      inputManager.isKeyPressed(LEFT_CTRL_KEY) ||
      inputManager.isKeyPressed(RIGHT_CTRL_KEY) ||
      inputManager.isKeyPressed(LEFT_META_KEY) ||
      inputManager.isKeyPressed(RIGHT_META_KEY)
    );
  };

  const isAltPressed = (inputManager: gdjs.InputManager) => {
    return (
      inputManager.isKeyPressed(LEFT_ALT_KEY) ||
      inputManager.isKeyPressed(RIGHT_ALT_KEY)
    );
  };

  const isShiftPressed = (inputManager: gdjs.InputManager) => {
    return (
      inputManager.isKeyPressed(LEFT_SHIFT_KEY) ||
      inputManager.isKeyPressed(RIGHT_SHIFT_KEY)
    );
  };

  const isControlPressedOnly = (inputManager: gdjs.InputManager) => {
    return (
      isControlOrCmdPressed(inputManager) &&
      !isShiftPressed(inputManager) &&
      !isAltPressed(inputManager)
    );
  };

  const isControlPlusShiftPressedOnly = (inputManager: gdjs.InputManager) => {
    return (
      isControlOrCmdPressed(inputManager) &&
      isShiftPressed(inputManager) &&
      !isAltPressed(inputManager)
    );
  };

  const isSpacePressed = (inputManager: gdjs.InputManager) =>
    inputManager.isKeyPressed(SPACE_KEY);

  const shouldDeleteSelection = (inputManager: gdjs.InputManager) => {
    return (
      inputManager.isKeyPressed(DEL_KEY) ||
      inputManager.isKeyPressed(BACKSPACE_KEY)
    );
  };

  const shouldScrollHorizontally = isAltPressed;

  const shouldZoom = (inputManager: gdjs.InputManager) => {
    // Browsers trigger a wheel event with ctrlKey or metaKey to true when the user
    // does a pinch gesture on a trackpad. If this is the case, we zoom.
    // see https://dev.to/danburzo/pinch-me-i-m-zooming-gestures-in-the-dom-a0e
    if (isControlOrCmdPressed(inputManager)) return true;
    if (isMacLike) {
      return isControlOrCmdPressed(inputManager);
    } else {
      return (
        !isControlOrCmdPressed(inputManager) &&
        !isAltPressed(inputManager) &&
        !isShiftPressed(inputManager)
      );
    }
  };

  const pressAndReleaseForClickDuration = 200;
  const timeBetweenClicksForDoubleClick = 400;

  /** Get the identifiers of the touches that are currently active, without the mouse. */
  const getCurrentTouchIdentifiers = (inputManager: gdjs.InputManager) => {
    return inputManager
      .getAllTouchIdentifiers()
      .slice()
      .filter((id) => id !== gdjs.InputManager.MOUSE_TOUCH_ID) // Exclude mouse touch
      .sort((a, b) => a - b); // Ensure stable order to help comparisons.
  };

  const getTouchesCentroid = (inputManager: gdjs.InputManager) => {
    const ids = getCurrentTouchIdentifiers(inputManager);
    if (ids.length === 0) return { x: 0, y: 0 };
    let sx = 0;
    let sy = 0;
    for (let i = 0; i < ids.length; i++) {
      sx += inputManager.getTouchX(ids[i]);
      sy += inputManager.getTouchY(ids[i]);
    }
    return { x: sx / ids.length, y: sy / ids.length };
  };

  const getTouchesDistance = (inputManager: gdjs.InputManager) => {
    const ids = getCurrentTouchIdentifiers(inputManager);
    if (ids.length === 0) return 0;
    return Math.hypot(
      inputManager.getTouchX(ids[0]) - inputManager.getTouchX(ids[1]),
      inputManager.getTouchY(ids[0]) - inputManager.getTouchY(ids[1])
    );
  };

  const areSameTouchesSet = (ids1: Array<integer>, ids2: Array<integer>) => {
    if (ids1.length !== ids2.length) return false;
    for (let i = 0; i < ids1.length; i++) {
      if (ids1[i] !== ids2[i]) return false;
    }
    return true;
  };

  const freeCameraKeys = [
    LEFT_KEY,
    RIGHT_KEY,
    UP_KEY,
    DOWN_KEY,
    W_KEY,
    S_KEY,
    A_KEY,
    D_KEY,
    Q_KEY,
    E_KEY,
  ];
  const shouldSwitchToFreeCamera = (inputManager: gdjs.InputManager) =>
    !isControlOrCmdPressed(inputManager) &&
    !isAltPressed(inputManager) &&
    !isShiftPressed(inputManager) &&
    freeCameraKeys.some((key) => inputManager.isKeyPressed(key));

  const snap = (value: float, size: float, offset: float) =>
    size ? offset + size * Math.round((value - offset) / size) : value;

  class Selection {
    private _selectedObjects: Array<gdjs.RuntimeObject> = [];

    add(object: gdjs.RuntimeObject) {
      if (!this._selectedObjects.includes(object)) {
        this._selectedObjects.push(object);
      }
    }

    addAll(objects: RuntimeObject[]) {
      for (const object of objects) {
        this.add(object);
      }
    }

    clear() {
      this._selectedObjects = [];
    }

    toggle(object: gdjs.RuntimeObject) {
      const index = this._selectedObjects.indexOf(object);
      if (index < 0) {
        this._selectedObjects.push(object);
      } else {
        this._selectedObjects.splice(index, 1);
      }
    }

    getSelectedObjects() {
      return this._selectedObjects;
    }

    getLastSelectedObject(options?: {
      ignoreIf: (object: gdjs.RuntimeObject) => boolean;
    }): gdjs.RuntimeObject | null {
      if (options && options.ignoreIf) {
        for (let i = this._selectedObjects.length - 1; i >= 0; i--) {
          const object = this._selectedObjects[i];
          if (!options.ignoreIf(object)) {
            return object;
          }
        }
        return null;
      }

      return this._selectedObjects[this._selectedObjects.length - 1] || null;
    }

    getAABB(): AABB3D | null {
      let aabb: AABB3D | null = null;
      for (const object of this._selectedObjects) {
        if (is3D(object)) {
          const aabb2D = object.getAABB();
          const minZ = object.getUnrotatedAABBMinZ();
          const maxZ = object.getUnrotatedAABBMaxZ();
          if (aabb) {
            aabb.min[0] = Math.min(aabb.min[0], aabb2D.min[0]);
            aabb.min[1] = Math.min(aabb.min[1], aabb2D.min[1]);
            aabb.min[2] = Math.min(aabb.min[2], minZ);
            aabb.max[0] = Math.max(aabb.max[0], aabb2D.max[0]);
            aabb.max[1] = Math.max(aabb.max[1], aabb2D.max[1]);
            aabb.max[2] = Math.max(aabb.max[2], maxZ);
          } else {
            aabb = {
              min: [aabb2D.min[0], aabb2D.min[1], minZ],
              max: [aabb2D.max[0], aabb2D.max[1], maxZ],
            };
          }
        }
      }
      return aabb;
    }
  }

  class ObjectMover {
    editor: InGameEditor;
    _changeHappened = false;

    constructor(editor: InGameEditor) {
      this.editor = editor;
    }

    _objectInitialPositions: Map<
      gdjs.RuntimeObject,
      {
        x: float;
        y: float;
        z: float;
        rotationX: float;
        rotationY: float;
        angle: float;
        width: float;
        height: float;
        depth: float;
      }
    > = new Map();

    startMove() {
      this._changeHappened = false;
      this._objectInitialPositions.clear();
    }

    endMove(): boolean {
      const changeHappened = this._changeHappened;
      this._objectInitialPositions.clear();
      this._changeHappened = false;
      return changeHappened;
    }

    move(
      selectedObjects: Array<gdjs.RuntimeObject>,
      movement: {
        translationX: float;
        translationY: float;
        translationZ: float;
        rotationX: float;
        rotationY: float;
        rotationZ: float;
        scaleX: float;
        scaleY: float;
        scaleZ: float;
      }
    ) {
      selectedObjects.forEach((object) => {
        if (this.editor.isInstanceLocked(object)) {
          return;
        }

        this._changeHappened =
          this._changeHappened ||
          movement.translationX !== 0 ||
          movement.translationY !== 0 ||
          movement.translationZ !== 0 ||
          movement.rotationX !== 0 ||
          movement.rotationY !== 0 ||
          movement.rotationZ !== 0 ||
          movement.scaleX !== 1 ||
          movement.scaleY !== 1 ||
          movement.scaleZ !== 1;

        let initialPosition = this._objectInitialPositions.get(object);
        if (!initialPosition) {
          initialPosition = is3D(object)
            ? {
                x: object.getX(),
                y: object.getY(),
                z: object.getZ(),
                rotationX: object.getRotationX(),
                rotationY: object.getRotationY(),
                angle: object.getAngle(),
                width: object.getWidth(),
                height: object.getHeight(),
                depth: object.getDepth(),
              }
            : {
                x: object.getX(),
                y: object.getY(),
                z: 0,
                rotationX: 0,
                rotationY: 0,
                angle: object.getAngle(),
                width: object.getWidth(),
                height: object.getHeight(),
                depth: 0,
              };
          this._objectInitialPositions.set(object, initialPosition);
        }
        // TODO Remove this when we handle 2D instances controls
        if (!is3D(object)) {
          return;
        }
        object.setX(Math.round(initialPosition.x + movement.translationX));
        object.setY(Math.round(initialPosition.y + movement.translationY));
        object.setAngle(
          gdjs.evtTools.common.mod(
            Math.round(initialPosition.angle + movement.rotationZ),
            360
          )
        );
        if (movement.scaleX !== 1) {
          object.setWidth(
            Math.round(initialPosition.width * Math.abs(movement.scaleX))
          );
        }
        if (movement.scaleY !== 1) {
          object.setHeight(
            Math.round(initialPosition.height * Math.abs(movement.scaleY))
          );
        }
        if (is3D(object)) {
          object.setZ(Math.round(initialPosition.z + movement.translationZ));
          object.setRotationX(
            gdjs.evtTools.common.mod(
              Math.round(initialPosition.rotationX + movement.rotationX),
              360
            )
          );
          object.setRotationY(
            gdjs.evtTools.common.mod(
              Math.round(initialPosition.rotationY + movement.rotationY),
              360
            )
          );
          if (movement.scaleZ !== 1) {
            object.setDepth(
              Math.round(initialPosition.depth * Math.abs(movement.scaleZ))
            );
          }
        }
      });
    }
  }

  const getCameraForwardVector = (threeCamera: THREE.Camera) => {
    // Make sure camera's matrixWorld is up-to-date (usually is, but good practice).
    threeCamera.updateMatrixWorld();

    // threeCamera.matrixWorld is a 4x4. In Three.js, the columns correspond to:
    //   [ right.x,   up.x,    forwardNeg.x,  pos.x
    //     right.y,   up.y,    forwardNeg.y,  pos.y
    //     right.z,   up.z,    forwardNeg.z,  pos.z
    //     0,         0,       0,             1     ]
    //
    // By default, a Three.js camera looks down the -Z axis, so the "forward" axis
    // in the matrix is actually the negative Z column. We'll call it "forward" below.
    const elements = threeCamera.matrixWorld.elements;

    // Local forward axis in world space (note we take the negative of that column).
    const forward = new THREE.Vector3(-elements[8], elements[9], -elements[10]);

    // Normalize it, just in case (they should generally be unit vectors).
    forward.normalize();

    return { forward };
  };

  /** @category In-Game Editor */
  export class InGameEditor {
    private _editorId: string = '';
    private _runtimeGame: RuntimeGame;
    private _currentScene: gdjs.RuntimeScene | null = null;
    private _editedInstanceContainer: gdjs.RuntimeInstanceContainer | null =
      null;
    private _editedInstanceDataList: InstanceData[] = [];
    private _editedLayerDataList: LayerData[] = [];
    private _selectedLayerName: string = '';
    private _innerArea: AABB3D | null = null;
    private _threeInnerArea: THREE.Object3D | null = null;
    private _unregisterContextLostListener: (() => void) | null = null;
    private _tempVector2d: THREE.Vector2 = new THREE.Vector2();
    private _raycaster: THREE.Raycaster = new THREE.Raycaster();

    private _isVisible = true;
    private _timeSinceLastInteraction = 0;
    private _isFirstFrame = true;

    private _editorCamera;

    /** Keep track of the focus to know if the game was blurred since the last frame. */
    private _windowHadFocus = true;

    // The controls shown to manipulate the selection.
    private _selectionControls: {
      object: gdjs.RuntimeObject;
      dummyThreeObject: THREE.Object3D;
      threeTransformControls: THREE_ADDONS.TransformControls;
    } | null = null;
    private _transformControlsMode: 'translate' | 'rotate' | 'scale' =
      'translate';
    private _editorGrid: EditorGrid;
    private _selectionControlsMovementTotalDelta: {
      translationX: float;
      translationY: float;
      translationZ: float;
      rotationX: float;
      rotationY: float;
      rotationZ: float;
      scaleX: float;
      scaleY: float;
      scaleZ: float;
    } | null = null;
    private _hasSelectionActuallyMoved = false;
    private _isTransformControlsHovered = false;
    private _wasMovingSelectionLastFrame = false;

    private _selectionBox: THREE_ADDONS.SelectionBox | null = null;
    private _selectionBoxElement: HTMLDivElement;
    private _selectionBoxStartCursorX: float = 0;
    private _selectionBoxStartCursorY: float = 0;

    // The selected objects.
    private _selection = new Selection();
    private _selectionBoxes: Map<RuntimeObject, ObjectSelectionBoxHelper> =
      new Map();
    private _objectMover = new ObjectMover(this);

    private _wasMouseLeftButtonPressed = false;
    private _wasMouseRightButtonPressed = false;
    private _wasMouseMiddleButtonPressed = false;
    private _pressedOriginalCursorX: float = 0;
    private _pressedOriginalCursorY: float = 0;
    private _previousCursorX: float = 0;
    private _previousCursorY: float = 0;
    private _pressedRightButtonTime: number = 0;
    private _pressedMiddleButtonTime: number = 0;

    private _lastClickOnObjectUnderCursor: {
      object: gdjs.RuntimeObject | null;
      time: number;
    } = {
      object: null,
      time: 0,
    };

    // Dragged new object:
    private _draggedNewObject: gdjs.RuntimeObject | null = null;
    private _draggedSelectedObject: gdjs.RuntimeObject | null = null;
    private _draggedSelectedObjectInitialX: float = 0;
    private _draggedSelectedObjectInitialY: float = 0;
    private _draggedSelectedObjectInitialZ: float = 0;
    private _draggedSelectedObjectTotalDelta: {
      translationX: float;
      translationY: float;
      translationZ: float;
      rotationX: float;
      rotationY: float;
      rotationZ: float;
      scaleX: float;
      scaleY: float;
      scaleZ: float;
    } = {
      translationX: 0,
      translationY: 0,
      translationZ: 0,
      rotationX: 0,
      rotationY: 0,
      rotationZ: 0,
      scaleX: 1,
      scaleY: 1,
      scaleZ: 1,
    };
    private _instancesEditorSettings: InstancesEditorSettings | null = null;
    private _toolbar: Toolbar;
    private _inGameEditorSettings: InGameEditorSettings;

    constructor(
      game: RuntimeGame,
      projectData: ProjectData,
      inGameEditorSettings: InGameEditorSettings | null
    ) {
      this._runtimeGame = game;
      this._editorCamera = new EditorCamera(this);
      this._editorGrid = new EditorGrid(this);

      this._selectionBoxElement = document.createElement('div');
      this._selectionBoxElement.style.position = 'fixed';
      this._selectionBoxElement.style.backgroundColor = '#f2a63c44';
      this._selectionBoxElement.style.border = '1px solid #f2a63c';

      this._inGameEditorSettings =
        inGameEditorSettings || defaultInGameEditorSettings;

      this._toolbar = new Toolbar({
        getTransformControlsMode: () => this._getTransformControlsMode(),
        setTransformControlsMode: (mode: 'translate' | 'rotate' | 'scale') =>
          this._setTransformControlsMode(mode),
        focusOnSelection: () => this._focusOnSelection(),
        switchToFreeCamera: () => this._getEditorCamera().switchToFreeCamera(),
        switchToOrbitCamera: () =>
          this._getEditorCamera().switchToOrbitAroundZ0(4000),
        isFreeCamera: () => this._getEditorCamera().isFreeCamera(),
        getSvgIconUrl: (iconName: string) => getSvgIconUrl(game, iconName),
        hasSelectionControlsShown: () => !!this._selectionControls,
      });

      this._applyInGameEditorSettings();
      this.onProjectDataChange(projectData);

      // Uncomment to get access to the runtime game from the console and do
      // testing.
      // window.globalRuntimeGameForTesting = game;
    }

    private _setupWebGLContextLostListener(): void {
      const canvas = this._runtimeGame.getRenderer().getCanvas();
      if (!canvas) return;

      const handleContextLost = (event: Event) => {
        console.warn('WebGL context lost, notifying the editor...');

        // Prevent to restore the context, and prefer to let the editor handle this
        // to restart from a clean state.
        event.preventDefault();

        const debuggerClient = this._runtimeGame._debuggerClient;
        if (debuggerClient) {
          debuggerClient.sendGraphicsContextLost();
        }
      };

      canvas.addEventListener('webglcontextlost', handleContextLost);
      canvas.addEventListener('contextlost', handleContextLost);
      this._unregisterContextLostListener = () => {
        canvas.removeEventListener('webglcontextlost', handleContextLost);
        canvas.removeEventListener('contextlost', handleContextLost);
      };
    }

    dispose(): void {
      if (this._unregisterContextLostListener) {
        this._unregisterContextLostListener();
        this._unregisterContextLostListener = null;
      }
    }

    private _applyInGameEditorSettings() {
      if (typeof document === 'undefined') return;

      const rootElement = document.documentElement;
      if (!rootElement) return;

      rootElement.style.setProperty(
        '--in-game-editor-theme-icon-button-selected-background-color',
        this._inGameEditorSettings.theme.iconButtonSelectedBackgroundColor
      );
      rootElement.style.setProperty(
        '--in-game-editor-theme-icon-button-selected-color',
        this._inGameEditorSettings.theme.iconButtonSelectedColor
      );
      rootElement.style.setProperty(
        '--in-game-editor-theme-toolbar-background-color',
        this._inGameEditorSettings.theme.toolbarBackgroundColor
      );
      rootElement.style.setProperty(
        '--in-game-editor-theme-toolbar-separator-color',
        this._inGameEditorSettings.theme.toolbarSeparatorColor
      );
      rootElement.style.setProperty(
        '--in-game-editor-theme-text-color-primary',
        this._inGameEditorSettings.theme.textColorPrimary
      );
    }

    setInGameEditorSettings(inGameEditorSettings: InGameEditorSettings) {
      this._inGameEditorSettings = {
        ...this._inGameEditorSettings,
        ...inGameEditorSettings,
      };
      this._applyInGameEditorSettings();
    }

    getRuntimeGame() {
      return this._runtimeGame;
    }

    onProjectDataChange(projectData: ProjectData): void {
      this.setEffectsHiddenInEditor(
        !!projectData.properties.areEffectsHiddenInEditor
      );
    }

    onLayersDataChange(
      layersData: Array<LayerData>,
      areEffectsHiddenInEditor: boolean
    ): void {
      for (const layerData of layersData) {
        // Camera controls don't work in orthographic.
        if (layerData.cameraType === 'orthographic') {
          layerData.cameraType = 'perspective';
        }
        layerData.camera3DFieldOfView = editorCameraFov;
        // Force 2D and 3D objects to be visible on any layer.
        layerData.renderingType = '2d+3d';
        if (areEffectsHiddenInEditor) {
          if (layerData.effects !== defaultEffectsData) {
            layerData._hiddenEffects = layerData.effects;
            layerData.effects = defaultEffectsData;
          }
        } else {
          if (layerData._hiddenEffects) {
            layerData.effects = layerData._hiddenEffects;
          }
        }
      }
    }

    /**
     * Modify the layer data accordingly.
     * `gdjs.HotReloader.hotReloadRuntimeSceneLayers` must be run for the
     * changes to be applied.
     */
    setEffectsHiddenInEditor(areEffectsHiddenInEditor: boolean) {
      const projectData = this._runtimeGame.getGameData();
      projectData.properties.areEffectsHiddenInEditor =
        areEffectsHiddenInEditor;
      for (const layoutData of projectData.layouts) {
        this.onLayersDataChange(layoutData.layers, areEffectsHiddenInEditor);
      }
    }

    areEffectsHidden(): boolean {
      return !!this._runtimeGame.getGameData().properties
        .areEffectsHiddenInEditor;
    }

    getEditorId(): string {
      return this._editorId;
    }

    getEditedInstanceDataList(): InstanceData[] {
      return this._editedInstanceDataList;
    }

    getEditedLayerDataList(): LayerData[] {
      return this._editedLayerDataList;
    }

    getEditedInstanceContainer(): gdjs.RuntimeInstanceContainer | null {
      return this._editedInstanceContainer;
    }

    getCurrentScene(): gdjs.RuntimeScene | null {
      return this._currentScene;
    }

    /**
     * Return the layer to be used for camera calculus.
     * @see getEditorLayer
     */
    private getCameraLayer(layerName: string): gdjs.RuntimeLayer | null {
      // When the edited container is a custom object,
      // only a base layer exists and `getLayer` falls back on it.
      return this._currentScene ? this._currentScene.getLayer(layerName) : null;
    }

    /**
     * Return the layer which contains the objects.
     * @see getCameraLayer
     */
    private getEditorLayer(layerName: string): gdjs.RuntimeLayer | null {
      return this._editedInstanceContainer
        ? this._editedInstanceContainer.getLayer(layerName)
        : null;
    }

    /**
     * Called by the RuntimeGame when the game resolution is changed.
     * Useful to notify scene and layers that resolution is changed, as they
     * might be caching it.
     */
    onGameResolutionResized() {
      if (!this._currentScene) {
        return;
      }
      this._currentScene.onGameResolutionResized();
    }

    async switchToSceneOrVariant(
      editorId: string | null,
      sceneName: string | null,
      externalLayoutName: string | null,
      eventsBasedObjectType: string | null,
      eventsBasedObjectVariantName: string | null,
      editorCamera3D: EditorCameraState | null
    ) {
      if (this._currentScene) {
        this._currentScene.unloadScene();
        this._currentScene = null;
      }
      // The 3D scene is rebuilt and the inner area marker is lost in the process.
      this._threeInnerArea = null;
      this._innerArea = null;
      this._selectedLayerName = '';
      // Clear any reference to `RuntimeObject` from the unloaded scene.
      this._selectionBoxes.clear();
      this._selectionControls = null;
      this._draggedNewObject = null;
      this._draggedSelectedObject = null;
      const selectedObjectIds = this._selection
        .getSelectedObjects()
        .map((object) => object.persistentUuid)
        .filter(Boolean) as Array<string>;

      let editedLayerDataList: Array<LayerData> = [];
      let editedInstanceDataList: Array<InstanceData> = [];
      if (eventsBasedObjectType) {
        const eventsBasedObjectVariantData =
          this._runtimeGame.getEventsBasedObjectVariantData(
            eventsBasedObjectType,
            eventsBasedObjectVariantName || ''
          );
        if (eventsBasedObjectVariantData) {
          editedLayerDataList = eventsBasedObjectVariantData.layers;
          editedInstanceDataList = eventsBasedObjectVariantData.instances;
          await this._runtimeGame._resourcesLoader.loadResources(
            eventsBasedObjectVariantData.usedResources.map(
              (resource) => resource.name
            ),
            () => {}
          );
          const sceneAndCustomObject = this._createSceneWithCustomObject(
            eventsBasedObjectType,
            eventsBasedObjectVariantName || ''
          );
          if (sceneAndCustomObject) {
            const { scene, customObjectInstanceContainer } =
              sceneAndCustomObject;
            this._currentScene = scene;
            this._editedInstanceContainer = customObjectInstanceContainer;
          }
          this.setInstancesEditorSettings(
            eventsBasedObjectVariantData.editionSettings
          );
          this._innerArea = eventsBasedObjectVariantData._initialInnerArea;
        } else {
          console.warn(
            `Couldn't find any variant named "${eventsBasedObjectVariantName || ''}" for ${eventsBasedObjectType}`
          );
        }
      } else if (sceneName) {
        await this._runtimeGame.loadFirstAssetsAndStartBackgroundLoading(
          sceneName,
          () => {}
        );
        // Load the new one
        const sceneAndExtensionsData =
          this._runtimeGame.getSceneAndExtensionsData(sceneName);
        const newScene = new gdjs.RuntimeScene(this._runtimeGame);
        newScene.loadFromScene(sceneAndExtensionsData, {
          skipCreatingInstances: !!externalLayoutName,
        });

        // Optionally create the objects from an external layout.
        if (externalLayoutName) {
          const externalLayoutData =
            this._runtimeGame.getExternalLayoutData(externalLayoutName);
          if (externalLayoutData) {
            newScene.createObjectsFrom(
              externalLayoutData.instances,
              0,
              0,
              0,
              /*trackByPersistentUuid=*/
              true
            );
            this.setInstancesEditorSettings(externalLayoutData.editionSettings);
          }
        } else {
          this.setInstancesEditorSettings(
            sceneAndExtensionsData!.sceneData.uiSettings
          );
        }
        this._currentScene = newScene;
        this._editedInstanceContainer = newScene;
        if (sceneAndExtensionsData) {
          editedLayerDataList = sceneAndExtensionsData.sceneData.layers;
        }
        if (externalLayoutName) {
          const externalLayoutData =
            this._runtimeGame.getExternalLayoutData(externalLayoutName);
          if (externalLayoutData) {
            editedInstanceDataList = externalLayoutData.instances;
          }
        } else {
          const sceneAndExtensionsData =
            this._runtimeGame.getSceneAndExtensionsData(sceneName);
          if (sceneAndExtensionsData) {
            editedInstanceDataList = sceneAndExtensionsData.sceneData.instances;
          }
        }
      } else {
        console.warn('eventsBasedObjectType or sceneName must be set.');
      }
      this._editedInstanceDataList = editedInstanceDataList;
      this._editedLayerDataList = editedLayerDataList;
      this._editorId = editorId || '';
      if (editorCamera3D) {
        this.restoreCameraState(editorCamera3D);
      } else {
        // TODO Get the visibleScreenArea from the editor.
        this.zoomToInitialPosition({
          minX: 0.15,
          minY: 0.15,
          maxX: 0.85,
          maxY: 0.85,
        });
      }

      // Update initialRuntimeGameStatus so that a hard reload
      // will come back to the same state, and so that we can check later
      // if the game is already on the state that is being requested.
      this._runtimeGame.getAdditionalOptions().initialRuntimeGameStatus = {
        isPaused: this._runtimeGame.isPaused(),
        isInGameEdition: this._runtimeGame.isInGameEdition(),
        sceneName: sceneName,
        injectedExternalLayoutName: externalLayoutName,
        skipCreatingInstancesFromScene: !!externalLayoutName,
        eventsBasedObjectType,
        eventsBasedObjectVariantName,
        editorId,
      };

      // Try to keep object selection in case the same scene is reloaded.
      this.setSelectedObjects(selectedObjectIds);
      this._isFirstFrame = true;
    }

    private _createSceneWithCustomObject(
      eventsBasedObjectType: string,
      eventsBasedObjectVariantName: string
    ): {
      scene: gdjs.RuntimeScene;
      customObjectInstanceContainer: gdjs.CustomRuntimeObjectInstanceContainer;
    } | null {
      const eventsBasedObjectData = this._runtimeGame.getEventsBasedObjectData(
        eventsBasedObjectType
      );
      if (!eventsBasedObjectData) {
        logger.error(
          `A CustomRuntimeObject was open in editor referring to an non existing events based object data with type "${eventsBasedObjectType}".`
        );
        return null;
      }

      const scene = new gdjs.RuntimeScene(this._runtimeGame);
      scene.loadFromScene({
        sceneData: {
          variables: [],
          instances: [
            {
              angle: 0,
              customSize: false,
              height: 0,
              layer: '',
              name: 'Object',
              persistentUuid: '12345678-1234-1234-1234-123456789abc',
              width: 0,
              x: 0,
              y: 0,
              zOrder: 1,
              numberProperties: [],
              stringProperties: [],
              initialVariables: [],
              locked: false,
            },
          ],
          objects: [
            {
              name: 'Object',
              type: eventsBasedObjectType,
              //@ts-ignore
              variant: eventsBasedObjectVariantName,
              content: {},
              variables: [],
              // Add all capabilities just in case events need them.
              behaviors: [
                {
                  name: 'Animation',
                  type: 'AnimatableCapability::AnimatableBehavior',
                },
                { name: 'Effect', type: 'EffectCapability::EffectBehavior' },
                {
                  name: 'Flippable',
                  type: 'FlippableCapability::FlippableBehavior',
                },
                {
                  name: 'Object3D',
                  type: 'Scene3D::Base3DBehavior',
                },
                {
                  name: 'Opacity',
                  type: 'OpacityCapability::OpacityBehavior',
                },
                {
                  name: 'Resizable',
                  type: 'ResizableCapability::ResizableBehavior',
                },
                {
                  name: 'Scale',
                  type: 'ScalableCapability::ScalableBehavior',
                },
                {
                  name: 'Text',
                  type: 'TextContainerCapability::TextContainerBehavior',
                },
              ],
              effects: [],
            },
          ],
          layers: [
            {
              ambientLightColorB: 200,
              ambientLightColorG: 200,
              ambientLightColorR: 200,
              camera3DFarPlaneDistance: 10000,
              camera3DFieldOfView: 45,
              camera3DNearPlaneDistance: 3,
              followBaseLayerCamera: false,
              isLightingLayer: false,
              name: '',
              renderingType: '2d+3d',
              visibility: true,
              cameras: [
                {
                  defaultSize: true,
                  defaultViewport: true,
                  height: 0,
                  viewportBottom: 1,
                  viewportLeft: 0,
                  viewportRight: 1,
                  viewportTop: 0,
                  width: 0,
                },
              ],
              effects: [
                {
                  effectType: 'Scene3D::HemisphereLight',
                  name: '3D Light',
                  doubleParameters: {
                    elevation: 45,
                    intensity: 1,
                    rotation: 0,
                  },
                  stringParameters: {
                    groundColor: '64;64;64',
                    skyColor: '255;255;255',
                    top: 'Y-',
                  },
                  booleanParameters: {},
                },
              ],
            },
          ],
          r: 32,
          v: 32,
          b: 32,
          mangledName: 'FakeSceneForCustomObject',
          name: eventsBasedObjectData.name,
          stopSoundsOnStartup: true,
          title: '',
          behaviorsSharedData: [
            {
              name: 'Text',
              type: 'TextContainerCapability::TextContainerBehavior',
            },
          ],
          usedResources: [],
        },
        usedExtensionsWithVariablesData:
          this._runtimeGame.getGameData().eventsFunctionsExtensions,
      });
      const objects = scene.getObjects('Object');
      const object = objects ? objects[0] : null;
      if (!object) {
        return null;
      }
      const customObject = object as gdjs.CustomRuntimeObject;
      if (!customObject._instanceContainer) {
        return null;
      }
      return {
        scene,
        customObjectInstanceContainer: customObject._instanceContainer,
      };
    }

    updateInnerArea(
      areaMinX: float,
      areaMinY: float,
      areaMinZ: float,
      areaMaxX: float,
      areaMaxY: float,
      areaMaxZ: float
    ) {
      if (!this._innerArea) {
        return;
      }
      // This only works because `this._innerArea` is the same instance as the
      // one used by custom object instances.
      this._innerArea.min[0] = areaMinX;
      this._innerArea.min[1] = areaMinY;
      this._innerArea.min[2] = areaMinZ;
      this._innerArea.max[0] = areaMaxX;
      this._innerArea.max[1] = areaMaxY;
      this._innerArea.max[2] = areaMaxZ;
    }

    setSelectedLayerName(layerName: string): void {
      this._selectedLayerName = layerName;
    }

    setInstancesEditorSettings(
      instancesEditorSettings: InstancesEditorSettings
    ) {
      this._instancesEditorSettings = instancesEditorSettings;
      this._editorGrid.setSettings(instancesEditorSettings);
    }

    updateInstancesEditorSettings(
      instancesEditorSettings: InstancesEditorSettings
    ) {
      if (this._instancesEditorSettings) {
        Object.assign(this._instancesEditorSettings, instancesEditorSettings);
      } else {
        this._instancesEditorSettings = instancesEditorSettings;
      }
      this._editorGrid.setSettings(instancesEditorSettings);
    }

    private _getTempVector2d(x: float, y: float): THREE.Vector2 {
      this._tempVector2d.x = x;
      this._tempVector2d.y = y;
      return this._tempVector2d;
    }

    zoomToInitialPosition(visibleScreenArea: {
      minX: number;
      minY: number;
      maxX: number;
      maxY: number;
    }) {
      if (this._innerArea) {
        this.zoomToFitArea(
          {
            minX: this._innerArea.min[0],
            minY: this._innerArea.min[1],
            minZ: this._innerArea.min[2],
            maxX: this._innerArea.max[0],
            maxY: this._innerArea.max[1],
            maxZ: this._innerArea.max[2],
          },
          visibleScreenArea,
          0.1
        );
      } else {
        this.zoomToFitArea(
          {
            minX: 0,
            minY: 0,
            minZ: 0,
            maxX: this._runtimeGame.getOriginalWidth(),
            maxY: this._runtimeGame.getOriginalHeight(),
            maxZ: 0,
          },
          visibleScreenArea,
          0.1
        );
      }
    }

    zoomToFitContent(visibleScreenArea: {
      minX: number;
      minY: number;
      maxX: number;
      maxY: number;
    }) {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      this.zoomToFitObjects(
        editedInstanceContainer.getAdhocListOfAllInstances(),
        visibleScreenArea,
        0.01
      );
    }

    zoomToFitSelection(visibleScreenArea: {
      minX: number;
      minY: number;
      maxX: number;
      maxY: number;
    }) {
      this.zoomToFitObjects(
        this._selection.getSelectedObjects(),
        visibleScreenArea,
        0.2
      );
    }

    zoomToFitObjects(
      objects: Array<RuntimeObject>,
      visibleScreenArea: {
        minX: number;
        minY: number;
        maxX: number;
        maxY: number;
      },
      margin: float
    ) {
      this._getEditorCamera().zoomToFitObjects(
        objects,
        visibleScreenArea,
        margin
      );
    }

    zoomToFitArea(
      sceneArea: {
        minX: number;
        minY: number;
        minZ: number;
        maxX: number;
        maxY: number;
        maxZ: number;
      },
      visibleScreenArea: {
        minX: number;
        minY: number;
        maxX: number;
        maxY: number;
      },
      margin: float
    ) {
      if (!this._currentScene) return;
      this._getEditorCamera().zoomToFitArea(
        sceneArea,
        visibleScreenArea,
        margin
      );
      this._getEditorCamera().switchToFreeCamera();
    }

    zoomBy(zoomFactor: float) {
      if (!this._currentScene) return;
      this._getEditorCamera().zoomBy(zoomFactor);
    }

    setZoom(zoom: float) {
      if (!this._currentScene) return;
      this._getEditorCamera().setZoom(zoom);
    }

    getSelectionAABB(): AABB3D | null {
      return this._selection.getAABB();
    }

    setSelectedObjects(persistentUuids: Array<string>) {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      const persistentUuidsSet = new Set<string>(persistentUuids);
      const selectedObjectsMap = new Map<string, gdjs.RuntimeObject>();
      for (const object of editedInstanceContainer.getAdhocListOfAllInstances()) {
        if (
          object.persistentUuid &&
          persistentUuidsSet.has(object.persistentUuid)
        ) {
          // We can't add the object to the selection directly because they
          // would be out of order.
          selectedObjectsMap.set(object.persistentUuid, object);
        }
      }
      this._selection.clear();
      for (const instanceUuid of persistentUuids) {
        const object = selectedObjectsMap.get(instanceUuid);
        if (object) {
          this._selection.add(object);
        }
      }
      // Send back default instances sizes.
      this._sendSelectionUpdate({ isSendingBackSelectionForDefaultSize: true });
    }

    centerViewOnLastSelectedInstance() {
      if (!this._currentScene) return;

      const object = this._selection.getLastSelectedObject();
      if (!object) {
        return;
      }

      this._getEditorCamera().switchToOrbitAroundObject(object);
    }

    private _focusOnSelection() {
      // TODO Use the center of the AABB of the whole selection instead
      const selectedObject = this._selection.getLastSelectedObject();
      if (!selectedObject) {
        return;
      }
      this._getEditorCamera().switchToOrbitAroundObject(selectedObject);
    }

    private _handleCameraMovement() {
      const inputManager = this._runtimeGame.getInputManager();
      const currentScene = this._currentScene;
      if (!currentScene) return;

      const selectedObject = this._selection.getLastSelectedObject();
      if (inputManager.isKeyPressed(F_KEY) && selectedObject) {
        this._focusOnSelection();
      }

      if (
        !this._getEditorCamera().isFreeCamera() &&
        shouldSwitchToFreeCamera(inputManager)
      ) {
        this._getEditorCamera().switchToFreeCamera();
      }

      this._getEditorCamera().step();

      const layerNames = [];
      currentScene.getAllLayerNames(layerNames);
      layerNames.forEach((layerName) => {
        const layer = currentScene.getLayer(layerName);

        this._getEditorCamera().updateCamera(currentScene, layer);
      });
    }

    moveSelectionUnderCursor() {
      if (!this._currentScene) return;

      const cursor = this._getCursorIn3D(this._selection.getSelectedObjects());
      if (!cursor) {
        return;
      }
      const [cursorX, cursorY, cursorZ] = cursor;

      let minX = Number.MAX_VALUE;
      let minY = Number.MAX_VALUE;
      let minZ = Number.MAX_VALUE;
      let maxX = Number.MIN_VALUE;
      let maxY = Number.MIN_VALUE;
      for (const object of this._selection.getSelectedObjects()) {
        minX = Math.min(minX, object.getAABBLeft());
        minY = Math.min(minY, object.getAABBTop());
        if (is3D(object)) {
          minZ = Math.min(minZ, object.getUnrotatedAABBMinZ());
        }
        maxX = Math.max(maxX, object.getAABBRight());
        maxY = Math.max(maxY, object.getAABBBottom());
      }
      const deltaX = cursorX - (maxX + minX) / 2;
      const deltaY = cursorY - (maxY + minY) / 2;
      const deltaZ = cursorZ - minZ;
      for (const object of this._selection.getSelectedObjects()) {
        object.setX(object.getX() + deltaX);
        object.setY(object.getY() + deltaY);
        if (is3D(object)) {
          object.setZ(object.getZ() + deltaZ);
        }
      }
      this._sendSelectionUpdate({ hasSelectedObjectBeenModified: true });
    }

    private _shouldDragSelectedObject(): boolean {
      const inputManager = this._runtimeGame.getInputManager();
      return (
        isControlOrCmdPressed(inputManager) &&
        (!this._selectionControls ||
          !this._selectionControls.threeTransformControls.dragging)
      );
    }

    private _handleSelectedObjectDragging(): void {
      const inputManager = this._runtimeGame.getInputManager();

      // Always check first if we should end an existing drag.
      if (
        this._draggedSelectedObject &&
        (inputManager.isMouseButtonReleased(0) ||
          !this._shouldDragSelectedObject())
      ) {
        this._draggedSelectedObject = null;
        const changeHappened = this._objectMover.endMove();
        this._sendSelectionUpdate({
          hasSelectedObjectBeenModified: changeHappened,
        });
      }

      // Inspect then if a drag should be started or continued.
      if (!this._shouldDragSelectedObject()) {
        // We can early return as the rest is not applicable (we've already checked
        // if a drag should be ended).
        return;
      }
      if (!this._currentScene) return;
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      if (
        inputManager.isMouseButtonPressed(0) &&
        !this._draggedSelectedObject
      ) {
        // Start a new drag.
        let object = this.getObjectUnderCursor();
        if (object && this._selection.getSelectedObjects().includes(object)) {
          if (isControlOrCmdPressed(inputManager)) {
            object = this._duplicateSelectedObjects(object);
            if (!object) {
              return;
            }
          }
          this._draggedSelectedObject = object;
          this._draggedSelectedObjectInitialX = object.getX();
          this._draggedSelectedObjectInitialY = object.getY();
          this._draggedSelectedObjectInitialZ = is3D(object)
            ? object.getZ()
            : 0;
          this._objectMover.startMove();
        }
      }

      // Continue an existing drag.
      if (!this._draggedSelectedObject) {
        return;
      }

      let isIntersectionFound = false;
      let intersectionX: float = 0;
      let intersectionY: float = 0;
      let intersectionZ: float = 0;
      if (is3D(this._draggedSelectedObject)) {
        const cursor = this._getCursorIn3D(
          this._selection.getSelectedObjects()
        );
        if (cursor) {
          isIntersectionFound = true;
          [intersectionX, intersectionY, intersectionZ] = cursor;
        }
      } else {
        const projectedCursor = this._getProjectedCursor();
        if (projectedCursor) {
          isIntersectionFound = true;
          [intersectionX, intersectionY] = projectedCursor;
        }
      }
      if (isIntersectionFound) {
        this._editorGrid.setNormal('Z');
        this._editorGrid.setPosition(
          intersectionX,
          intersectionY,
          intersectionZ
        );
        const cameraLayer = this.getCameraLayer(
          this._draggedSelectedObject.getLayer()
        );
        const threeScene = cameraLayer
          ? cameraLayer.getRenderer().getThreeScene()
          : null;
        if (threeScene) {
          this._editorGrid.setTreeScene(threeScene);
        }
        this._editorGrid.setVisible(true);
        if (this._editorGrid.isSpanningEnabled(inputManager)) {
          intersectionX = this._editorGrid.getSnappedX(intersectionX);
          intersectionY = this._editorGrid.getSnappedY(intersectionY);
        }
        this._draggedSelectedObjectTotalDelta.translationX =
          intersectionX - this._draggedSelectedObjectInitialX;
        this._draggedSelectedObjectTotalDelta.translationY =
          intersectionY - this._draggedSelectedObjectInitialY;
        this._draggedSelectedObjectTotalDelta.translationZ =
          intersectionZ - this._draggedSelectedObjectInitialZ;
      } else {
        this._draggedSelectedObjectTotalDelta.translationX = 0;
        this._draggedSelectedObjectTotalDelta.translationY = 0;
        this._draggedSelectedObjectTotalDelta.translationZ = 0;
      }
      this._objectMover.move(
        this._selection.getSelectedObjects(),
        this._draggedSelectedObjectTotalDelta
      );
    }

    private _duplicateSelectedObjects(
      objectUnderCursor: gdjs.RuntimeObject
    ): gdjs.RuntimeObject | null {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return null;
      let newObjectUnderCursor: gdjs.RuntimeObject | null = null;
      const addedObjects: Array<gdjs.RuntimeObject> = [];
      for (const selectedObject of this._selection.getSelectedObjects()) {
        const newObject = editedInstanceContainer.createObject(
          selectedObject.getName()
        );
        if (!newObject) return null;
        newObject.persistentUuid = gdjs.makeUuid();
        newObject.setLayer(selectedObject.getLayer());
        newObject.setX(selectedObject.getX());
        newObject.setY(selectedObject.getY());
        newObject.setAngle(selectedObject.getAngle());
        newObject.setWidth(selectedObject.getWidth());
        newObject.setHeight(selectedObject.getHeight());
        if (is3D(newObject) && is3D(selectedObject)) {
          newObject.setZ(selectedObject.getZ());
          newObject.setRotationX(selectedObject.getRotationX());
          newObject.setRotationY(selectedObject.getRotationY());
          newObject.setDepth(selectedObject.getDepth());
        }
        addedObjects.push(newObject);
        if (selectedObject === objectUnderCursor) {
          newObjectUnderCursor = newObject;
        }
      }
      this._selection.clear();
      this._selection.addAll(addedObjects);
      this._sendSelectionUpdate({
        addedObjects,
      });
      return newObjectUnderCursor;
    }

    private _handleSelectionMovement() {
      // Finished moving the selection.
      if (
        this._wasMovingSelectionLastFrame &&
        !this._selectionControlsMovementTotalDelta
      ) {
        const changeHappened = this._objectMover.endMove();
        this._sendSelectionUpdate({
          hasSelectedObjectBeenModified: changeHappened,
        });
      }

      // Start moving the selection.
      if (
        !this._wasMovingSelectionLastFrame &&
        this._selectionControlsMovementTotalDelta
      ) {
        this._objectMover.startMove();
      }

      // Move the selection.
      if (this._selectionControlsMovementTotalDelta) {
        this._objectMover.move(
          this._selection.getSelectedObjects(),
          this._selectionControlsMovementTotalDelta
        );
      }
    }

    private _updateSelectionBox() {
      const inputManager = this._runtimeGame.getInputManager();
      const runtimeGame = this._runtimeGame;
      const threeRenderer = runtimeGame.getRenderer().getThreeRenderer();
      if (!threeRenderer) return;
      const currentScene = this._currentScene;
      if (!currentScene) return;
      const editedInstanceContainer = this._editedInstanceContainer;
      if (!editedInstanceContainer) return;
      const cameraLayer = this.getCameraLayer('');
      if (!cameraLayer) return;

      const runtimeLayerRender = cameraLayer.getRenderer();
      const threeCamera = runtimeLayerRender.getThreeCamera();
      const threeScene = runtimeLayerRender.getThreeScene();
      if (!threeCamera || !threeScene) return;

      const cursorX = inputManager.getCursorX();
      const cursorY = inputManager.getCursorY();

      const touchIds = getCurrentTouchIdentifiers(inputManager);
      const hasMultipleTouches = touchIds.length >= 2;

      if (
        inputManager.isMouseButtonPressed(0) &&
        !this._shouldDragSelectedObject() &&
        !isSpacePressed(inputManager) &&
        !hasMultipleTouches
      ) {
        if (this._wasMouseLeftButtonPressed && this._selectionBox) {
          this._selectionBox.endPoint.set(
            this._getNormalizedScreenX(cursorX),
            this._getNormalizedScreenY(cursorY),
            0.5
          );
          const minX = Math.min(this._selectionBoxStartCursorX, cursorX);
          const minY = Math.min(this._selectionBoxStartCursorY, cursorY);
          const maxX = Math.max(this._selectionBoxStartCursorX, cursorX);
          const maxY = Math.max(this._selectionBoxStartCursorY, cursorY);
          this._selectionBoxElement.style.left = minX + 'px';
          this._selectionBoxElement.style.top = minY + 'px';
          this._selectionBoxElement.style.width = maxX - minX + 'px';
          this._selectionBoxElement.style.height = maxY - minY + 'px';
        } else {
          this._selectionBox = new THREE_ADDONS.SelectionBox(
            threeCamera,
            threeScene
          );
          this._selectionBox.startPoint.set(
            this._getNormalizedScreenX(cursorX),
            this._getNormalizedScreenY(cursorY),
            0.5
          );
          const domElementContainer = runtimeGame
            .getRenderer()
            .getDomElementContainer();
          if (domElementContainer) {
            this._selectionBoxElement.style.left = cursorX + 'px';
            this._selectionBoxElement.style.top = cursorY + 'px';
            this._selectionBoxElement.style.width = '0px';
            this._selectionBoxElement.style.height = '0px';
            domElementContainer.appendChild(this._selectionBoxElement);
          }
          this._selectionBoxStartCursorX = cursorX;
          this._selectionBoxStartCursorY = cursorY;
        }
      }
      if (
        (inputManager.isMouseButtonReleased(0) ||
          this._hasSelectionActuallyMoved) &&
        this._selectionBox
      ) {
        if (
          !this._selectionBox.endPoint.equals(this._selectionBox.startPoint) &&
          !this._hasSelectionActuallyMoved &&
          !hasMultipleTouches
        ) {
          // Selection rectangle ended.

          const objects = new Set<gdjs.RuntimeObject>();
          for (const selectThreeObject of this._selectionBox.select()) {
            // TODO Select the object if all its meshes are inside the rectangle
            // instead of if any is.
            const object = this._getObject3D(selectThreeObject);
            if (object) {
              objects.add(object);
            }
          }
          if (!isShiftPressed(inputManager)) {
            this._selection.clear();
          }
          const layer = this.getEditorLayer(this._selectedLayerName);
          if (layer && layer.isVisible() && !layer._initialLayerData.isLocked) {
            for (const object of objects) {
              if (!this.isInstanceSealed(object)) {
                this._selection.add(object);
              }
            }
          }
          this._sendSelectionUpdate();
        } else {
          // Selection rectangle was discarded.
        }
        this._selectionBox = null;
        const domElementContainer = runtimeGame
          .getRenderer()
          .getDomElementContainer();
        if (domElementContainer) {
          domElementContainer.removeChild(this._selectionBoxElement);
        }
      }
    }

    private _handleSelection({
      objectUnderCursor,
    }: {
      objectUnderCursor: gdjs.RuntimeObject | null;
    }) {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      const inputManager = this._runtimeGame.getInputManager();

      if (shouldDeleteSelection(inputManager)) {
        const removedObjects = this._selection.getSelectedObjects();
        removedObjects.forEach((object) => {
          object.deleteFromScene();
        });
        this._selection.clear();
        this._sendSelectionUpdate({
          removedObjects,
        });
      }

      if (inputManager.wasKeyJustPressed(ESC_KEY)) {
        this._selection.clear();
        this._sendSelectionUpdate();
      }

      // Left click: select the object under the cursor.
      if (
        !this._isTransformControlsHovered &&
        inputManager.isMouseButtonReleased(0) &&
        this._hasCursorStayedStillWhilePressed({ toleranceRadius: 10 })
      ) {
        if (!isShiftPressed(inputManager)) {
          this._selection.clear();
        }
        let objectToEdit: gdjs.RuntimeObject | null = null;
        if (objectUnderCursor) {
          const layer = this.getEditorLayer(objectUnderCursor.getLayer());
          if (
            layer &&
            !layer._initialLayerData.isLocked &&
            !this.isInstanceSealed(objectUnderCursor)
          ) {
            this._selection.toggle(objectUnderCursor);
          }

          if (
            this._lastClickOnObjectUnderCursor.object === objectUnderCursor &&
            Date.now() - this._lastClickOnObjectUnderCursor.time <
              timeBetweenClicksForDoubleClick
          ) {
            // Double click on the same object: edit the object.
            objectToEdit = objectUnderCursor;

            this._lastClickOnObjectUnderCursor = {
              object: null,
              time: 0,
            };
          } else {
            this._lastClickOnObjectUnderCursor = {
              object: objectUnderCursor,
              time: Date.now(),
            };
          }
        }
        this._sendSelectionUpdate({
          objectToEdit,
        });
      }
    }

    private _updateSelectionOutline({
      objectUnderCursor,
    }: {
      objectUnderCursor: gdjs.RuntimeObject | null;
    }) {
      if (!this._currentScene) return;

      const selectedObjects = this._selection.getSelectedObjects();

      // Add/update boxes for selected objects
      selectedObjects.forEach((object) =>
        this._createBoundingBoxIfNeeded(object)
      );
      if (
        objectUnderCursor &&
        !this._selectionBoxes.has(objectUnderCursor) &&
        !this.isInstanceSealed(objectUnderCursor)
      ) {
        this._createBoundingBoxIfNeeded(objectUnderCursor);
      }

      // Remove boxes for deselected objects
      this._selectionBoxes.forEach((box, object) => {
        const isHovered =
          object === objectUnderCursor && !this._isTransformControlsHovered;
        const isInSelection = selectedObjects.includes(object);
        if (!isInSelection && !isHovered) {
          box.removeFromParent();
          this._selectionBoxes.delete(object);
        } else {
          const isLocked = this.isInstanceLocked(object);

          const color =
            instanceWireframeColor[
              (isLocked || !is3D(object) ? instanceStateFlag.locked : 0) |
                (isInSelection ? instanceStateFlag.selected : 0) |
                (isHovered ? instanceStateFlag.hovered : 0)
            ] || '#aaaaaa';

          box.setColor(color);
        }
      });

      this._selectionBoxes.forEach((box) => {
        box.update();
      });
    }

    private _createBoundingBoxIfNeeded(object: RuntimeObject): void {
      if (this._selectionBoxes.has(object)) {
        return;
      }
      const currentScene = this._currentScene;
      if (!currentScene) return;

      const objectLayer = this.getEditorLayer(object.getLayer());
      if (!objectLayer) return;

      const threeGroup = objectLayer.getRenderer().getThreeGroup();
      if (!threeGroup) return;

      const objectBoxHelper = new ObjectSelectionBoxHelper(object);
      threeGroup.add(objectBoxHelper.container);
      this._selectionBoxes.set(object, objectBoxHelper);
    }

    private _getTransformControlsMode(): 'translate' | 'rotate' | 'scale' {
      return this._transformControlsMode;
    }

    private _setTransformControlsMode(
      mode: 'translate' | 'rotate' | 'scale'
    ): void {
      this._transformControlsMode = mode;
      if (!this._selectionControls) {
        return;
      }
      const { threeTransformControls, dummyThreeObject } =
        this._selectionControls;
      threeTransformControls.mode = mode;

      const lastEditableSelectedObject = this._selection.getLastSelectedObject({
        ignoreIf: (object) =>
          this.isInstanceLocked(object) || this.isInstanceSealed(object),
      });
      if (!lastEditableSelectedObject) {
        return;
      }
      const threeObject = lastEditableSelectedObject.get3DRendererObject();
      if (!threeObject) {
        return;
      }
      dummyThreeObject.rotation.copy(threeObject.rotation);
      if (this._transformControlsMode === 'rotate') {
        dummyThreeObject.rotation.y = -dummyThreeObject.rotation.y;
        dummyThreeObject.rotation.z = -dummyThreeObject.rotation.z;
      }
    }

    private _forceUpdateSelectionControls() {
      if (this._selectionControls) {
        this._removeSelectionControls();
      }
      this._updateSelectionControls();
    }

    private _updateSelectionControls() {
      const inputManager = this._runtimeGame.getInputManager();
      const currentScene = this._currentScene;
      if (!currentScene) return;

      const touchIds = getCurrentTouchIdentifiers(inputManager);
      const hasMultipleTouches = touchIds.length >= 2;

      // Selection controls are shown on the last object that can be manipulated
      // (and if none, selection controls are not shown).
      const lastEditableSelectedObject = this._selection.getLastSelectedObject({
        ignoreIf: (object) =>
          this.isInstanceLocked(object) || this.isInstanceSealed(object),
      });

      // Space or multiple touches will hide the selection controls as they are
      // used to move the camera.
      const shouldHideSelectionControls =
        isSpacePressed(inputManager) || hasMultipleTouches;

      // Remove the selection controls if the last selected object has changed
      // or if nothing movable is selected.
      if (
        this._selectionControls &&
        (!lastEditableSelectedObject ||
          (lastEditableSelectedObject &&
            this._selectionControls.object !== lastEditableSelectedObject) ||
          this._shouldDragSelectedObject() ||
          shouldHideSelectionControls)
      ) {
        this._removeSelectionControls();
      }

      // Create the selection controls on the last object that can be manipulated.
      if (
        lastEditableSelectedObject &&
        !this._selectionControls &&
        !this._shouldDragSelectedObject() &&
        !shouldHideSelectionControls &&
        lastEditableSelectedObject.get3DRendererObject()
      ) {
        const cameraLayer = this.getCameraLayer(
          lastEditableSelectedObject.getLayer()
        );
        if (cameraLayer) {
          const runtimeLayerRender = cameraLayer
            ? cameraLayer.getRenderer()
            : null;
          const threeCamera = runtimeLayerRender
            ? runtimeLayerRender.getThreeCamera()
            : null;
          const threeScene = runtimeLayerRender
            ? runtimeLayerRender.getThreeScene()
            : null;
          if (threeCamera && threeScene) {
            // Create and attach the transform controls. It is attached to a dummy object
            // to avoid the controls to directly move the runtime object (we handle this
            // manually).
            const threeTransformControls = new THREE_ADDONS.TransformControls(
              threeCamera,
              this._runtimeGame.getRenderer().getCanvas() || undefined
            );
            patchAxesOnTransformControlsGizmos(threeTransformControls);
            patchColorsOnTransformControlsGizmos(threeTransformControls);
            patchNegativeAxisHandlesOnTransformControlsGizmos(
              threeTransformControls
            );

            threeTransformControls.rotation.order = 'ZYX';
            threeTransformControls.scale.y = -1;
            threeTransformControls.mode = this._transformControlsMode;
            threeTransformControls.traverse((obj) => {
              // To be detected correctly by OutlinePass.
              // @ts-ignore
              obj.isTransformControls = true;
            });

            // The dummy object is an invisible object that is the one moved by the transform
            // controls.
            const dummyThreeObject = new THREE.Object3D();
            this._updateDummyLocation(
              dummyThreeObject,
              lastEditableSelectedObject,
              threeTransformControls
            );
            threeScene.add(dummyThreeObject);

            threeTransformControls.attach(dummyThreeObject);
            threeScene.add(threeTransformControls);

            // Keep track of the movement so the editor can apply it to the selection.
            let initialObjectX = 0;
            let initialObjectY = 0;
            let initialObjectZ = 0;
            const initialDummyPosition = new THREE.Vector3();
            const initialDummyRotation = new THREE.Euler();
            const initialDummyScale = new THREE.Vector3();
            threeTransformControls.addEventListener('change', (e) => {
              if (!threeTransformControls.dragging) {
                this._selectionControlsMovementTotalDelta = null;

                this._updateDummyLocation(
                  dummyThreeObject,
                  lastEditableSelectedObject,
                  threeTransformControls
                );
                // Reset the initial position to the current position, so that
                // it's ready to be dragged again.
                initialObjectX = lastEditableSelectedObject.getX();
                initialObjectY = lastEditableSelectedObject.getY();
                initialObjectZ = is3D(lastEditableSelectedObject)
                  ? lastEditableSelectedObject.getZ()
                  : 0;
                initialDummyPosition.copy(dummyThreeObject.position);
                initialDummyRotation.copy(dummyThreeObject.rotation);
                initialDummyScale.copy(dummyThreeObject.scale);
                return;
              }

              let translationX =
                dummyThreeObject.position.x - initialDummyPosition.x;
              let translationY =
                dummyThreeObject.position.y - initialDummyPosition.y;
              let translationZ =
                dummyThreeObject.position.z - initialDummyPosition.z;
              if (
                this._transformControlsMode === 'translate' &&
                threeTransformControls.axis
              ) {
                if (threeTransformControls.axis === 'XYZ') {
                  // We need to override the translation vector because
                  // `threeTransformControls` don't know that the selection
                  // must be excluded when looking for the cursor position.
                  let isIntersectionFound = false;
                  let intersectionX: float = 0;
                  let intersectionY: float = 0;
                  let intersectionZ: float = 0;
                  if (is3D(lastEditableSelectedObject)) {
                    const cursor = this._getCursorIn3D(
                      this._selection.getSelectedObjects()
                    );
                    if (cursor) {
                      isIntersectionFound = true;
                      [intersectionX, intersectionY, intersectionZ] = cursor;
                    }
                  } else {
                    const projectedCursor = this._getProjectedCursor();
                    if (projectedCursor) {
                      isIntersectionFound = true;
                      [intersectionX, intersectionY] = projectedCursor;
                    }
                  }
                  if (isIntersectionFound) {
                    translationX = intersectionX - initialObjectX;
                    translationY = intersectionY - initialObjectY;
                    translationZ = intersectionZ - initialObjectZ;
                  } else {
                    translationX = 0;
                    translationY = 0;
                    translationZ = 0;
                  }
                }
                const isMovingOnX = threeTransformControls.axis.includes('X');
                const isMovingOnY = threeTransformControls.axis.includes('Y');
                const isMovingOnZ = threeTransformControls.axis.includes('Z');
                if (this._editorGrid.isSpanningEnabled(inputManager)) {
                  if (isMovingOnX) {
                    translationX =
                      this._editorGrid.getSnappedX(
                        initialObjectX + translationX
                      ) - initialObjectX;
                  }
                  if (isMovingOnY) {
                    translationY =
                      this._editorGrid.getSnappedY(
                        initialObjectY + translationY
                      ) - initialObjectY;
                  }
                  if (isMovingOnZ) {
                    translationZ =
                      this._editorGrid.getSnappedZ(
                        initialObjectZ + translationZ
                      ) - initialObjectZ;
                  }
                }
              }
              // 0.2 = 20% of the movement speed (Three.js transform controls scaling is too fast)
              const scaleDamping =
                threeTransformControls.axis &&
                threeTransformControls.axis.length === 1
                  ? 1
                  : 0.2;
              this._selectionControlsMovementTotalDelta = {
                translationX,
                translationY,
                translationZ,
                rotationX: gdjs.toDegrees(
                  dummyThreeObject.rotation.x - initialDummyRotation.x
                ),
                rotationY: -gdjs.toDegrees(
                  dummyThreeObject.rotation.y - initialDummyRotation.y
                ),
                rotationZ: -gdjs.toDegrees(
                  dummyThreeObject.rotation.z - initialDummyRotation.z
                ),
                scaleX:
                  1 +
                  (dummyThreeObject.scale.x / initialDummyScale.x - 1) *
                    scaleDamping,
                scaleY:
                  1 +
                  (dummyThreeObject.scale.y / initialDummyScale.y - 1) *
                    scaleDamping,
                scaleZ:
                  1 +
                  (dummyThreeObject.scale.z / initialDummyScale.z - 1) *
                    scaleDamping,
              };

              this._hasSelectionActuallyMoved =
                this._hasSelectionActuallyMoved ||
                !dummyThreeObject.position.equals(initialDummyPosition) ||
                !dummyThreeObject.rotation.equals(initialDummyRotation) ||
                !dummyThreeObject.scale.equals(initialDummyScale);
            });

            this._selectionControls = {
              object: lastEditableSelectedObject,
              dummyThreeObject,
              threeTransformControls,
            };
          }
        }
      }

      if (
        lastEditableSelectedObject &&
        this._selectionControls &&
        !this._draggedNewObject &&
        !this._draggedSelectedObject
      ) {
        const { threeTransformControls } = this._selectionControls;

        // Update the rotation snap.
        const inputManager = this._runtimeGame.getInputManager();
        const shouldSnap =
          this._transformControlsMode === 'rotate' && isAltPressed(inputManager);
        const rotationSnap = shouldSnap ? gdjs.toRad(ROTATION_SNAP_DEGREES) : null;

        threeTransformControls.setRotationSnap(rotationSnap);

        // Update the grid.
        const axis = threeTransformControls.axis;
        if (axis) {
          const isMovingOnX = axis ? axis.includes('X') : false;
          const isMovingOnY = axis ? axis.includes('Y') : false;
          const isMovingOnZ = axis ? axis.includes('Z') : false;
          let gridNormal: 'X' | 'Y' | 'Z' = 'Z';
          if (isMovingOnZ) {
            if (!isMovingOnX && !isMovingOnY) {
              // Choose the plan that faces the camera.
              const cameraRotation = Math.abs(
                gdjs.evtTools.common.angleDifference(
                  this._editorCamera.getCameraRotation(),
                  0
                )
              );
              if (cameraRotation <= 45 || cameraRotation > 135) {
                gridNormal = 'Y';
              } else {
                gridNormal = 'X';
              }
            } else if (!isMovingOnX) {
              gridNormal = 'X';
            } else if (!isMovingOnY) {
              gridNormal = 'Y';
            }
          }
          this._editorGrid.setNormal(gridNormal);
        }
        this._editorGrid.setPosition(
          lastEditableSelectedObject.getX(),
          lastEditableSelectedObject.getY(),
          is3D(lastEditableSelectedObject)
            ? lastEditableSelectedObject.getZ()
            : 0
        );
        const cameraLayer = this.getCameraLayer(
          lastEditableSelectedObject.getLayer()
        );
        const threeScene = cameraLayer
          ? cameraLayer.getRenderer().getThreeScene()
          : null;
        if (threeScene) {
          this._editorGrid.setTreeScene(threeScene);
        }
        this._editorGrid.setVisible(
          this._transformControlsMode === 'translate'
        );
      }
    }

    private _updateDummyLocation(
      dummyThreeObject: THREE.Object3D,
      lastEditableSelectedObject: gdjs.RuntimeObject,
      threeTransformControls: THREE_ADDONS.TransformControls
    ) {
      const threeObject = lastEditableSelectedObject.get3DRendererObject();
      if (!threeObject) return;
      dummyThreeObject.position.copy(threeObject.position);
      dummyThreeObject.rotation.copy(threeObject.rotation);
      dummyThreeObject.scale.copy(threeObject.scale);
      if (this._transformControlsMode === 'rotate') {
        // This is only done for the rotate mode because it messes with the
        // orientation of the scale mode.
        dummyThreeObject.rotation.y = -dummyThreeObject.rotation.y;
        dummyThreeObject.rotation.z = -dummyThreeObject.rotation.z;

        dummyThreeObject.position.set(
          lastEditableSelectedObject.getCenterXInScene(),
          lastEditableSelectedObject.getCenterYInScene(),
          is3D(lastEditableSelectedObject)
            ? lastEditableSelectedObject.getCenterZInScene()
            : 0
        );
      } else {
        dummyThreeObject.position.set(
          lastEditableSelectedObject.getX(),
          lastEditableSelectedObject.getY(),
          is3D(lastEditableSelectedObject)
            ? lastEditableSelectedObject.getZ()
            : 0
        );
      }
    }

    private _removeSelectionControls(): void {
      if (!this._selectionControls) {
        return;
      }
      this._selectionControls.threeTransformControls.detach();
      this._selectionControls.threeTransformControls.removeFromParent();
      this._selectionControls.dummyThreeObject.removeFromParent();
      this._editorGrid.setVisible(false);
      this._selectionControls = null;
    }

    activate(enable: boolean) {
      if (enable) {
        // Nothing to do.
      } else {
        this._runtimeGame.getSoundManager().unmuteEverything('in-game-editor');
        this._removeSelectionControls();

        // Cleanup selection boxes
        this._selectionBoxes.forEach((box) => {
          box.removeFromParent();
        });
        this._selectionBoxes.clear();
      }
    }

    setVisibleStatus(visible: boolean) {
      this._isVisible = visible;
    }

    private _sendSelectionUpdate(options?: {
      hasSelectedObjectBeenModified?: boolean;
      isSendingBackSelectionForDefaultSize?: boolean;
      addedObjects?: Array<gdjs.RuntimeObject>;
      removedObjects?: Array<gdjs.RuntimeObject>;
      objectToEdit?: gdjs.RuntimeObject | null;
    }) {
      const debuggerClient = this._runtimeGame._debuggerClient;
      if (!debuggerClient) return;

      const getPersistentUuidsFromObjects = (
        objects: Array<gdjs.RuntimeObject>
      ): Array<InstancePersistentUuidData> =>
        objects
          .map((object) => {
            if (!object.persistentUuid) return null;

            return { persistentUuid: object.persistentUuid };
          })
          .filter(isDefined);

      const getSelectedInstances = (
        objects: Array<gdjs.RuntimeObject>
      ): Array<InstancePersistentUuidData> =>
        objects
          .map((object) => {
            if (!object.persistentUuid) {
              return null;
            }
            return {
              persistentUuid: object.persistentUuid,
              defaultWidth: object.getOriginalWidth(),
              defaultHeight: object.getOriginalHeight(),
              defaultDepth: is3D(object)
                ? object.getOriginalDepth()
                : undefined,
            };
          })
          .filter(isDefined);

      const updatedInstances =
        options && options.hasSelectedObjectBeenModified
          ? this._selection
              .getSelectedObjects()
              .map((object) => this.getInstanceDataFromRuntimeObject(object))
              .filter(isDefined)
          : [];

      const addedInstances =
        options && options.addedObjects
          ? options.addedObjects
              .map((object) => this.getInstanceDataFromRuntimeObject(object))
              .filter(isDefined)
          : [];

      const removedInstances =
        options && options.removedObjects ? options.removedObjects : [];

      this._removeInstances(removedInstances);
      this._updateInstances(updatedInstances);
      this._addInstances(addedInstances);

      debuggerClient.sendInstanceChanges({
        isSendingBackSelectionForDefaultSize: options
          ? options.isSendingBackSelectionForDefaultSize || false
          : false,
        updatedInstances,
        addedInstances,
        selectedInstances: getSelectedInstances(
          this._selection.getSelectedObjects()
        ),
        removedInstances: getPersistentUuidsFromObjects(removedInstances),
        objectNameToEdit:
          options && options.objectToEdit
            ? options.objectToEdit.getName()
            : null,
      });
    }

    private getInstanceDataFromRuntimeObject(
      runtimeObject: gdjs.RuntimeObject
    ): InstanceData | null {
      if (is3D(runtimeObject)) {
        if (!runtimeObject.persistentUuid) return null;

        const width = runtimeObject.getWidth();
        const height = runtimeObject.getHeight();
        const depth = runtimeObject.getDepth();
        const defaultWidth = runtimeObject.getOriginalWidth();
        const defaultHeight = runtimeObject.getOriginalHeight();
        const defaultDepth = runtimeObject.getOriginalDepth();

        const oldData = this._getInstanceData(runtimeObject.persistentUuid);
        const instanceData: InstanceData = {
          name: runtimeObject.getName(),
          zOrder: runtimeObject.getZOrder(),
          persistentUuid: runtimeObject.persistentUuid,
          x: runtimeObject.getX(),
          y: runtimeObject.getY(),
          z: runtimeObject.getZ(),
          layer: runtimeObject.getLayer(),
          angle: runtimeObject.getAngle(),
          rotationY: runtimeObject.getRotationY(),
          rotationX: runtimeObject.getRotationX(),
          customSize: width !== defaultWidth || height !== defaultHeight,
          width,
          height,
          depth: depth === defaultDepth ? undefined : depth,
          locked: oldData ? oldData.locked : false,
          sealed: oldData ? oldData.sealed : false,
          // TODO: how to transmit/should we transmit other properties?
          numberProperties: [],
          stringProperties: [],
          initialVariables: [],
          // @ts-ignore
          defaultWidth,
          defaultHeight,
          defaultDepth,
        };

        return instanceData;
      } else {
        // TODO: handle 2D objects/instances.
        return null;
      }
    }

    private _removeInstances(
      removedInstances: Array<{ persistentUuid: string | null }>
    ) {
      for (const removedInstance of removedInstances) {
        // TODO: Might be worth indexing instances data
        const instanceIndex = this._editedInstanceDataList.findIndex(
          (instance) =>
            instance.persistentUuid === removedInstance.persistentUuid
        );
        if (instanceIndex >= 0) {
          this._editedInstanceDataList.splice(instanceIndex, 1);
        }
      }
    }

    private _updateInstances(updatedInstances: Array<InstanceData>) {
      for (const updatedInstance of updatedInstances) {
        const oldInstance = this._getInstanceData(
          updatedInstance.persistentUuid
        );
        if (oldInstance) {
          gdjs.HotReloader.assignOrDelete(oldInstance, updatedInstance, [
            // These are never modified by the InGameEditor, so don't update them:
            'initialVariables',
            'numberProperties',
            'stringProperties',
          ]);
        }
      }
    }

    private _getInstanceData(
      persistentUuid: string | null
    ): InstanceData | null {
      // TODO: Might be worth indexing instances data
      return persistentUuid
        ? this._editedInstanceDataList.find(
            (instanceData) => instanceData.persistentUuid === persistentUuid
          ) || null
        : null;
    }

    isInstanceLocked(object: gdjs.RuntimeObject): boolean {
      const instanceData = this._getInstanceData(object.persistentUuid);
      return !!instanceData && !!instanceData.locked;
    }

    isInstanceSealed(object: gdjs.RuntimeObject): boolean {
      const instanceData = this._getInstanceData(object.persistentUuid);
      return !!instanceData && !!instanceData.sealed;
    }

    private _addInstances(addedInstances: Array<InstanceData>) {
      for (const addedInstance of addedInstances) {
        this._editedInstanceDataList.push(addedInstance);
      }
    }

    private _updateInnerAreaOutline(): void {
      if (!this._currentScene) return;

      const layer = this.getCameraLayer('');
      if (!layer) {
        return;
      }
      const threeGroup = layer.getRenderer().getThreeGroup();
      if (!threeGroup) {
        return;
      }
      if (!this._threeInnerArea) {
        const boxMesh = new THREE.Mesh(
          new THREE.BoxGeometry(1, 1, 1),
          new THREE.MeshBasicMaterial({
            transparent: true,
            opacity: 0,
            alphaTest: 1,
          })
        );
        boxMesh.position.x = 0.5;
        boxMesh.position.y = 0.5;
        boxMesh.position.z = 0.5;
        const box = new THREE.BoxHelper(boxMesh, '#444444');
        box.rotation.order = 'ZYX';
        //box.material.depthTest = false;
        box.material.fog = false;
        const container = new THREE.Group();
        container.rotation.order = 'ZYX';
        container.add(box);
        threeGroup.add(container);
        this._threeInnerArea = container;
      }
      const threeInnerArea = this._threeInnerArea;
      if (this._innerArea) {
        const innerArea = this._innerArea;
        threeInnerArea.scale.x = innerArea.max[0] - innerArea.min[0];
        threeInnerArea.scale.y = innerArea.max[1] - innerArea.min[1];
        threeInnerArea.scale.z = innerArea.max[2] - innerArea.min[2];
        threeInnerArea.position.x = innerArea.min[0];
        threeInnerArea.position.y = innerArea.min[1];
        threeInnerArea.position.z = innerArea.min[2];
      } else {
        threeInnerArea.scale.x = this._runtimeGame.getOriginalWidth();
        threeInnerArea.scale.y = this._runtimeGame.getOriginalHeight();
        threeInnerArea.scale.z = 0.01;
        threeInnerArea.position.x = 0;
        threeInnerArea.position.y = 0;
        threeInnerArea.position.z = 0;
      }
    }

    private _handleContextMenu() {
      const inputManager = this._runtimeGame.getInputManager();
      const isClick =
        Date.now() - this._pressedRightButtonTime <=
        pressAndReleaseForClickDuration;
      if (inputManager.isMouseButtonReleased(1) && isClick) {
        this._sendOpenContextMenu(
          inputManager.getCursorX(),
          inputManager.getCursorY()
        );
      }
    }

    private _hasCursorStayedStillWhilePressed({
      toleranceRadius,
    }: {
      toleranceRadius: float;
    }) {
      const inputManager = this._runtimeGame.getInputManager();
      const deltaX = Math.abs(
        this._pressedOriginalCursorX - inputManager.getCursorX()
      );
      const deltaY = Math.abs(
        this._pressedOriginalCursorY - inputManager.getCursorY()
      );
      return (
        deltaX * deltaX + deltaY * deltaY <= toleranceRadius * toleranceRadius
      );
    }

    private _sendOpenContextMenu(cursorX: float, cursorY: float) {
      const debuggerClient = this._runtimeGame._debuggerClient;
      if (!debuggerClient) return;

      debuggerClient.sendOpenContextMenu(cursorX, cursorY);
    }

    private _handleShortcuts() {
      const inputManager = this._runtimeGame.getInputManager();
      let alreadyHandledShortcut = false;
      if (isControlPressedOnly(inputManager)) {
        // Note: use `wasKeyJustPressed` instead of `wasKeyReleased` to avoid
        // macOS stealing the key release ("key up") information
        // when the "Meta" key is pressed.
        if (inputManager.wasKeyJustPressed(Z_KEY)) {
          this._sendUndo();
          alreadyHandledShortcut = true;
        } else if (inputManager.wasKeyJustPressed(Y_KEY)) {
          this._sendRedo();
          alreadyHandledShortcut = true;
        } else if (inputManager.wasKeyJustPressed(C_KEY)) {
          this._sendCopy();
          alreadyHandledShortcut = true;
        } else if (inputManager.wasKeyJustPressed(V_KEY)) {
          this._sendPaste();
          alreadyHandledShortcut = true;
        } else if (inputManager.wasKeyJustPressed(X_KEY)) {
          this._sendCut();
          alreadyHandledShortcut = true;
        }
      }
      if (isControlPlusShiftPressedOnly(inputManager)) {
        if (inputManager.wasKeyJustPressed(Z_KEY)) {
          this._sendRedo();
          alreadyHandledShortcut = true;
        }
      }

      // Send the shortcut to the editor (as the iframe does not bubble up
      // the event to the parent window).
      if (!alreadyHandledShortcut) {
        this._forwardShortcutsToEditor(inputManager);
      }
    }

    private _sendUndo() {
      const debuggerClient = this._runtimeGame._debuggerClient;
      if (!debuggerClient) return;
      debuggerClient.sendUndo();
    }

    private _sendRedo() {
      const debuggerClient = this._runtimeGame._debuggerClient;
      if (!debuggerClient) return;
      debuggerClient.sendRedo();
    }

    private _sendCopy() {
      const debuggerClient = this._runtimeGame._debuggerClient;
      if (!debuggerClient) return;
      debuggerClient.sendCopy();
    }

    private _sendPaste() {
      const debuggerClient = this._runtimeGame._debuggerClient;
      if (!debuggerClient) return;
      debuggerClient.sendPaste();
    }

    private _sendCut() {
      const debuggerClient = this._runtimeGame._debuggerClient;
      if (!debuggerClient) return;
      debuggerClient.sendCut();
    }

    private _forwardShortcutsToEditor(inputManager: gdjs.InputManager) {
      const isCtrlPressed =
        inputManager.isKeyPressed(LEFT_CTRL_KEY) ||
        inputManager.isKeyPressed(RIGHT_CTRL_KEY);
      const isMetaPressed =
        inputManager.isKeyPressed(LEFT_META_KEY) ||
        inputManager.isKeyPressed(RIGHT_META_KEY);
      const isShiftKeyPressed = isShiftPressed(inputManager);
      const isAltKeyPressed = isAltPressed(inputManager);

      if (
        !isCtrlPressed &&
        !isMetaPressed &&
        !isAltKeyPressed &&
        !isShiftKeyPressed
      ) {
        return;
      }

      for (const locationAwareKeyCode of this._runtimeGame
        .getInputManager()
        .exceptionallyGetAllJustPressedKeys()) {
        const keyCode =
          exceptionallyGetKeyCodeFromLocationAwareKeyCode(locationAwareKeyCode);

        const debuggerClient = this._runtimeGame._debuggerClient;
        if (debuggerClient) {
          debuggerClient.sendKeyboardShortcut({
            keyCode,
            metaKey: isMetaPressed,
            ctrlKey: isCtrlPressed,
            altKey: isAltKeyPressed,
            shiftKey: isShiftKeyPressed,
          });
        }
      }
    }

    cancelDragNewInstance() {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      if (this._draggedNewObject) {
        this._draggedNewObject.deleteFromScene();
        this._draggedNewObject = null;
      }
      this._editorGrid.setVisible(false);
    }

    dragNewInstance({
      name,
      dropped,
      isAltPressed,
    }: {
      name: string;
      dropped: boolean;
      isAltPressed: boolean;
    }) {
      const currentScene = this._currentScene;
      if (!currentScene) return;
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      const selectedLayer = this.getEditorLayer(this._selectedLayerName);
      if (!selectedLayer) return;

      const inputManager = this._runtimeGame.getInputManager();

      if (this._draggedNewObject && this._draggedNewObject.getName() !== name) {
        this._draggedNewObject.deleteFromScene();
        this._draggedNewObject = null;
      }

      if (!this._draggedNewObject) {
        const newObject = editedInstanceContainer.createObject(name);
        if (!newObject) return;
        if (!is3D(newObject)) {
          editedInstanceContainer.markObjectForDeletion(newObject);
          return;
        }
        newObject.persistentUuid = gdjs.makeUuid();
        newObject.setLayer(selectedLayer.getName());
        this._draggedNewObject = newObject;
      }

      // We don't update the object position when it's dropped because it makes
      // the object shift a bit.
      // It seems that newIDE doesn't send the last cursor position even when
      // the cursor stay still for several frames. The right position is only
      // sent it when the object is dropped which make the shift.
      // To reproduce the issue:
      // - remove the `if`
      // - drag the object vertically very fast
      // - stay still
      // - drop the object
      if (!dropped) {
        let isCursorFound = false;
        let cursorX = 0;
        let cursorY = 0;
        let cursorZ = 0;
        if (is3D(this._draggedNewObject)) {
          const cursor = this._getCursorIn3D([this._draggedNewObject]);
          if (cursor) {
            [cursorX, cursorY, cursorZ] = cursor;
            isCursorFound = true;
          }
        } else {
          const projectedCursor = this._getProjectedCursor();
          if (projectedCursor) {
            [cursorX, cursorY] = projectedCursor;
            isCursorFound = true;
          }
        }
        if (isCursorFound) {
          this._editorGrid.setNormal('Z');
          this._editorGrid.setPosition(cursorX, cursorY, cursorZ);
          const cameraLayer = this.getCameraLayer(
            this._draggedNewObject.getLayer()
          );
          const threeScene = cameraLayer
            ? cameraLayer.getRenderer().getThreeScene()
            : null;
          if (threeScene) {
            this._editorGrid.setTreeScene(threeScene);
          }
          this._editorGrid.setVisible(true);
          if (this._editorGrid.isSpanningEnabled(inputManager, isAltPressed)) {
            cursorX = this._editorGrid.getSnappedX(cursorX);
            cursorY = this._editorGrid.getSnappedY(cursorY);
          }
          // TODO The object Z should be changed according to the new X and Y
          // to match the ground.
          this._draggedNewObject.setX(Math.round(cursorX));
          this._draggedNewObject.setY(Math.round(cursorY));
          // We don't round on Z because if cubes are stacked and there depth
          // is not round it would leave an interstice between them.
          if (is3D(this._draggedNewObject)) {
            this._draggedNewObject.setZ(cursorZ);
          }
        }
      }

      if (dropped) {
        if (this._draggedNewObject) {
          const isLayer3D = selectedLayer.getRenderer().getThreeGroup();
          if (isLayer3D) {
            const cameraX = selectedLayer.getCameraX();
            const cameraY = selectedLayer.getCameraY();
            const cameraZ = getCameraZ(
              currentScene,
              selectedLayer.getName(),
              0
            );

            const closestIntersect = this._getClosestIntersectionUnderCursor([
              this._draggedNewObject,
            ]);
            if (closestIntersect && !is3D(this._draggedNewObject)) {
              // Avoid to create a 2D object hidden under a 3D one.
              this.cancelDragNewInstance();
              return;
            }

            let cursorX: float;
            let cursorY: float;
            let cursorZ: float;
            if (closestIntersect) {
              cursorX = closestIntersect.point.x;
              cursorY = -closestIntersect.point.y;
              cursorZ = closestIntersect.point.z;
            } else {
              const projectedCursor = this._getProjectedCursor();
              if (!projectedCursor) {
                // Avoid to create an object behind the camera when it's dropped over the horizon.
                this.cancelDragNewInstance();
                return;
              }
              cursorX = projectedCursor[0];
              cursorY = projectedCursor[1];
              cursorZ = 0;
            }

            const cursorDistance = Math.hypot(
              cursorX - cameraX,
              cursorY - cameraY,
              cursorZ - cameraZ
            );
            if (
              cursorDistance >
              selectedLayer.getInitialCamera3DFarPlaneDistance()
            ) {
              // Avoid to create an object outside of the rendered area.
              this.cancelDragNewInstance();
              return;
            }
          }
          this._sendSelectionUpdate({
            addedObjects: [this._draggedNewObject],
          });
        }

        this._draggedNewObject = null;
        return;
      }
    }

    /**
     * @returns The cursor projected on the plane Z = 0 or `null` if the cursor is in the sky.
     */
    _getProjectedCursor(): FloatPoint | null {
      const currentScene = this._currentScene;
      if (!currentScene) return null;

      const layer = this.getCameraLayer('');
      if (!layer) {
        return null;
      }

      const cameraX = layer.getCameraX();
      const cameraY = layer.getCameraY();
      const cameraZ = getCameraZ(currentScene, layer.getName(), 0);

      const cursorX = gdjs.evtTools.input.getCursorX(
        currentScene,
        layer.getName(),
        0
      );
      const cursorY = gdjs.evtTools.input.getCursorY(
        currentScene,
        layer.getName(),
        0
      );

      const deltaX = cursorX - cameraX;
      const deltaY = cursorY - cameraY;
      const deltaZ = 0 - cameraZ;

      const threeCamera = layer.getRenderer().getThreeCamera();
      if (!threeCamera) {
        return [cursorX, cursorY];
      }
      const { forward } = getCameraForwardVector(threeCamera);
      // It happens when the cursor is over the horizon and projected on the plane Z = 0.
      const isCursorBehindTheCamera =
        forward.dot(new THREE.Vector3(deltaX, deltaY, deltaZ)) < 1;
      if (isCursorBehindTheCamera) {
        return null;
      }
      return [cursorX, cursorY];
    }

    reloadInstances(instances: Array<InstanceData>) {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      // TODO: Might be worth indexing instances data and runtime objects by their
      // persistentUuid (See HotReloader.indexByPersistentUuid).
      editedInstanceContainer
        .getAdhocListOfAllInstances()
        .forEach((runtimeObject) => {
          const instance = instances.find(
            (instance) =>
              instance.persistentUuid === runtimeObject.persistentUuid
          );
          if (instance) {
            runtimeObject.setX(instance.x);
            runtimeObject.setY(instance.y);
            if (instance.customSize) {
              runtimeObject.setWidth(instance.width);
              runtimeObject.setHeight(instance.height);
            } else {
              runtimeObject.setWidth(runtimeObject.getOriginalWidth());
              runtimeObject.setHeight(runtimeObject.getOriginalHeight());
            }
            runtimeObject.setAngle(instance.angle);
            runtimeObject.setLayer(instance.layer);
            if (is3D(runtimeObject)) {
              runtimeObject.setZ(instance.z === undefined ? 0 : instance.z);
              runtimeObject.setRotationX(
                instance.rotationX == undefined ? 0 : instance.rotationX
              );
              runtimeObject.setRotationY(
                instance.rotationY == undefined ? 0 : instance.rotationY
              );
              runtimeObject.setDepth(
                instance.depth == undefined
                  ? runtimeObject.getOriginalDepth()
                  : instance.depth
              );
            }
            runtimeObject.extraInitializationFromInitialInstance(instance);
          }
        });
      this._updateInstances(instances);
      this._forceUpdateSelectionControls();
    }

    addInstances(instances: Array<InstanceData>) {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      editedInstanceContainer.createObjectsFrom(instances, 0, 0, 0, true);
      this._addInstances(instances);
    }

    deleteSelection() {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return;

      this._removeInstances(this._selection.getSelectedObjects());
      for (const object of this._selection.getSelectedObjects()) {
        object.deleteFromScene();
      }
      this._selection.clear();
    }

    private _getClosestIntersectionUnderCursor(
      excludedObjects?: Array<gdjs.RuntimeObject>
    ): THREE.Intersection | null {
      const runtimeGame = this._runtimeGame;
      const firstIntersectsByLayer: {
        [layerName: string]: null | {
          intersect: THREE.Intersection;
        };
      } = {};
      const cursorX = runtimeGame.getInputManager().getCursorX();
      const cursorY = runtimeGame.getInputManager().getCursorY();

      const layerNames = [];
      const currentScene = this._currentScene;
      const threeRenderer = runtimeGame.getRenderer().getThreeRenderer();
      if (!currentScene || !threeRenderer) return null;

      // Only check layer 0, on which Three.js objects are by default,
      // and move selection boxes + dragged object to layer 1 so they
      // are not considered by raycasting.
      this._raycaster.layers.set(0);
      this._selectionBoxes.forEach((box) => box.setLayer(1));
      if (this._threeInnerArea) {
        for (const child of this._threeInnerArea.children) {
          child.layers.set(1);
        }
      }
      if (excludedObjects) {
        for (const excludedObject of excludedObjects) {
          if (is3D(excludedObject)) {
            const draggedRendererObject = excludedObject.get3DRendererObject();
            if (draggedRendererObject) {
              draggedRendererObject.layers.set(1);
              draggedRendererObject.traverse((object) => object.layers.set(1));
            }
          }
        }
      }

      currentScene.getAllLayerNames(layerNames);
      layerNames.forEach((layerName) => {
        const runtimeLayerRender = currentScene
          .getLayer(layerName)
          .getRenderer();
        const threeCamera = runtimeLayerRender.getThreeCamera();
        const threeGroup = runtimeLayerRender.getThreeGroup();
        if (!threeCamera || !threeGroup) return;

        // Note that raycasting is done by Three.js, which means it could slow down
        // if lots of 3D objects are shown. We consider that if this needs improvements,
        // this must be handled by the game engine culling
        const normalizedDeviceCoordinates = this._getTempVector2d(
          this._getNormalizedScreenX(cursorX),
          this._getNormalizedScreenY(cursorY)
        );
        this._raycaster.setFromCamera(normalizedDeviceCoordinates, threeCamera);
        const intersects = this._raycaster.intersectObjects(
          threeGroup.children,
          true
        );

        const firstIntersect = intersects[0];
        if (!firstIntersect) return;

        firstIntersectsByLayer[layerName] = {
          intersect: firstIntersect,
        };
      });

      // Reset selection boxes layers so they are properly displayed.
      this._selectionBoxes.forEach((box) => box.setLayer(0));
      if (this._threeInnerArea) {
        for (const child of this._threeInnerArea.children) {
          child.layers.set(0);
        }
      }
      // Also reset the layer of the object being added.
      if (excludedObjects) {
        for (const excludedObject of excludedObjects) {
          if (is3D(excludedObject)) {
            const draggedRendererObject = excludedObject.get3DRendererObject();
            if (draggedRendererObject) {
              draggedRendererObject.layers.set(0);
              draggedRendererObject.traverse((object) => object.layers.set(0));
            }
          }
        }
      }

      let closestIntersect: THREE.Intersection | null = null;
      for (const intersect of Object.values(firstIntersectsByLayer)) {
        if (
          intersect &&
          (!closestIntersect ||
            intersect.intersect.distance < closestIntersect.distance)
        ) {
          closestIntersect = intersect.intersect;
        }
      }

      return closestIntersect;
    }

    private _getCursorIn3D(
      excludedObjects?: Array<gdjs.RuntimeObject>
    ): Point3D | null {
      const closestIntersect =
        this._getClosestIntersectionUnderCursor(excludedObjects);
      if (closestIntersect) {
        return [
          closestIntersect.point.x,
          -closestIntersect.point.y,
          closestIntersect.point.z,
        ];
      }
      const projectedCursor = this._getProjectedCursor();
      if (!projectedCursor) {
        return null;
      }
      return [projectedCursor[0], projectedCursor[1], 0];
    }

    private _getNormalizedScreenX(x: float): float {
      return (x / this._runtimeGame.getGameResolutionWidth()) * 2 - 1;
    }

    private _getNormalizedScreenY(y: float): float {
      return -(y / this._runtimeGame.getGameResolutionHeight()) * 2 + 1;
    }

    getObjectUnderCursor(): gdjs.RuntimeObject | null {
      const closestIntersect = this._getClosestIntersectionUnderCursor();
      if (!closestIntersect) {
        const editedInstanceContainer = this.getEditedInstanceContainer();
        if (!editedInstanceContainer) {
          return null;
        }
        const cursor = this._getCursorIn3D();
        if (!cursor || cursor[2] !== 0) {
          return null;
        }
        let topObject2D: gdjs.RuntimeObject | null = null;
        let topLayer: gdjs.RuntimeLayer | null = null;
        let topLayerIndex = 0;
        for (const object of editedInstanceContainer.getAdhocListOfAllInstances()) {
          if (is3D(object) || !object.cursorOnObject()) {
            continue;
          }
          const layer = editedInstanceContainer.getLayer(object.getLayer());
          const layerIndex =
            editedInstanceContainer._orderedLayers.indexOf(layer);
          if (
            !topObject2D ||
            layerIndex > topLayerIndex ||
            (layer === topLayer && object.getZOrder() > topObject2D.getZOrder())
          ) {
            topObject2D = object;
            topLayer = layer;
            topLayerIndex = layerIndex;
          }
        }
        return topObject2D;
      }
      return this._getObject3D(closestIntersect.object);
    }

    private _getObject3D(
      initialThreeObject: THREE.Object3D
    ): gdjs.RuntimeObject | null {
      const editedInstanceContainer = this.getEditedInstanceContainer();
      if (!editedInstanceContainer) return null;

      // Walk back up the object hierarchy to find the runtime object.
      // We sadly need to do that because the intersection can be found on a Mesh or other
      // child Three.js object, instead of the one exposed by the gdjs.RuntimeObject.
      let threeObject: THREE.Object3D | null = initialThreeObject;
      while (threeObject) {
        const runtimeObject: gdjs.RuntimeObject | null =
          // @ts-ignore
          threeObject.gdjsRuntimeObject;
        if (runtimeObject) {
          let rootRuntimeObject = runtimeObject;
          while (
            rootRuntimeObject.getInstanceContainer() instanceof
              gdjs.CustomRuntimeObjectInstanceContainer &&
            rootRuntimeObject.getInstanceContainer() !== editedInstanceContainer
          ) {
            rootRuntimeObject = (
              rootRuntimeObject.getInstanceContainer() as gdjs.CustomRuntimeObjectInstanceContainer
            ).getOwner();
          }
          return rootRuntimeObject;
        }
        threeObject = threeObject.parent || null;
      }
      return null;
    }

    getCameraState(): EditorCameraState {
      return this._getEditorCamera().getCameraState();
    }

    restoreCameraState(editorCamera3D: EditorCameraState) {
      this._getEditorCamera().restoreCameraState(editorCamera3D);
    }

    private _updateMouseCursor() {
      const mouseCursor = this._getEditorCamera().getRequestedMouseCursor();

      const canvas = this._runtimeGame.getRenderer().getCanvas();
      if (canvas) {
        canvas.style.cursor = mouseCursor || 'default';
      }
    }

    private _handleTransformControlsMode() {
      const inputManager = this._runtimeGame.getInputManager();
      if (inputManager.wasKeyJustPressed(KEY_DIGIT_1)) {
        this._setTransformControlsMode('translate');
      } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_2)) {
        this._setTransformControlsMode('rotate');
      } else if (inputManager.wasKeyJustPressed(KEY_DIGIT_3)) {
        this._setTransformControlsMode('scale');
      }
    }

    private _handlePointerLock() {
      const inputManager = this._runtimeGame.getInputManager();
      const renderer = this._runtimeGame.getRenderer();
      const isRightButtonPressed = inputManager.isMouseButtonPressed(1);
      const isMiddleButtonPressed = inputManager.isMouseButtonPressed(2);

      // Request pointer lock when right or middle button is pressed,
      // but only after the delay to ensure it's not a click.
      if (
        (isRightButtonPressed &&
          Date.now() - this._pressedRightButtonTime >
            pressAndReleaseForClickDuration) ||
        (isMiddleButtonPressed &&
          Date.now() - this._pressedMiddleButtonTime >
            pressAndReleaseForClickDuration)
      ) {
        renderer.requestPointerLock('in-game-editor');
      }

      // Exit pointer lock as soon as we can.
      if (!isRightButtonPressed && !isMiddleButtonPressed) {
        if (renderer.isPointerLocked()) {
          renderer.exitPointerLock('in-game-editor');
        }
      }
    }

    updateTargetFramerate(elapsedTime: float) {
      const inputManager = this._runtimeGame.getInputManager();
      if (
        inputManager.anyKeyPressed() ||
        inputManager.anyMouseButtonPressed() ||
        inputManager.getAllTouchIdentifiers().length > 0 ||
        inputManager.getMouseWheelDelta() !== 0 ||
        inputManager.getMouseWheelDeltaX() !== 0 ||
        inputManager.getMouseWheelDeltaZ() !== 0
      ) {
        this._timeSinceLastInteraction = 0;
      }
      if (this._draggedNewObject) {
        this._timeSinceLastInteraction = 0;
      }
      this._timeSinceLastInteraction += elapsedTime;

      // Adapt the framerate to avoid consuming too much CPU when the editor is not visible
      // or not interacted with.
      if (!this._isVisible) {
        this._runtimeGame.setMaximumFps(0.3);
      } else {
        if (this._timeSinceLastInteraction > 1000) {
          this._runtimeGame.setMaximumFps(10);
        } else {
          this._runtimeGame.setMaximumFps(60);
        }
      }
    }

    updateAndRender() {
      if (!this._unregisterContextLostListener) {
        this._setupWebGLContextLostListener();
      }

      const objectUnderCursor: gdjs.RuntimeObject | null =
        this.getObjectUnderCursor();

      this._runtimeGame.getSoundManager().muteEverything('in-game-editor');

      const inputManager = this._runtimeGame.getInputManager();

      // Ensure we don't keep keys considered as pressed if the editor is blurred.
      if (!hasWindowFocus && this._windowHadFocus) {
        inputManager.releaseAllPressedKeys();
      }
      this._windowHadFocus = hasWindowFocus;

      // Update the state of the mouse/cursor for this frame.
      const mouseLeftButtonJustPressed =
        !this._wasMouseLeftButtonPressed &&
        inputManager.isMouseButtonPressed(0);
      const mouseRightButtonJustPressed =
        !this._wasMouseRightButtonPressed &&
        inputManager.isMouseButtonPressed(1);
      const mouseMiddleButtonJustPressed =
        !this._wasMouseMiddleButtonPressed &&
        inputManager.isMouseButtonPressed(2);
      if (mouseLeftButtonJustPressed || mouseRightButtonJustPressed) {
        this._pressedOriginalCursorX = inputManager.getCursorX();
        this._pressedOriginalCursorY = inputManager.getCursorY();
      }
      if (mouseRightButtonJustPressed) {
        this._pressedRightButtonTime = Date.now();
      }
      if (mouseMiddleButtonJustPressed) {
        this._pressedMiddleButtonTime = Date.now();
      }

      // Note: don't add more logic here. Instead, create a new method
      // to handle what you need, possibly with a dedicated class to abstract it.

      if (!this._selectionControls) {
        this._isTransformControlsHovered = false;
      } else if (
        this._previousCursorX !== inputManager.getMouseX() ||
        this._previousCursorY !== inputManager.getMouseY()
      ) {
        this._isTransformControlsHovered =
          !!this._selectionControls.threeTransformControls.axis;
      }

      this._handlePointerLock();
      this._handleTransformControlsMode();
      this._handleCameraMovement();
      this._handleSelectedObjectDragging();
      this._handleSelectionMovement();
      this._updateSelectionBox();
      this._handleSelection({ objectUnderCursor });
      this._updateSelectionOutline({ objectUnderCursor });
      // Custom objects only update their position at the end of the frame
      // because they don't override position setters like built-in objects do.
      // Since the instance position is not yet set when `onCreated` is called,
      // they will be at (0; 0; 0) during the 1st step.
      // When they are selected and `switchToSceneOrVariant` has just been
      // called, it avoid to put the control at (0; 0; 0).
      if (!this._isFirstFrame) {
        this._updateSelectionControls();
      }
      this._updateInnerAreaOutline();
      this._handleContextMenu();
      this._handleShortcuts();
      this._updateMouseCursor();

      const domElementContainer = this._runtimeGame
        .getRenderer()
        .getDomElementContainer();
      if (domElementContainer) {
        this._toolbar.render(domElementContainer);
      }

      // Prepare state of the mouse/cursor for the next frame.
      this._wasMovingSelectionLastFrame =
        !!this._selectionControlsMovementTotalDelta;
      if (!this._selectionControlsMovementTotalDelta) {
        this._hasSelectionActuallyMoved = false;
      }
      this._wasMouseLeftButtonPressed = inputManager.isMouseButtonPressed(0);
      this._wasMouseRightButtonPressed = inputManager.isMouseButtonPressed(1);
      this._wasMouseMiddleButtonPressed = inputManager.isMouseButtonPressed(2);
      this._previousCursorX = inputManager.getMouseX();
      this._previousCursorY = inputManager.getMouseY();

      if (this._currentScene) {
        this._currentScene._updateObjectsForInGameEditor();
        this._currentScene.render();
      }

      this._isFirstFrame = false;
    }

    private _getEditorCamera(): EditorCamera {
      return this._editorCamera;
    }
  }

  class Toolbar {
    private _renderedElements: {
      container: HTMLDivElement;
      moveButton: HTMLButtonElement;
      rotateButton: HTMLButtonElement;
      scaleButton: HTMLButtonElement;
      freeCameraButton: HTMLButtonElement;
      orbitCameraButton: HTMLButtonElement;
    } | null = null;
    private _parent: HTMLElement | null = null;
    private _getTransformControlsMode: () => 'translate' | 'rotate' | 'scale';
    private _setTransformControlsMode: (
      mode: 'translate' | 'rotate' | 'scale'
    ) => void;
    private _focusOnSelection: () => void;
    private _switchToFreeCamera: () => void;
    private _switchToOrbitCamera: () => void;
    private _isFreeCamera: () => boolean;
    private _getSvgIconUrl: (iconName: string) => string;
    private _hasSelectionControlsShown: () => boolean;

    private addOrUpdateToolbarStyle() {
      const id = 'InGameEditor-Toolbar-Style';

      let styleElement = document.getElementById(id);
      if (!styleElement) {
        styleElement = document.createElement('style');
        styleElement.id = 'InGameEditor-Toolbar-Style';
        document.head.appendChild(styleElement);
      }

      styleElement.textContent = `
        .InGameEditor-Toolbar-Centering-Container {
          position: fixed;
          left: 0;
          right: 0;
          display: flex;
          flex-direction: row;
          align-items: center;
          justify-content: center;

          transition: transform 0.1s ease-in-out;
        }
        .InGameEditor-Toolbar-Container {
          position: relative;
          pointer-events: all;
          display: flex;
          flex-direction: row;
          align-items: center;
          justify-content: center;
          top: 2px;
          border-radius: 3px;
          padding: 4px;
          gap: 6px;
        }
        .InGameEditor-Toolbar-Container-Background {
          position: absolute;
          inset: 0;
          background-color: var(--in-game-editor-theme-toolbar-background-color);
          opacity: 0.5;
          border-radius: 3px;
          z-index: -1;
        }
        .InGameEditor-Toolbar-Button {
          width: 24px;
          height: 24px;
          border-radius: 4px;
          border: none;
          padding: 0;
          border-width: 0;
          display: flex;
          align-items: center;
          justify-content: center;

          background-color: transparent;
        }
        .InGameEditor-Toolbar-Button-Icon {
          width: 20px;
          height: 20px;
          display:inline-block;
          background-color: var(--in-game-editor-theme-text-color-primary);
        }
        .InGameEditor-Toolbar-Button:hover:not(.InGameEditor-Toolbar-Button-Active):not(.InGameEditor-Toolbar-Button-Disabled) {
          background-color: rgba(255, 255, 255, 0.08);
        }
        .InGameEditor-Toolbar-Button-Active {
          background-color: var(--in-game-editor-theme-icon-button-selected-background-color);
        }
        .InGameEditor-Toolbar-Button-Active .InGameEditor-Toolbar-Button-Icon {
          background-color: var(--in-game-editor-theme-icon-button-selected-color);
        }
        .InGameEditor-Toolbar-Divider {
          width: 1px;
          height: 24px;
          background-color: var(--in-game-editor-theme-toolbar-separator-color);
        }
      `;
    }

    constructor({
      getTransformControlsMode,
      setTransformControlsMode,
      focusOnSelection,
      switchToFreeCamera,
      switchToOrbitCamera,
      isFreeCamera,
      getSvgIconUrl,
      hasSelectionControlsShown,
    }: {
      getTransformControlsMode: () => 'translate' | 'rotate' | 'scale';
      setTransformControlsMode: (
        mode: 'translate' | 'rotate' | 'scale'
      ) => void;
      focusOnSelection: () => void;
      switchToFreeCamera: () => void;
      switchToOrbitCamera: () => void;
      isFreeCamera: () => boolean;
      getSvgIconUrl: (iconName: string) => string;
      hasSelectionControlsShown: () => boolean;
    }) {
      this._getTransformControlsMode = getTransformControlsMode;
      this._setTransformControlsMode = setTransformControlsMode;
      this._focusOnSelection = focusOnSelection;
      this._switchToFreeCamera = switchToFreeCamera;
      this._switchToOrbitCamera = switchToOrbitCamera;
      this._isFreeCamera = isFreeCamera;
      this._getSvgIconUrl = getSvgIconUrl;
      this._hasSelectionControlsShown = hasSelectionControlsShown;

      this.addOrUpdateToolbarStyle();
    }

    render(parent: HTMLElement) {
      if (this._renderedElements && this._parent !== parent) {
        this._renderedElements.container.remove();
        this._renderedElements = null;
      }
      this._parent = parent;

      const makeIcon = ({ svgIconUrl }: { svgIconUrl: string }) => (
        <span
          class="InGameEditor-Toolbar-Button-Icon"
          style={{
            '-webkit-mask': `url('${svgIconUrl}') center/contain no-repeat`,
            mask: `url('${svgIconUrl}') center/contain no-repeat`,
          }}
        ></span>
      );

      if (!this._renderedElements) {
        const container = (
          <div class="InGameEditor-Toolbar-Centering-Container">
            <div class="InGameEditor-Toolbar-Container">
              <div class="InGameEditor-Toolbar-Container-Background" />
              <button
                class="InGameEditor-Toolbar-Button"
                id="free-camera-button"
                onClick={this._switchToFreeCamera}
                title="Free camera mode"
              >
                {makeIcon({
                  svgIconUrl: this._getSvgIconUrl(
                    'InGameEditor-FreeCameraIcon'
                  ),
                })}
              </button>
              <button
                class="InGameEditor-Toolbar-Button"
                id="orbit-camera-button"
                onClick={this._switchToOrbitCamera}
                title="Orbit camera mode"
              >
                {makeIcon({
                  svgIconUrl: this._getSvgIconUrl(
                    'InGameEditor-OrbitCameraIcon'
                  ),
                })}
              </button>
              <div class="InGameEditor-Toolbar-Divider" />
              <button
                class="InGameEditor-Toolbar-Button"
                id="move-button"
                onClick={() => this._setTransformControlsMode('translate')}
                title="Move (translate) selection (1)"
              >
                {makeIcon({
                  svgIconUrl: this._getSvgIconUrl('InGameEditor-MoveIcon'),
                })}
              </button>
              <button
                class="InGameEditor-Toolbar-Button"
                id="rotate-button"
                onClick={() => this._setTransformControlsMode('rotate')}
                title="Rotate selection (2)"
              >
                {makeIcon({
                  svgIconUrl: this._getSvgIconUrl('InGameEditor-RotateIcon'),
                })}
              </button>
              <button
                class="InGameEditor-Toolbar-Button"
                id="scale-button"
                onClick={() => this._setTransformControlsMode('scale')}
                title="Resize selection (3)"
              >
                {makeIcon({
                  svgIconUrl: this._getSvgIconUrl('InGameEditor-ResizeIcon'),
                })}
              </button>
              <div class="InGameEditor-Toolbar-Divider" />
              <button
                class="InGameEditor-Toolbar-Button"
                id="focus-button"
                onClick={this._focusOnSelection}
                title="Focus on selection (F)"
              >
                {makeIcon({
                  svgIconUrl: this._getSvgIconUrl('InGameEditor-FocusIcon'),
                })}
              </button>
            </div>
          </div>
        );
        this._parent.appendChild(container);

        this._renderedElements = {
          container,
          moveButton: container.querySelector('#move-button')!,
          rotateButton: container.querySelector('#rotate-button')!,
          scaleButton: container.querySelector('#scale-button')!,
          freeCameraButton: container.querySelector('#free-camera-button')!,
          orbitCameraButton: container.querySelector('#orbit-camera-button')!,
        };
      }

      const displayed = this._hasSelectionControlsShown();

      this._renderedElements.container.tabIndex = displayed ? 0 : -1;
      this._renderedElements.container.style.transform = displayed
        ? 'translateY(0)'
        : 'translateY(-50px)';

      const transformControlsMode = this._getTransformControlsMode();
      this._renderedElements.freeCameraButton.classList.toggle(
        'InGameEditor-Toolbar-Button-Active',
        this._isFreeCamera()
      );
      this._renderedElements.orbitCameraButton.classList.toggle(
        'InGameEditor-Toolbar-Button-Active',
        !this._isFreeCamera()
      );
      this._renderedElements.moveButton.classList.toggle(
        'InGameEditor-Toolbar-Button-Active',
        transformControlsMode === 'translate'
      );
      this._renderedElements.rotateButton.classList.toggle(
        'InGameEditor-Toolbar-Button-Active',
        transformControlsMode === 'rotate'
      );
      this._renderedElements.scaleButton.classList.toggle(
        'InGameEditor-Toolbar-Button-Active',
        transformControlsMode === 'scale'
      );
    }
  }

  class EditorGrid {
    editor: gdjs.InGameEditor;
    gridHelper: THREE.GridHelper;
    isVisible = true;
    normal: 'Z' | 'Y' | 'X' = 'Z';
    position = new THREE.Vector3();

    isForcefullyHidden = true;
    gridWidth: float = 0;
    gridHeight: float = 0;
    gridDepth: float = 0;
    gridOffsetX: float = 0;
    gridOffsetY: float = 0;
    gridOffsetZ: float = 0;
    gridColor: integer = 0;
    gridAlpha: float = 1;
    isSnappingEnabledByDefault = false;
    threeScene: THREE.Scene | null = null;

    constructor(editor: gdjs.InGameEditor) {
      this.editor = editor;
      this.gridHelper = new THREE.GridHelper();
    }

    setSettings(instancesEditorSettings: InstancesEditorSettings): void {
      this.isForcefullyHidden = !instancesEditorSettings.grid;
      this.gridWidth = instancesEditorSettings.gridWidth || 0;
      this.gridHeight = instancesEditorSettings.gridHeight || 0;
      this.gridDepth =
        instancesEditorSettings.gridDepth === undefined
          ? 32
          : instancesEditorSettings.gridDepth;
      this.gridOffsetX = instancesEditorSettings.gridOffsetX || 0;
      this.gridOffsetY = instancesEditorSettings.gridOffsetY || 0;
      this.gridOffsetZ = instancesEditorSettings.gridOffsetZ || 0;
      this.gridColor = instancesEditorSettings.gridColor;
      this.gridAlpha = instancesEditorSettings.gridAlpha;
      this.isSnappingEnabledByDefault = instancesEditorSettings.snap;
      this.rebuildGrid();
    }

    private rebuildGrid(): void {
      this.gridHelper.removeFromParent();
      this.gridHelper.dispose();
      this.gridHelper = new THREE.GridHelper(
        10,
        10,
        this.gridColor,
        this.gridColor
      );
      this.gridHelper.material.transparent = true;
      this.gridHelper.material.opacity = this.gridAlpha;
      this.gridHelper.rotation.order = 'ZYX';
      this.updateVisibility();
      this.updateLocation();
      if (this.threeScene) {
        this.threeScene.add(this.gridHelper);
      }
    }

    private updateLocation() {
      const { gridWidth, gridHeight, gridDepth } = this;
      const { x, y, z } = this.position;
      if (this.normal === 'X') {
        this.gridHelper.rotation.set(0, 0, Math.PI / 2);
        this.gridHelper.scale.set(gridWidth, 1, gridDepth || 0);
        this.gridHelper.position.set(
          x,
          this.getSnappedY(y),
          this.getSnappedZ(z)
        );
      } else if (this.normal === 'Y') {
        this.gridHelper.rotation.set(0, 0, 0);
        this.gridHelper.scale.set(gridHeight, 1, gridDepth || 0);
        this.gridHelper.position.set(
          this.getSnappedX(x),
          y,
          this.getSnappedZ(z)
        );
      } else {
        this.gridHelper.rotation.set(Math.PI / 2, 0, 0);
        this.gridHelper.scale.set(gridWidth, 1, gridHeight);
        this.gridHelper.position.set(
          this.getSnappedX(x),
          this.getSnappedY(y),
          z
        );
      }
    }

    setTreeScene(threeScene: THREE.Scene): void {
      this.threeScene = threeScene;
      this.gridHelper.removeFromParent();
      threeScene.add(this.gridHelper);
    }

    setVisible(isVisible: boolean): void {
      this.isVisible = isVisible;
      this.updateVisibility();
    }

    private updateVisibility(): void {
      this.gridHelper.visible = this.isVisible && !this.isForcefullyHidden;
    }

    setNormal(normal: 'X' | 'Y' | 'Z') {
      this.normal = normal;
      if (this.normal === 'X') {
        this.gridHelper.rotation.set(0, 0, Math.PI / 2);
      } else if (this.normal === 'Y') {
        this.gridHelper.rotation.set(0, 0, 0);
      } else {
        this.gridHelper.rotation.set(Math.PI / 2, 0, 0);
      }
      this.updateLocation();
    }

    setPosition(x: float, y: float, z: float): void {
      this.position.set(x, y, z);
      this.updateLocation();
    }

    getSnappedX(x: float): float {
      const { gridWidth, gridOffsetX } = this;
      return snap(x, gridWidth, gridOffsetX);
    }

    getSnappedY(y: float): float {
      const { gridHeight, gridOffsetY } = this;
      return snap(y, gridHeight, gridOffsetY);
    }

    getSnappedZ(z: float): float {
      const { gridDepth, gridOffsetY } = this;
      return snap(z, gridDepth || 0, gridOffsetY);
    }

    isSpanningEnabled(
      inputManager: gdjs.InputManager,
      considerAltPressed?: boolean
    ): boolean {
      const altPressed =
        considerAltPressed === undefined
          ? isAltPressed(inputManager)
          : considerAltPressed;
      return this.isSnappingEnabledByDefault !== altPressed;
    }
  }

  /** @category In-Game Editor */
  export type EditorCameraState = {
    cameraMode: 'free' | 'orbit';
    positionX: float;
    positionY: float;
    positionZ: float;
    rotationAngle: float;
    elevationAngle: float;
    distance: float;
  };

  class EditorCamera implements CameraControl {
    editor: gdjs.InGameEditor;
    orbitCameraControl: OrbitCameraControl;
    freeCameraControl: FreeCameraControl;
    private _hasChanged = false;
    private _hadChanged = false;
    private _mouseCursor: string | null = null;

    constructor(editor: gdjs.InGameEditor) {
      this.editor = editor;
      this.orbitCameraControl = new OrbitCameraControl(this);
      this.freeCameraControl = new FreeCameraControl(this);
      this.orbitCameraControl.setEnabled(false);
    }

    isFreeCamera(): boolean {
      return this.freeCameraControl.isEnabled();
    }

    private getActiveCamera() {
      return this.isFreeCamera()
        ? this.freeCameraControl
        : this.orbitCameraControl;
    }

    getRequestedMouseCursor(): string | null {
      return this._mouseCursor;
    }

    switchToOrbitAroundObject(object: gdjs.RuntimeObject): void {
      this.switchToOrbitAroundPosition(
        object.getCenterXInScene(),
        object.getCenterYInScene(),
        is3D(object) ? object.getUnrotatedAABBMinZ() : 0
      );
      this.onHasCameraChanged();
    }

    switchToOrbitAroundPosition(
      targetX: float,
      targetY: float,
      targetZ: float
    ): void {
      this.orbitCameraControl.target.x = targetX;
      this.orbitCameraControl.target.y = targetY;
      this.orbitCameraControl.target.z = targetZ;
      if (this.freeCameraControl.isEnabled()) {
        this.orbitCameraControl.rotationAngle =
          this.freeCameraControl.rotationAngle;
        this.orbitCameraControl.elevationAngle =
          this.freeCameraControl.elevationAngle;
      }
      this.orbitCameraControl.setEnabled(true);
      this.freeCameraControl.setEnabled(false);
      this.onHasCameraChanged();
    }

    switchToOrbitAroundZ0(maxDistance: number): void {
      if (this.freeCameraControl.isEnabled()) {
        // Match orientation and orbit from the current free camera position.
        this.orbitCameraControl.rotationAngle =
          this.freeCameraControl.rotationAngle;
        this.orbitCameraControl.elevationAngle =
          this.freeCameraControl.elevationAngle;
        this.orbitCameraControl.orbitFromPositionAroundZ0(
          this.freeCameraControl.position.x,
          this.freeCameraControl.position.y,
          this.freeCameraControl.position.z,
          maxDistance
        );
      }

      this.orbitCameraControl.setEnabled(true);
      this.freeCameraControl.setEnabled(false);
      this.onHasCameraChanged();
    }

    switchToFreeCamera(): void {
      this.orbitCameraControl.setEnabled(false);
      this.freeCameraControl.setEnabled(true);

      this.freeCameraControl.position.x = this.orbitCameraControl.getCameraX();
      this.freeCameraControl.position.y = this.orbitCameraControl.getCameraY();
      this.freeCameraControl.position.z = this.orbitCameraControl.getCameraZ();
      this.freeCameraControl.rotationAngle =
        this.orbitCameraControl.rotationAngle;
      this.freeCameraControl.elevationAngle =
        this.orbitCameraControl.elevationAngle;
      this.onHasCameraChanged();
    }

    resetRotationToTopDown(): void {
      this.orbitCameraControl.resetRotationToTopDown();
      this.freeCameraControl.resetRotationToTopDown();
      this.onHasCameraChanged();
    }

    setOrbitDistance(distance: number): void {
      this.orbitCameraControl.distance = distance;
      this.onHasCameraChanged();
    }

    step(): void {
      const runtimeGame = this.editor.getRuntimeGame();
      const inputManager = runtimeGame.getInputManager();

      const touchIds = getCurrentTouchIdentifiers(inputManager);
      const touchCount = touchIds.length;

      // Always allow to use Space+click to switch to free camera and pan.
      // Display a grab cursor to indicate that.
      this._mouseCursor = null;
      if (isSpacePressed(inputManager)) {
        this._mouseCursor = 'grab';

        // Switch to pan if space + left click is used.
        if (inputManager.isMouseButtonPressed(0) && !this.isFreeCamera()) {
          this.switchToFreeCamera();
        }
      }
      // Also switch to pan if shift + wheel click is used.
      if (
        isShiftPressed(inputManager) &&
        inputManager.isMouseButtonPressed(2) &&
        !this.isFreeCamera()
      ) {
        this.switchToFreeCamera();
      }
      // Shift to orbit if just mouse wheel click is used
      if (
        !isShiftPressed(inputManager) &&
        inputManager.isMouseButtonPressed(2) &&
        this.isFreeCamera()
      ) {
        const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level.
        this.switchToOrbitAroundZ0(maxDistance);
      }
      // With touches, 2 touches will always pan/zoom the camera with the free camera.
      if (touchCount === 2 && !this.isFreeCamera()) {
        this.switchToFreeCamera();
      }
      // With touches, 3 touches will orbit around the point "in front of the camera".
      if (
        (touchCount === 3 || inputManager.isKeyPressed(O_KEY)) &&
        this.isFreeCamera()
      ) {
        const maxDistance = 4000; // Large enough to orbit quickly on most parts of a level.
        this.switchToOrbitAroundZ0(maxDistance);
      }

      this.orbitCameraControl.step();
      this.freeCameraControl.step();

      if (this._hadChanged && !this._hasChanged) {
        this._sendCameraState();
      }
      this._hadChanged = this._hasChanged;
      this._hasChanged = false;
    }

    onHasCameraChanged() {
      this._hasChanged = true;
    }

    updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void {
      this.getActiveCamera().updateCamera(currentScene, layer);
    }

    zoomBy(zoomInFactor: float): void {
      this.getActiveCamera().zoomBy(zoomInFactor);
      this.onHasCameraChanged();
    }

    setZoom(zoom: float): void {
      const distance = this._getCameraZFromZoom(zoom);
      this.switchToOrbitAroundPosition(this.getAnchorX(), this.getAnchorY(), 0);
      this.resetRotationToTopDown();
      this.setOrbitDistance(distance);
      this.onHasCameraChanged();
    }

    getAnchorX(): float {
      return this.getActiveCamera().getAnchorX();
    }
    getAnchorY(): float {
      return this.getActiveCamera().getAnchorY();
    }
    getAnchorZ(): float {
      return this.getActiveCamera().getAnchorZ();
    }

    zoomToInitialPosition(visibleScreenArea: {
      minX: number;
      minY: number;
      maxX: number;
      maxY: number;
    }) {
      const runtimeGame = this.editor.getRuntimeGame();
      this.zoomToFitArea(
        {
          minX: 0,
          minY: 0,
          minZ: 0,
          maxX: runtimeGame.getOriginalWidth(),
          maxY: runtimeGame.getOriginalHeight(),
          maxZ: 0,
        },
        visibleScreenArea,
        0.1
      );
    }

    zoomToFitObjects(
      objects: Array<RuntimeObject>,
      visibleScreenArea: {
        minX: number;
        minY: number;
        maxX: number;
        maxY: number;
      },
      margin: float
    ) {
      if (objects.length === 0) {
        const runtimeGame = this.editor.getRuntimeGame();
        this.zoomToFitArea(
          {
            minX: 0,
            minY: 0,
            minZ: 0,
            maxX: runtimeGame.getOriginalWidth(),
            maxY: runtimeGame.getOriginalHeight(),
            maxZ: 0,
          },
          visibleScreenArea,
          0.1
        );
      }
      let minX = Number.MAX_VALUE;
      let minY = Number.MAX_VALUE;
      let minZ = Number.MAX_VALUE;
      let maxX = Number.MIN_VALUE;
      let maxY = Number.MIN_VALUE;
      let maxZ = Number.MIN_VALUE;
      for (const object of objects) {
        const aabb = object.getAABB();
        minX = Math.min(minX, aabb.min[0]);
        minY = Math.min(minY, aabb.min[1]);
        minZ = Math.min(minZ, is3D(object) ? object.getUnrotatedAABBMinZ() : 0);
        maxX = Math.max(maxX, aabb.max[0]);
        maxY = Math.max(maxY, aabb.max[1]);
        maxZ = Math.max(maxZ, is3D(object) ? object.getUnrotatedAABBMaxZ() : 0);
      }
      this.zoomToFitArea(
        {
          minX,
          minY,
          minZ,
          maxX,
          maxY,
          maxZ,
        },
        visibleScreenArea,
        margin
      );
    }

    zoomToFitArea(
      sceneArea: {
        minX: number;
        minY: number;
        minZ: number;
        maxX: number;
        maxY: number;
        maxZ: number;
      },
      visibleScreenArea: {
        minX: number;
        minY: number;
        maxX: number;
        maxY: number;
      },
      margin: float
    ) {
      const sceneAreaWidth = sceneArea.maxX - sceneArea.minX;
      const sceneAreaHeight = sceneArea.maxY - sceneArea.minY;

      const runtimeGame = this.editor.getRuntimeGame();
      const renderedWidth = runtimeGame.getGameResolutionWidth();
      const renderedHeight = runtimeGame.getGameResolutionHeight();
      const editorWidth =
        (visibleScreenArea.maxX - visibleScreenArea.minX) * renderedWidth;
      const editorHeight =
        (visibleScreenArea.maxY - visibleScreenArea.minY) * renderedHeight;
      const isContentWider =
        editorWidth * sceneAreaHeight < sceneAreaWidth * editorHeight;
      const zoom =
        (1 - 2 * margin) *
        (isContentWider
          ? editorWidth / sceneAreaWidth
          : editorHeight / sceneAreaHeight);
      const distance = this._getCameraZFromZoom(zoom);

      const sceneAreaCenterX = (sceneArea.maxX + sceneArea.minX) / 2;
      const sceneAreaCenterY = (sceneArea.maxY + sceneArea.minY) / 2;

      this.switchToOrbitAroundPosition(
        sceneAreaCenterX,
        sceneAreaCenterY,
        sceneArea.minZ
      );
      this.resetRotationToTopDown();
      this.setOrbitDistance(distance);
      this.onHasCameraChanged();
    }

    /**
     * Get the camera center Z position.
     *
     * @param zoom The camera zoom.
     * @return The z position of the camera
     */
    _getCameraZFromZoom = (zoom: float): float => {
      const runtimeGame = this.editor.getRuntimeGame();
      // Set the camera so that it displays the whole PixiJS plane, as if it was a 2D rendering.
      // The Z position is computed by taking the half height of the displayed rendering,
      // and using the angle of the triangle defined by the field of view to compute the length
      // of the triangle defining the distance between the camera and the rendering plane.
      return (
        (0.5 * runtimeGame.getGameResolutionHeight()) /
        zoom /
        Math.tan(0.5 * gdjs.toRad(editorCameraFov))
      );
    };

    getCameraRotation(): float {
      return this.getActiveCamera().rotationAngle;
    }

    getCameraState(): EditorCameraState {
      return this.getActiveCamera()._getCameraState();
    }

    restoreCameraState(cameraState: EditorCameraState) {
      if (cameraState.cameraMode === 'free') {
        this.orbitCameraControl.setEnabled(false);
        this.freeCameraControl.setEnabled(true);
        this.freeCameraControl._restoreCameraState(cameraState);
      } else {
        this.freeCameraControl.setEnabled(false);
        this.orbitCameraControl.setEnabled(true);
        this.orbitCameraControl._restoreCameraState(cameraState);
      }
      this.onHasCameraChanged();
    }

    private _sendCameraState() {
      const runtimeGame = this.editor.getRuntimeGame();
      const debuggerClient = runtimeGame._debuggerClient;
      if (!debuggerClient) return;
      debuggerClient.sendCameraState(this.getCameraState());
    }
  }

  interface CameraControl {
    step(): void;
    updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void;
    zoomBy(zoomInFactor: float): void;
    resetRotationToTopDown(): void;
    getAnchorX(): float;
    getAnchorY(): float;
    getAnchorZ(): float;
  }

  class OrbitCameraControl implements CameraControl {
    private _editorCamera: EditorCamera;
    target: THREE.Vector3 = new THREE.Vector3();
    rotationAngle: float = 0;
    elevationAngle: float = 90;
    distance: float = 800;
    private _isEnabled: boolean = true;

    private _lastCursorX: float = 0;
    private _lastCursorY: float = 0;
    private _wasMouseRightButtonPressed = false;
    private _wasMouseMiddleButtonPressed = false;

    private _gestureActiveTouchIds: Array<integer> = [];
    private _gestureLastCentroidX: float = 0;
    private _gestureLastCentroidY: float = 0;

    constructor(editorCamera: EditorCamera) {
      this._editorCamera = editorCamera;
    }

    isEnabled(): boolean {
      return this._isEnabled;
    }

    setEnabled(isEnabled: boolean): void {
      this._isEnabled = isEnabled;
      this._editorCamera.onHasCameraChanged();
    }

    step(): void {
      const runtimeGame = this._editorCamera.editor.getRuntimeGame();
      const inputManager = runtimeGame.getInputManager();
      const renderer = runtimeGame.getRenderer();
      const isRightButtonPressed = inputManager.isMouseButtonPressed(1);
      const isMiddleButtonPressed = inputManager.isMouseButtonPressed(2);

      if (this._isEnabled) {
        // Right click: rotate the camera.
        // Middle click: also rotate the camera.
        if (
          (isRightButtonPressed &&
            // The camera should not move the 1st frame
            this._wasMouseRightButtonPressed) ||
          (isMiddleButtonPressed &&
            // The camera should not move the 1st frame
            this._wasMouseMiddleButtonPressed)
        ) {
          // Use movement deltas when pointer is locked, otherwise use cursor position delta
          const xDelta = renderer.isPointerLocked()
            ? inputManager.getMouseMovementX()
            : inputManager.getCursorX() - this._lastCursorX;
          const yDelta = renderer.isPointerLocked()
            ? inputManager.getMouseMovementY()
            : inputManager.getCursorY() - this._lastCursorY;

          const rotationSpeed = 0.2;
          this.rotationAngle += xDelta * rotationSpeed;
          this.elevationAngle += yDelta * rotationSpeed;
          this._editorCamera.onHasCameraChanged();
        }

        // Mouse wheel: movement on the plane or forward/backward movement.
        const wheelDeltaY = inputManager.getMouseWheelDelta();
        if (wheelDeltaY !== 0 && shouldZoom(inputManager)) {
          this.distance = Math.max(
            10,
            this.distance * Math.pow(2, -wheelDeltaY / 512)
          );
          this._editorCamera.onHasCameraChanged();
        }

        // Movement with keyboard: zoom in/out.
        if (isControlOrCmdPressed(inputManager)) {
          if (inputManager.wasKeyJustPressed(EQUAL_KEY)) {
            this.zoomBy(zoomInFactor);
          } else if (inputManager.wasKeyJustPressed(MINUS_KEY)) {
            this.zoomBy(zoomOutFactor);
          }
        }

        // Touch gestures
        const touchIds = getCurrentTouchIdentifiers(inputManager);
        const touchCount = touchIds.length;

        if (touchCount === 0) {
          this._gestureActiveTouchIds = [];
        } else if (!areSameTouchesSet(this._gestureActiveTouchIds, touchIds)) {
          // Start or reinitialize gesture tracking
          this._gestureActiveTouchIds = touchIds.slice();
          if (touchCount === 3) {
            const centroid3 = getTouchesCentroid(inputManager);
            this._gestureLastCentroidX = centroid3.x;
            this._gestureLastCentroidY = centroid3.y;
          }
        } else {
          // Process ongoing gesture
          if (touchCount === 3) {
            // Three-finger rotation:
            // - adjust elevation angle from vertical movement of centroid
            // - adjust rotation angle from horizontal movement of centroid
            const centroid3 = getTouchesCentroid(inputManager);
            const dx3 = centroid3.x - this._gestureLastCentroidX;
            const dy3 = centroid3.y - this._gestureLastCentroidY;
            if (dx3 !== 0) {
              const tiltSpeed = 0.2;
              this.rotationAngle += dx3 * tiltSpeed;
              this._editorCamera.onHasCameraChanged();
            }
            if (dy3 !== 0) {
              const tiltSpeed = 0.2;
              this.elevationAngle += dy3 * tiltSpeed;
              if (this.elevationAngle < -45) this.elevationAngle = -45;
              if (this.elevationAngle > 175) this.elevationAngle = 175;
              this._editorCamera.onHasCameraChanged();
            }
            this._gestureLastCentroidX = centroid3.x;
            this._gestureLastCentroidY = centroid3.y;
          }
        }
      } else {
        // Reset gesture tracking when camera control is disabled.
        this._gestureActiveTouchIds = [];
      }

      this._wasMouseRightButtonPressed = isRightButtonPressed;
      this._wasMouseMiddleButtonPressed = isMiddleButtonPressed;
      this._lastCursorX = inputManager.getCursorX();
      this._lastCursorY = inputManager.getCursorY();
    }

    getAnchorX(): float {
      return this.target.x;
    }

    getAnchorY(): float {
      return this.target.y;
    }

    getAnchorZ(): float {
      return this.target.z;
    }

    private _getCameraForwardVector(): Point3D {
      // Camera forward (from camera toward where it looks), unit length.
      const cosYaw = Math.cos(gdjs.toRad(this.rotationAngle + 90));
      const sinYaw = Math.sin(gdjs.toRad(this.rotationAngle + 90));
      const cosEl = Math.cos(gdjs.toRad(this.elevationAngle));
      const sinEl = Math.sin(gdjs.toRad(this.elevationAngle));

      const fwdX = -cosYaw * cosEl;
      const fwdY = -sinYaw * cosEl;
      const fwdZ = -sinEl;

      return [fwdX, fwdY, fwdZ];
    }

    getCameraX(): float {
      const [fwdX, ,] = this._getCameraForwardVector();
      return this.target.x - this.distance * fwdX;
    }

    getCameraY(): float {
      const [, fwdY] = this._getCameraForwardVector();
      return this.target.y - this.distance * fwdY;
    }

    getCameraZ(): float {
      const [, , fwdZ] = this._getCameraForwardVector();
      return this.target.z - this.distance * fwdZ;
    }

    orbitFromPositionAroundZ0(
      x: float,
      y: float,
      z: float,
      targetMaxDistance: float
    ): void {
      const [fwdX, fwdY, fwdZ] = this._getCameraForwardVector();

      // Intersect ray P(t) = camera position + t * forward with plane z = 0:
      // z + t*fwdZ = 0 => t = -z / fwdZ
      let tPlane: number | null = null;
      if (Math.abs(fwdZ) > 1e-6) {
        const t = -z / fwdZ;
        // Only keep intersections "in front" of the camera
        if (t > 0) tPlane = t;
      }

      // Choose distance along the ray:
      // - If there is a valid intersection within targetMaxDistance, use it
      // - Otherwise, clamp to targetMaxDistance
      const distance =
        tPlane !== null && tPlane <= targetMaxDistance
          ? tPlane
          : targetMaxDistance;

      // Target point = point ahead of camera along forward by distance
      this.target.x = x + fwdX * distance;
      this.target.y = y + fwdY * distance;
      this.target.z = z + fwdZ * distance;

      // Distance so that orbit camera stays exactly at the specified position
      this.distance = distance;
    }

    updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void {
      const layerName = layer.getName();
      layer.setCameraX(this.getCameraX());
      layer.setCameraY(this.getCameraY());
      setCameraZ(currentScene, this.getCameraZ(), layerName, 0);
      setCameraRotationX(currentScene, 90 - this.elevationAngle, layerName, 0);
      setCameraRotationY(currentScene, 0, layerName, 0);
      layer.setCameraRotation(this.rotationAngle);
    }

    zoomBy(zoomFactor: float): void {
      // The distance is proportional to the inverse of the zoom.
      this.distance /= zoomFactor;
      this._editorCamera.onHasCameraChanged();
    }

    resetRotationToTopDown(): void {
      this.rotationAngle = 0;
      this.elevationAngle = 90;
      this._editorCamera.onHasCameraChanged();
    }

    _getCameraState(): EditorCameraState {
      return {
        cameraMode: 'orbit',
        positionX: this.target.x,
        positionY: this.target.y,
        positionZ: this.target.z,
        rotationAngle: this.rotationAngle,
        elevationAngle: this.elevationAngle,
        distance: this.distance,
      };
    }

    _restoreCameraState(cameraState: EditorCameraState): void {
      if (cameraState.cameraMode !== 'orbit') {
        return;
      }
      this.target.x = cameraState.positionX;
      this.target.y = cameraState.positionY;
      this.target.z = cameraState.positionZ;
      this.rotationAngle = cameraState.rotationAngle;
      this.elevationAngle = cameraState.elevationAngle;
      this.distance = cameraState.distance;
      this._editorCamera.onHasCameraChanged();
    }
  }

  class FreeCameraControl implements CameraControl {
    private _editorCamera: EditorCamera;
    position: THREE.Vector3 = new THREE.Vector3();
    rotationAngle: float = 0;
    elevationAngle: float = 30;
    private _isEnabled: boolean = true;
    private _euler: THREE.Euler = new THREE.Euler(0, 0, 0, 'ZYX');
    private _rotationMatrix: THREE.Matrix4 = new THREE.Matrix4();

    private _lastCursorX: float = 0;
    private _lastCursorY: float = 0;
    private _wasMouseRightButtonPressed = false;

    // Touch gesture state
    private _gestureActiveTouchIds: Array<integer> = [];
    private _gestureLastCentroidX: float = 0;
    private _gestureLastCentroidY: float = 0;
    private _gestureLastDistance: float = 0;

    constructor(editorCamera: EditorCamera) {
      this._editorCamera = editorCamera;
    }

    isEnabled(): boolean {
      return this._isEnabled;
    }

    setEnabled(isEnabled: boolean): void {
      this._isEnabled = isEnabled;
      this._editorCamera.onHasCameraChanged();
    }

    step(): void {
      const runtimeGame = this._editorCamera.editor.getRuntimeGame();
      const inputManager = runtimeGame.getInputManager();
      const renderer = runtimeGame.getRenderer();
      const isRightButtonPressed = inputManager.isMouseButtonPressed(1);
      if (this._isEnabled) {
        const { right, up, forward } = this.getCameraVectors();

        const moveCameraByVector = (vector: THREE.Vector3, scale: number) => {
          this.position.x += vector.x * scale;
          this.position.y += vector.y * scale;
          this.position.z += vector.z * scale;
          this._editorCamera.onHasCameraChanged();
        };

        // Mouse wheel: movement on the plane or forward/backward movement.
        const wheelDeltaY = inputManager.getMouseWheelDelta();
        const wheelDeltaX = inputManager.getMouseWheelDeltaX();
        if (wheelDeltaY !== 0 && shouldZoom(inputManager)) {
          moveCameraByVector(forward, wheelDeltaY);
        } else if (
          wheelDeltaY !== 0 &&
          shouldScrollHorizontally(inputManager)
        ) {
          moveCameraByVector(right, wheelDeltaY / 5);
        } else if (wheelDeltaX !== 0 || wheelDeltaY !== 0) {
          moveCameraByVector(up, wheelDeltaY / 5);
          moveCameraByVector(right, wheelDeltaX / 5);
        }

        // Touch gestures
        const touchIds = getCurrentTouchIdentifiers(inputManager);
        const touchCount = touchIds.length;

        if (touchCount === 0) {
          this._gestureActiveTouchIds = [];
        } else if (!areSameTouchesSet(this._gestureActiveTouchIds, touchIds)) {
          // Start or reinitialize gesture tracking
          this._gestureActiveTouchIds = touchIds.slice();
          if (touchCount === 2) {
            const centroid = getTouchesCentroid(inputManager);
            this._gestureLastCentroidX = centroid.x;
            this._gestureLastCentroidY = centroid.y;
            this._gestureLastDistance = getTouchesDistance(inputManager);
          }
        } else {
          // Process ongoing gesture
          if (touchCount === 2) {
            // Pan: move on the camera plane by centroid delta
            const centroid = getTouchesCentroid(inputManager);
            const dx = (centroid.x - this._gestureLastCentroidX) * 5;
            const dy = (centroid.y - this._gestureLastCentroidY) * 5;
            if (dx !== 0 || dy !== 0) {
              moveCameraByVector(up, dy);
              moveCameraByVector(right, -dx);
              this._gestureLastCentroidX = centroid.x;
              this._gestureLastCentroidY = centroid.y;
            }

            // Pinch: zoom forward/backward based on distance delta
            const dist = getTouchesDistance(inputManager);
            const pinchDelta = (dist - this._gestureLastDistance) * 10;
            if (pinchDelta !== 0) {
              moveCameraByVector(forward, pinchDelta);
              this._gestureLastDistance = dist;
            }
          }
        }

        // Movement with the keyboard:
        // Either arrow keys (move in the camera plane) or WASD ("FPS move" + Q/E for up/down).
        const moveSpeed = isShiftPressed(inputManager) ? 48 : 6;

        if (
          !isControlOrCmdPressed(inputManager) &&
          !isAltPressed(inputManager)
        ) {
          if (inputManager.isKeyPressed(LEFT_KEY)) {
            moveCameraByVector(right, -moveSpeed);
          }
          if (inputManager.isKeyPressed(RIGHT_KEY)) {
            moveCameraByVector(right, moveSpeed);
          }
          if (inputManager.isKeyPressed(UP_KEY)) {
            moveCameraByVector(up, moveSpeed);
          }
          if (inputManager.isKeyPressed(DOWN_KEY)) {
            moveCameraByVector(up, -moveSpeed);
          }
          // Forward/back
          if (inputManager.isKeyPressed(W_KEY)) {
            moveCameraByVector(forward, moveSpeed);
          }
          if (inputManager.isKeyPressed(S_KEY)) {
            moveCameraByVector(forward, -moveSpeed);
          }

          // Left/right (strafe)
          if (inputManager.isKeyPressed(A_KEY)) {
            moveCameraByVector(right, -moveSpeed);
          }
          if (inputManager.isKeyPressed(D_KEY)) {
            moveCameraByVector(right, moveSpeed);
          }

          // Up/down
          if (inputManager.isKeyPressed(Q_KEY)) {
            moveCameraByVector(up, -moveSpeed);
          }
          if (inputManager.isKeyPressed(E_KEY)) {
            moveCameraByVector(up, moveSpeed);
          }
        }

        // Movement with keyboard: zoom in/out.
        if (isControlOrCmdPressed(inputManager)) {
          if (inputManager.wasKeyJustPressed(EQUAL_KEY)) {
            this.zoomBy(zoomInFactor);
          } else if (inputManager.wasKeyJustPressed(MINUS_KEY)) {
            this.zoomBy(zoomOutFactor);
          }
        }

        // Space + click: move the camera on its plane.
        // Shift + Wheel click: same.
        if (
          (isSpacePressed(inputManager) &&
            inputManager.isMouseButtonPressed(0)) ||
          (isShiftPressed(inputManager) && inputManager.isMouseButtonPressed(2))
        ) {
          // Use movement deltas when pointer is locked, otherwise use cursor position delta
          const xDelta = renderer.isPointerLocked()
            ? inputManager.getMouseMovementX()
            : inputManager.getCursorX() - this._lastCursorX;
          const yDelta = renderer.isPointerLocked()
            ? inputManager.getMouseMovementY()
            : inputManager.getCursorY() - this._lastCursorY;
          moveCameraByVector(up, yDelta);
          moveCameraByVector(right, -xDelta);
        }

        // Right click: rotate the camera.
        if (
          isRightButtonPressed &&
          // The camera should not move the 1st frame
          this._wasMouseRightButtonPressed
        ) {
          // Use movement deltas when pointer is locked, otherwise use cursor position delta
          const xDelta = renderer.isPointerLocked()
            ? inputManager.getMouseMovementX()
            : inputManager.getCursorX() - this._lastCursorX;
          const yDelta = renderer.isPointerLocked()
            ? inputManager.getMouseMovementY()
            : inputManager.getCursorY() - this._lastCursorY;

          const rotationSpeed = 0.2;
          this.rotationAngle += xDelta * rotationSpeed;
          this.elevationAngle += yDelta * rotationSpeed;
          this._editorCamera.onHasCameraChanged();
        }
      } else {
        // Reset gesture tracking when camera control is disabled.
        this._gestureActiveTouchIds = [];
      }
      this._wasMouseRightButtonPressed = isRightButtonPressed;
      this._lastCursorX = inputManager.getCursorX();
      this._lastCursorY = inputManager.getCursorY();
    }

    moveForward(distanceDelta: number) {
      const { forward } = this.getCameraVectors();

      const moveCameraByVector = (vector: THREE.Vector3, scale: number) => {
        this.position.x += vector.x * scale;
        this.position.y += vector.y * scale;
        this.position.z += vector.z * scale;
        this._editorCamera.onHasCameraChanged();
      };

      moveCameraByVector(forward, distanceDelta);
    }

    updateCamera(currentScene: RuntimeScene, layer: RuntimeLayer): void {
      const layerName = layer.getName();
      layer.setCameraX(this.position.x);
      layer.setCameraY(this.position.y);
      setCameraZ(currentScene, this.position.z, layerName, 0);
      setCameraRotationX(currentScene, 90 - this.elevationAngle, layerName, 0);
      setCameraRotationY(currentScene, 0, layerName, 0);
      layer.setCameraRotation(this.rotationAngle);
    }

    private getCameraVectors() {
      this._euler.x = gdjs.toRad(90 - this.elevationAngle);
      this._euler.z = gdjs.toRad(this.rotationAngle);
      this._rotationMatrix.makeRotationFromEuler(this._euler);

      // threeCamera.matrixWorld is a 4x4. In Three.js, the columns correspond to:
      //   [ right.x,   up.x,    forwardNeg.x,  pos.x
      //     right.y,   up.y,    forwardNeg.y,  pos.y
      //     right.z,   up.z,    forwardNeg.z,  pos.z
      //     0,         0,       0,             1     ]
      //
      // By default, a Three.js camera looks down the -Z axis, so the "forward" axis
      // in the matrix is actually the negative Z column. We'll call it "forward" below.
      const elements = this._rotationMatrix.elements;

      // Local right axis in world space:
      const right = new THREE.Vector3(elements[0], elements[1], elements[2]);
      // Local forward axis in world space (note we take the negative of that column).
      const forward = new THREE.Vector3(
        elements[8],
        elements[9],
        -elements[10]
      );

      // Local up axis in world space: orthogonal to both right and forward.
      const up = new THREE.Vector3().crossVectors(forward, right);

      // Normalize them, just in case (they should generally be unit vectors).
      right.normalize();
      up.normalize();
      forward.normalize();

      return { right, up, forward };
    }

    getAnchorX(): float {
      return this.position.x;
    }

    getAnchorY(): float {
      return this.position.y;
    }

    getAnchorZ(): float {
      return this.position.z;
    }

    zoomBy(zoomInFactor: float): void {
      this.moveForward(zoomInFactor > 1 ? 200 : -200);
      this._editorCamera.onHasCameraChanged();
    }

    resetRotationToTopDown(): void {
      this.rotationAngle = 0;
      this.elevationAngle = 90;
      this._editorCamera.onHasCameraChanged();
    }

    _getCameraState(): EditorCameraState {
      return {
        cameraMode: 'free',
        positionX: this.position.x,
        positionY: this.position.y,
        positionZ: this.position.z,
        rotationAngle: this.rotationAngle,
        elevationAngle: this.elevationAngle,
        distance: 0,
      };
    }

    _restoreCameraState(cameraState: EditorCameraState): void {
      if (cameraState.cameraMode !== 'free') {
        return;
      }
      this.position.x = cameraState.positionX;
      this.position.y = cameraState.positionY;
      this.position.z = cameraState.positionZ;
      this.rotationAngle = cameraState.rotationAngle;
      this.elevationAngle = cameraState.elevationAngle;
      this._editorCamera.onHasCameraChanged();
    }
  }

  const getCameraZ = (
    runtimeScene: RuntimeScene,
    layerName: string,
    cameraIndex: integer
  ): float => {
    return gdjs.scene3d.camera
      ? gdjs.scene3d.camera.getCameraZ(runtimeScene, layerName, cameraIndex)
      : 0;
  };

  const setCameraZ = (
    runtimeScene: RuntimeScene,
    z: float,
    layerName: string,
    cameraIndex: integer
  ) => {
    if (gdjs.scene3d.camera) {
      gdjs.scene3d.camera.setCameraZ(runtimeScene, z, layerName, cameraIndex);
    }
  };

  const setCameraRotationX = (
    runtimeScene: RuntimeScene,
    angle: float,
    layerName: string,
    cameraIndex: integer
  ) => {
    if (gdjs.scene3d.camera) {
      gdjs.scene3d.camera.setCameraRotationX(
        runtimeScene,
        angle,
        layerName,
        cameraIndex
      );
    }
  };

  const setCameraRotationY = (
    runtimeScene: RuntimeScene,
    angle: float,
    layerName: string,
    cameraIndex: integer
  ) => {
    if (gdjs.scene3d.camera) {
      gdjs.scene3d.camera.setCameraRotationY(
        runtimeScene,
        angle,
        layerName,
        cameraIndex
      );
    }
  };

  class ObjectSelectionBoxHelper {
    object: gdjs.RuntimeObject;
    dummyObject3DForObject2D: THREE.Object3D | null = null;
    boxHelper: THREE.BoxHelper;
    container: THREE.Group;

    constructor(object: gdjs.RuntimeObject) {
      this.object = object;

      let threeObject = object.get3DRendererObject();
      if (!threeObject) {
        threeObject = new THREE.Group();
        threeObject.add(
          new THREE.Mesh(
            new THREE.BoxGeometry(1, 1, 1),
            new THREE.MeshBasicMaterial()
          )
        );
        this.dummyObject3DForObject2D = threeObject;
      }
      // Use a group to invert the Y-axis as the GDevelop Y axis is inverted
      // compared to Three.js. This is somehow necessary because the position
      // of the BoxHelper is always (0, 0, 0) and the geometry is hard to manipulate.
      this.container = new THREE.Group();
      this.container.rotation.order = 'ZYX';
      this.container.scale.y = -1;
      this.boxHelper = new THREE.BoxHelper(threeObject, '#f2a63c');
      this.boxHelper.rotation.order = 'ZYX';
      this.boxHelper.material.depthTest = false;
      this.boxHelper.material.fog = false;
      this.container.add(this.boxHelper);
    }

    update() {
      if (this.dummyObject3DForObject2D) {
        this.dummyObject3DForObject2D.position.set(
          this.object.getCenterXInScene(),
          -this.object.getCenterYInScene(),
          0
        );
        this.dummyObject3DForObject2D.scale.set(
          this.object.getWidth() + 2,
          this.object.getHeight() + 2,
          0
        );
      }
      this.boxHelper.update();
    }

    removeFromParent() {
      this.container.removeFromParent();
    }

    setLayer(layer: number): void {
      this.boxHelper.layers.set(layer);
    }

    setColor(color: THREE.ColorRepresentation) {
      this.boxHelper.material.color.set(color);
      this.boxHelper.material.needsUpdate = true;
    }
  }

  /**
   * A 3D object placeholder that is used as a fall back when the object type
   * is not known.
   */
  class UnknownRuntimeObject extends gdjs.RuntimeObject3D {
    _renderer: UnknownRuntimeObjectRenderer;

    constructor(
      instanceContainer: gdjs.RuntimeInstanceContainer,
      objectData: gdjs.Object3DData,
      instanceData?: InstanceData
    ) {
      super(instanceContainer, objectData, instanceData);
      this._renderer = new UnknownRuntimeObjectRenderer(
        this,
        instanceContainer
      );
    }

    override getRenderer(): gdjs.RuntimeObject3DRenderer {
      return this._renderer;
    }

    override onDestroyed(): void {
      super.onDestroyed();
      this._renderer.onDestroyed();
    }
  }
  gdjs.registerObject('', UnknownRuntimeObject);

  class UnknownRuntimeObjectRenderer extends gdjs.RuntimeObject3DRenderer {
    private _threeObject: THREE.Mesh;

    constructor(
      runtimeObject: UnknownRuntimeObject,
      instanceContainer: gdjs.RuntimeInstanceContainer
    ) {
      const cube = new THREE.Mesh(
        new THREE.BoxGeometry(),
        runtimeObject
          .getInstanceContainer()
          .getGame()
          .getImageManager()
          .getThreeMaterial('', {
            useTransparentTexture: false,
            forceBasicMaterial: true,
            vertexColors: false,
          })
      );
      super(runtimeObject, instanceContainer, cube);
      this._threeObject = cube;
      this.updateSize();
      this.updatePosition();
      this.updateRotation();
    }

    onDestroyed(): void {
      this._threeObject.removeFromParent();
      this._threeObject.geometry.dispose();
    }
  }
}
