mprManager.js

// Use modified MPRSlice interactor
import vtkInteractorStyleMPRWindowLevel from "./vtk/vtkInteractorStyleMPRWindowLevel";
import vtkInteractorStyleMPRCrosshairs from "./vtk/vtkInteractorStyleMPRCrosshairs";
import vtkInteractorStyleMPRPanZoom from "./vtk/vtkInteractorStyleMPRPanZoom";

import vtkCoordinate from "@kitware/vtk.js/Rendering/Core/Coordinate";
import vtkMatrixBuilder from "@kitware/vtk.js/Common/Core/MatrixBuilder";

import {
  getPlaneIntersection,
  getVolumeCenter,
  createVolumeActor
} from "./utils/utils";

import { MPRView } from "./mprView";

/**
 * Internal state of a single view
 * @typedef {Object} State
 * @property {Number[]} slicePlaneNormal - The slice plane normal as [x,y,z]
 * @property {Number[]} sliceViewUp - The up vector as [x,y,z]
 * @property {Number} slicePlaneXRotation - The x axis rotation in deg
 * @property {Number} slicePlaneYRotation - The y axis rotation in deg
 * @property {Number} viewRotation - The view rotation in deg
 * @property {Number} sliceThickness - The MIP slice thickness in px
 * @property {String} blendMode - The active blending mode ("MIP", "MinIP", "Average")
 * @property {Object} window - wwwl
 * @property {Number} window.ww - Window width
 * @property {Number} window.wl - Window level
 */

/** A manager for MPR views */
export class MPRManager {
  /**
   * Create a manager.
   * @param {Object} elements - The 3 target HTML elements {key1:{}, key2:{}, key3:{}}.
   * @param {HTMLElement} elements.element - The target HTML elements.
   * @param {String} elements.key - The target HTML elements.
   */
  constructor(elements) {
    this.VERBOSE = false; // TODO setter
    this.syncWindowLevels = true; // TODO setter
    this._activeTool = null;

    // TODO input sanity check

    this.elements = elements;

    this.volume = null;

    this.sliceIntersection = [0, 0, 0];

    this.mprViews = {};

    this.initMPR();
  }

  /**
   * wwwl
   * @type {Array}
   */
  set wwwl([ww, wl]) {
    const lower = wl - ww / 2.0;
    const upper = wl + ww / 2.0;

    this.volume
      .getProperty()
      .getRGBTransferFunction(0)
      .setMappingRange(lower, upper);

    Object.keys(this.elements).forEach((key, i) => {
      this.mprViews[key].wwwl = [ww, wl];
    });
  }

  /**
   * Initialize the three MPR views
   * @private
   */
  initMPR() {
    Object.keys(this.elements).forEach((key, i) => {
      try {
        this.mprViews[key] = new MPRView(key, i, this.elements[key].element);
      } catch (err) {
        console.error("Error creating MPRView", key);
        console.error(err);
      }
    });

    if (this.VERBOSE) console.log("initialized");
  }

  /**
   * Get initial State object
   * @returns {State} The initial internal state
   */
  getInitialState() {
    // cycle on keys, and reduce extracting only useful properties
    // NOTE: initialize reduce with cloned object!
    let viewsState = Object.keys(this.mprViews).reduce((result, key) => {
      let {
        slicePlaneNormal,
        sliceViewUp,
        slicePlaneXRotation,
        slicePlaneYRotation,
        viewRotation,
        _sliceThickness,
        _blendMode,
        window
      } = result[key];
      result[key] = {
        slicePlaneNormal,
        sliceViewUp,
        slicePlaneXRotation,
        slicePlaneYRotation,
        viewRotation,
        sliceThickness: _sliceThickness,
        blendMode: _blendMode,
        window
      };
      return result;
    }, Object.assign({}, this.mprViews));

    return {
      // interactorCenters: { top: [0, 0], left: [0, 0], front: [0, 0] },
      interactorCenters: Object.keys(this.elements).reduce(
        (res, key) => ({ ...res, [key]: [0, 0] }),
        {}
      ),
      sliceIntersection: [...this.sliceIntersection], // clone
      views: viewsState
    };
  }

  /**
   * Set the image to render
   * @param {State} state - The current manager state
   * @param {Array} image - The pixel data from DICOM serie
   */
  setImage(state, image) {
    let actor = createVolumeActor(image);
    this.volume = actor;
    this.sliceIntersection = getVolumeCenter(actor.getMapper());
    // update external state
    state.sliceIntersection = [...this.sliceIntersection];

    Object.keys(this.elements).forEach(key => {
      this.mprViews[key].initView(
        actor,
        state,
        // on scroll callback (it's fired but too early)
        () => {
          this.onScrolled.call(this, state);
        },
        // on initialized callback (fire when all is set)
        () => {
          this.onScrolled.call(this, state);
        }
      );
    });

    if (this._activeTool) {
      this.setTool(this._activeTool, state);
    }
  }

  /**
   * Set the active tool
   * @param {String} toolName - "level" or "crosshair"
   * @param {State} state - The current manager state
   */
  setTool(toolName, state) {
    switch (toolName) {
      case "level":
        this.setLevelTool(state);
        break;
      case "crosshair":
        this.setCrosshairTool(state);
        break;
      case "zoom":
        this.setZoomTool(state);
        break;
      case "pan":
        this.setPanTool(state);
        break;
    }
  }

  /**
   * Set "pan" as active tool
   * @private
   * @param {State} state - The current manager state
   */
  setPanTool(state) {
    Object.entries(state.views).forEach(([key]) => {
      const istyle = vtkInteractorStyleMPRPanZoom.newInstance({
        leftButtonTool: "pan"
      });
      istyle.setOnScroll(() => {
        this.onScrolled(state);
      });
      // update interactor center
      istyle.setOnPanChanged(() => {
        this.updateInteractorCenters(state);
      });
      this.mprViews[key].setInteractor(istyle);
    });
    this._activeTool = "pan";
  }

  /**
   * Set "zoom" as active tool
   * @private
   * @param {State} state - The current manager state
   */
  setZoomTool(state) {
    Object.entries(state.views).forEach(([key]) => {
      const istyle = vtkInteractorStyleMPRPanZoom.newInstance({
        leftButtonTool: "zoom"
      });
      istyle.setOnScroll(() => {
        this.onScrolled(state);
      });
      // update interactor center
      istyle.setOnZoomChanged(() => {
        this.updateInteractorCenters(state);
      });
      this.mprViews[key].setInteractor(istyle);
    });
    this._activeTool = "zoom";
  }

  /**
   * Set "level" as active tool
   * @private
   * @param {State} state - The current manager state
   */
  setLevelTool(state) {
    Object.entries(state.views).forEach(([key]) => {
      const istyle = vtkInteractorStyleMPRWindowLevel.newInstance();
      istyle.setOnScroll(() => {
        this.onScrolled(state);
      });
      istyle.setOnLevelsChanged(levels => {
        this.updateLevels({ ...levels, srcKey: key }, state);
      });
      this.mprViews[key].setInteractor(istyle);
    });
    this._activeTool = "level";
  }

  /**
   * Set "crosshair" as active tool
   * @private
   * @param {State} state - The current manager state
   */
  setCrosshairTool(state) {
    let self = this;
    Object.entries(state.views).forEach(([key]) => {
      const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
      istyle.setOnScroll(() => {
        self.onScrolled(state);
      });
      istyle.setOnClickCallback(({ worldPos }) => {
        self.onCrosshairPointSelected({ worldPos, srcKey: key }, state);
      });
      this.mprViews[key].setInteractor(istyle);
    });
    this._activeTool = "crosshair";
  }

  /**
   * Update slice positions on user interaction (for crosshair tool)
   * @private
   * @param {Object} {}
   */
  onCrosshairPointSelected({ srcKey, worldPos }, externalState) {
    Object.keys(this.elements).forEach(key => {
      if (key !== srcKey) {
        // We are basically doing the same as getSlice but with the world coordinate
        // that we want to jump to instead of the camera focal point.
        // I would rather do the camera adjustment directly but I keep
        // doing it wrong and so this is good enough for now.
        // ~ swerik
        const renderWindow = this.mprViews[
          key
        ]._genericRenderWindow.getRenderWindow();

        const istyle = renderWindow.getInteractor().getInteractorStyle();
        const sliceNormal = istyle.getSliceNormal();
        const transform = vtkMatrixBuilder
          .buildFromDegree()
          .identity()
          .rotateFromDirections(sliceNormal, [1, 0, 0]);

        const mutatedWorldPos = worldPos.slice();
        transform.apply(mutatedWorldPos);
        const slice = mutatedWorldPos[0];

        istyle.setSlice(slice);

        renderWindow.render();
      }

      this.updateInteractorCenters(externalState);
    });

    // update both internal & external state
    this.sliceIntersection = [...worldPos];
    externalState.sliceIntersection = [...worldPos];
  }

  /**
   * Update wwwl on user interaction (for level tool)
   * @private
   * @param {Object} {}
   * @param {State} state - The current manager state
   */
  updateLevels({ windowCenter, windowWidth, srcKey }, state) {
    state.views[srcKey].window.center = windowCenter;
    state.views[srcKey].window.width = windowWidth;

    if (this.syncWindowLevels) {
      Object.keys(this.elements)
        .filter(key => key !== srcKey)
        .forEach(k => {
          this.mprViews[k].wwwl = [windowWidth, windowCenter];
        });
    }
  }

  /**
   * Update slice position when scrolling
   * @private
   */
  onScrolled(state) {
    let planes = [];

    Object.keys(this.elements).forEach(key => {
      const camera = this.mprViews[key].camera;
      planes.push({
        position: camera.getFocalPoint(),
        normal: camera.getDirectionOfProjection()
        // this[viewportIndex].slicePlaneNormal
      });
    });

    const newPoint = getPlaneIntersection(...planes);

    if (
      !Number.isNaN(newPoint) &&
      !newPoint.some(coord => Number.isNaN(coord))
    ) {
      this.sliceIntersection = [...newPoint];
      state.sliceIntersection = [...newPoint];
      if (this.VERBOSE) console.log("updating slice intersection", newPoint);
    }

    this.updateInteractorCenters(state);

    return newPoint;
  }

  /**
   * Update slice planes on rotation
   * @param {String} key - One of the initially provided keys (identify a view)
   * @param {String} axis - 'x' or 'y' axis
   * @param {Number} angle - The amount of rotation [deg], absolute
   * @param {State} state - The current manager state
   */
  onRotate(key, axis, angle, state) {
    // Match the source axis to the associated plane
    switch (key) {
      case "top":
        if (axis === "x") state.views.front.slicePlaneYRotation = angle;
        else if (axis === "y") state.views.left.slicePlaneYRotation = angle;
        break;
      case "left":
        if (axis === "x") state.views.top.slicePlaneXRotation = angle;
        else if (axis === "y") state.views.front.slicePlaneXRotation = angle;
        break;
      case "front":
        if (axis === "x") state.views.top.slicePlaneYRotation = angle;
        else if (axis === "y") state.views.left.slicePlaneXRotation = angle;
        break;
    }

    // dv: this was a watcher in mpr component, update all except myself ?

    Object.keys(this.elements)
      .filter(c => c !== key)
      .forEach(k => {
        this.mprViews[k].updateSlicePlane(state.views[k]);
      });

    if (this.VERBOSE) console.log("afterOnRotate", state);
  }

  /**
   * Update slice planes on rotation
   * @param {String} key - One of the initially provided keys (identify a view)
   * @param {String} axis - 'x' or 'y' axis
   * @param {Number} thickness - The amount of thickness [px], absolute
   * @param {State} state - The current manager state
   */
  onThickness(key, axis, thickness, state) {
    const shouldBeMIP = thickness > 1;
    let target_view;
    switch (key) {
      case "top":
        if (axis === "x") target_view = "front";
        else if (axis === "y") target_view = "left";
        break;
      case "left":
        if (axis === "x") target_view = "top";
        else if (axis === "y") target_view = "front";
        break;
      case "front":
        if (axis === "x") target_view = "top";
        else if (axis === "y") target_view = "left";
        break;
    }

    // if thickness > 1 switch to MIP
    if (shouldBeMIP && this.mprViews[target_view].blendMode === "none") {
      this.mprViews[target_view].blendMode = "MIP";
      state.mprViews[target_view].blendMode = "MIP";
    }

    // update both internal and external state
    this.mprViews[target_view].sliceThickness = thickness;
    state.views[target_view].sliceThickness = thickness;
  }

  /**
   * Update interactor centers coordinates on canvas
   * @private
   * @param {State} state - The current manager state
   */
  updateInteractorCenters(state) {
    Object.keys(this.elements).forEach(key => {
      // compute interactor centers display position
      const renderer = this.mprViews[key]._genericRenderWindow.getRenderer();
      const wPos = vtkCoordinate.newInstance();
      wPos.setCoordinateSystemToWorld();
      wPos.setValue(...this.sliceIntersection);
      const displayPosition = wPos.getComputedDisplayValue(renderer);
      if (this.VERBOSE) console.log("interactor center", key, displayPosition);
      // set new interactor center on canvas into external state
      state.interactorCenters[key] = displayPosition;
    });
  }

  /**
   * Force views resize
   * @param {String} key - If provided, resize just its view, otherwise all views
   */
  resize(state, key) {
    if (key) {
      this.mprViews[key].onResize();
    } else {
      Object.values(this.mprViews).forEach(view => {
        view.onResize();
      });
    }
    this.updateInteractorCenters(state);
  }

  /**
   * Destroy webgl content and release listeners
   */
  destroy() {
    Object.keys(this.elements).forEach(k => {
      this.mprViews[k].destroy();
    });
  }
}