state_State.js

const Diff = require('./Diff');
const Object3D = require('../Object3D');

const { objectEquals } = require('@ud-viz/utils_shared');

/** @class */
const State = class {
  /**
   * Store state of context at a given time
   *
   * @param {Object3D} object3D - context root object3D
   * @param {number} timestamp - time
   */
  constructor(object3D, timestamp) {
    if (!object3D || !timestamp) throw new Error('need parameters');

    /**
     * context root object3D
     *
     * @type {Object3D}
     */
    this.object3D = object3D;

    /**
     * time when the state has been created in ms
     *
     * @type {number}
     */
    this.timestamp = timestamp;

    /**
     * flag to determine if state has been consumed/treated
     *
     * @type {boolean}
     */
    this._consumed = false;
  }

  /**
   *
   * @param {boolean} value - new consumed value
   */
  setConsumed(value) {
    this._consumed = value;
  }

  /**
   *
   * @returns {boolean} - return true if state has been consumed/treated
   */
  hasBeenConsumed() {
    return this._consumed;
  }

  /**
   * Compute next state based on a {@link Diff}
   *
   * @param {Diff} diff - diff between two State
   * @returns {State} - next state
   */
  add(diff) {
    const nextStateObjectsUUID = diff.getNextStateObjectsUUID();
    const objects3DToUpdateJSON = diff.getObjects3DToUpdateJSON();

    const object3DUpdated = [];

    const cloneObject3D = this.object3D.clone();

    const objectToRemove = [];

    cloneObject3D.traverse((child) => {
      if (!nextStateObjectsUUID.includes(child.uuid)) {
        // not present in the next state => has been removed
        objectToRemove.push(child); // record for further removal
      } else {
        // Check if an update is needed
        const object3DJSON = objects3DToUpdateJSON[child.uuid];
        if (object3DJSON) {
          child.updatefromJSON(object3DJSON);
          object3DUpdated.push(child.uuid);
        } else {
          child.setOutdated(false); // => this object is no longer outdated
        }
      }
    });

    // remove object here so the traverse is not disturbed
    objectToRemove.forEach((object) => object.removeFromParent());

    for (const uuid in objects3DToUpdateJSON) {
      if (object3DUpdated.includes(uuid)) continue; // already update

      // still not updated => did not find parent object3D

      const json = objects3DToUpdateJSON[uuid];
      const newObject3D = new Object3D(json, null);
      const parent = cloneObject3D.getObjectByProperty('uuid', json.parentUUID);
      parent.add(newObject3D);
    }

    // DEBUG
    // let count = 0;
    // cloneObject3D.traverse(function (child) {
    //   if (nextStateObjectsUUID.includes(child.uuid)) count++;
    // });
    // if (nextStateObjectsUUID.length != count) {
    //   throw new Error('count of object3D error');
    // }

    return new State(cloneObject3D, diff.getTimeStamp());
  }

  /**
   * Check if there is an object3D with a given uuid
   *
   * @param {string} uuid - uuid to be check
   * @returns {boolean} - true if there is an object3D with this uuid, false otherwise
   */
  includes(uuid) {
    if (this.object3D.getObjectByProperty('uuid', uuid)) {
      return true;
    }
    return false;
  }

  /**
   * Compute the diff between this and previous state
   *
   * @param {State} previousState - state passed to compute the diff with this
   * @returns {Diff} diff between this and previousState
   */
  sub(previousState) {
    const nextStateObjectsUUID = [];
    const objects3DToUpdateJSON = {};
    this.object3D.traverse((child) => {
      nextStateObjectsUUID.push(child.uuid); // Register all uuid
      if (!previousState.includes(child.uuid) || child.isOutdated()) {
        // If not in the previous state or outdated
        objects3DToUpdateJSON[child.uuid] = child.toJSON(true, false, false); // add it without its children
      }
    });

    return new Diff({
      nextStateObjectsUUID: nextStateObjectsUUID,
      objects3DToUpdateJSON: objects3DToUpdateJSON,
      timestamp: this.timestamp,
    });
  }

  /**
   *
   * @param {State} state - state to compare to
   * @returns {boolean} - true if states are equal
   */
  equals(state) {
    if (state.timestamp != this.timestamp) return false;
    return objectEquals(
      this.object3D.toJSON(true),
      state.object3D.toJSON(true)
    );
  }

  /**
   *
   * @returns {State} - clone of state
   */
  clone() {
    return new State(this.object3D.clone(), this.timestamp);
  }

  /**
   *
   * @returns {number} - state timestamp
   */
  getTimestamp() {
    return this.timestamp;
  }

  /**
   *
   * @returns {Object3D} - state object3D
   */
  getObject3D() {
    return this.object3D;
  }

  /**
   * export state to serializable json object
   *
   * @returns {object} - serializable json object
   */
  toJSON() {
    return {
      object3D: this.object3D.toJSON(),
      timestamp: this.timestamp,
    };
  }
};

/**
 * Compute a state interpolated between s1 and s2 with a given ratio
 *
 * @param {State} s1 - first state if ratio = 0, result = s1
 * @param {State} s2 - second state if ratio = 1, result = s2
 * @param {number} ratio - a number between 0 => 1
 * @returns {State} - interpolated state
 */
State.interpolate = function (s1, s2, ratio) {
  if (!s2) return s1;

  // Interpolate object3D
  const mapState2 = {};
  s2.getObject3D().traverse(function (object) {
    mapState2[object.uuid] = object;
  });
  const result = s1.clone();
  result.getObject3D().traverse(function (object) {
    if (object.isStatic()) return false; // no need to interpolate

    const s2Object = mapState2[object.uuid];

    if (s2Object) {
      // interpolate
      object.position.lerp(s2Object.position, ratio);
      object.scale.lerp(s2Object.scale, ratio);
      object.quaternion.slerp(s2Object.quaternion, ratio);
    }
  });

  return result;
};

module.exports = State;