vrView.js

import vtkGenericRenderWindow from "@kitware/vtk.js/Rendering/Misc/GenericRenderWindow";
import vtkColorTransferFunction from "@kitware/vtk.js/Rendering/Core/ColorTransferFunction";
import vtkPiecewiseFunction from "@kitware/vtk.js/Common/DataModel/PiecewiseFunction";
import vtkColorMaps from "@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps";

import vtkMouseCameraTrackballRotateManipulator from "@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballRotateManipulator";
import vtkMouseCameraTrackballPanManipulator from "@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballPanManipulator";
import vtkMouseCameraTrackballZoomManipulator from "@kitware/vtk.js/Interaction/Manipulators/MouseCameraTrackballZoomManipulator";
import vtkMouseRangeManipulator from "@kitware/vtk.js/Interaction/Manipulators/MouseRangeManipulator";
import vtkInteractorStyleManipulator from "@kitware/vtk.js/Interaction/Style/InteractorStyleManipulator";

import vtkPointPicker from "@kitware/vtk.js/Rendering/Core/PointPicker";
import vtkCoordinate from "@kitware/vtk.js/Rendering/Core/Coordinate";
import vtkSphereSource from "@kitware/vtk.js/Filters/Sources/SphereSource";
import vtkLineSource from "@kitware/vtk.js/Filters/Sources/LineSource";
import vtkActor from "@kitware/vtk.js/Rendering/Core/Actor";
import vtkImageMapper from "@kitware/vtk.js/Rendering/Core/ImageMapper";
import vtkImageReslice from "@kitware/vtk.js/Imaging/Core/ImageReslice";
import vtkImageSlice from "@kitware/vtk.js/Rendering/Core/ImageSlice";
import vtkImageProperty from "@kitware/vtk.js/Rendering/Core/ImageProperty";
import vtkPlane from "@kitware/vtk.js/Common/DataModel/Plane";
import vtkImplicitPlaneWidget from "@kitware/vtk.js/Widgets/Widgets3D/ImplicitPlaneWidget";
import vtkWidgetManager from "@kitware/vtk.js/Widgets/Core/WidgetManager";
import { dot, cross, normalize } from "@kitware/vtk.js/Common/Core/Math";
import { InterpolationType } from "@kitware/vtk.js/Rendering/Core/ImageProperty/Constants";
import vtkMapper from "@kitware/vtk.js/Rendering/Core/Mapper";

import {
  createVolumeActor,
  setupPGwidget,
  setCamera,
  setActorProperties,
  setupCropWidget,
  setupPickingPlane,
  getRelativeRange,
  createSurfaceActor
} from "./utils/utils";
import { applyStrategy } from "./utils/strategies";
import { createPreset } from "./utils/colormaps";
import { getRenderPass } from "./renderPasses";

import { baseView } from "./baseView";

// Add custom presets
vtkColorMaps.addPreset(createPreset());

/** A class representing a Volume Rendering scene */
export class VRView extends baseView {
  /**
   * Create a volume rendering scene
   * @param {HTMLElement} element - the target html element to render the scene
   */
  constructor(element) {
    super();

    this.VERBOSE = false;

    this._element = element;
    this._renderer = null;
    this._renderWindow = null;
    this._genericRenderWindow = null;
    this._actor = null;
    this._raysDistance = null;
    this._blurOnInteraction = null;

    // piecewise gaussian widget stuff
    this._PGwidgetElement = null;
    this._PGwidget = null;
    this._gaussians = null;
    this._PGwidgetLoaded = false;

    // crop widget
    this._cropWidget = null;

    // normalized ww wl
    this._ww = 0.1;
    this._wl = 0.4;

    // absolute ww wl
    this._wwwl = [0, 0];

    // LUT options
    this._rangeLUT = null;
    this._rescaleLUT = false; // cannot initialize true (must set lut before)

    // rendering passes
    this._edgeEnhancement = false;

    // measurement state
    this._measurementState = null;

    // picking state
    this._pickCb = null;
    this._pickOptions = null;
    this._pickingEnabled = false;

    // surfaces
    this._surfaces = new Map();

    // landmarks
    this._landmarks = new Map();
    this._highlightLineX = null;
    this._highlightLineY = null;
    this._highlightActorY = null;
    this._highlightActorX = null;
    this._highlightActorY = null;

    // slice plane
    this._slicePlane = null;
    this._reslice = null;
    this._sliceMapper = null;
    this._sliceActor = null;
    this._sliceProperty = null;

    this._widgetManager = null;
    this._sliceWidget = null;
    this._sliceWidgetInstance = null;

    // initialize empty scene
    this._init();
  }

  // ===========================================================
  // ====== setters & getters ==================================
  // ===========================================================

  /**
   * wwwl
   * @type {Array}
   */
  set wwwl(value) {
    if (!this._actor) {
      return;
    }

    let relativeWwwl = getRelativeRange(this._actor, value);

    this._wl = relativeWwwl.wl;
    this._ww = relativeWwwl.ww;

    if (this._PGwidget) {
      this._updateWidget();
      this._setWidgetCallbacks();
    }
  }

  get wwwl() {
    let absoluteWwwl = getAbsoluteRange(this._actor, [this._ww, this._wl]);
    return [absoluteWwwl.ww, absoluteWwwl.wl];
  }

  /**
   * raysDistance
   * @type {Number}
   */
  set resolution(value) {
    this._raysDistance = 1 / value;
    this._actor.getMapper().setSampleDistance(this._raysDistance);
    let maxSamples = value > 1 ? value * 1000 : 1000;
    this._actor.getMapper().setMaximumSamplesPerRay(maxSamples);
    this._renderWindow.render();
  }

  get resolution() {
    return Math.round(1 / this._raysDistance);
  }

  /**
   * Presets
   * @type {Array}
   */
  get presetsList() {
    return vtkColorMaps.rgbPresetNames;
  }

  /**
   * PGwidgetElement (set null to hide)
   * @type {HTMLelement}
   */
  set widgetElement(element) {
    // initalize piecewise gaussian widget
    this._PGwidgetElement = element;
    if (!this._PGwidget) {
      this._PGwidget = setupPGwidget(this._PGwidgetElement);
    }
    let h = element.offsetHeight ? element.offsetHeight - 5 : 100;
    let w = element.offsetWidth ? element.offsetWidth - 5 : 300;
    this._PGwidget.setSize(w, h);
    this._PGwidget.setContainer(this._PGwidgetElement);
    this._PGwidget.render();
  }

  /**
   * Flag to set lut rescaling on opacity range
   * @type {bool}
   */
  set rescaleLUT(bool) {
    this._rescaleLUT = bool;
    let range;
    if (this._rescaleLUT && this._PGwidget) {
      range = this._PGwidget.getOpacityRange();
    } else {
      range = this._actor
        .getMapper()
        .getInputData()
        .getPointData()
        .getScalars()
        .getRange();
    }
    this.ctfun.setMappingRange(...range);
    this.ctfun.updateRange();
  }

  /**
   * Set range to apply lut  !!! WIP
   * @type {Array}
   */
  set rangeLUT([min, max]) {
    this._rangeLUT = [min, max];
    this._actor
      .getProperty()
      .getRGBTransferFunction(0)
      .setMappingRange(min, max);
  }

  /**
   * Crop widget on / off
   * @type {bool}
   */
  set cropWidget(visible) {
    if (!this._cropWidget) this._initCropWidget();
    this._cropWidget.setVisibility(visible);
    this._widgetManager.renderWidgets();
    this._renderWindow.render();
  }

  /**
   * Set colormap and opacity function
   * lutName - as in presets list
   * @type {String}
   */
  set lut(lutName) {
    // set up color transfer function
    const lookupTable = vtkColorTransferFunction.newInstance();
    lookupTable.applyColorMap(vtkColorMaps.getPresetByName(lutName));

    // update lookup table mapping range based on input dataset
    let range;

    if (this._rescaleLUT && this._PGwidgetLoaded) {
      range = this._PGwidget.getOpacityRange();
    } else {
      range = this._actor
        .getMapper()
        .getInputData()
        .getPointData()
        .getScalars()
        .getRange();
    }

    // TODO a function to set custom mapping range (unbind from opacity)
    lookupTable.setMappingRange(...range);
    lookupTable.updateRange();

    this._actor.getProperty().setRGBTransferFunction(0, lookupTable);

    // setup opacity function (values will be set by PGwidget)
    const piecewiseFun = vtkPiecewiseFunction.newInstance();
    this._actor.getProperty().setScalarOpacity(0, piecewiseFun);

    this.ctfun = lookupTable;
    this.ofun = piecewiseFun;

    if (this._PGwidget) {
      this._updateWidget();
      this._setWidgetCallbacks();
    }
  }

  /**
   * Toggle blurring on interaction (Increase performance)
   * @type {bool} toggle - if true, blur on interaction
   */
  set blurOnInteraction(toggle) {
    this._blurOnInteraction = toggle;
    let interactor = this._renderWindow.getInteractor();
    let mapper = this._actor.getMapper();

    if (toggle) {
      interactor.onLeftButtonPress(() => {
        mapper.setSampleDistance(this._raysDistance * 5);
      });

      interactor.onLeftButtonRelease(() => {
        mapper.setSampleDistance(this._raysDistance);
        // update picking plane
        let camera = this._renderer.getActiveCamera();
        if (this._pickingPlane)
          this._pickingPlane.setNormal(camera.getDirectionOfProjection());
        this._renderWindow.render();
      });
    } else {
      interactor.onLeftButtonPress(() => {
        mapper.setSampleDistance(this._raysDistance);
      });

      interactor.onLeftButtonRelease(() => {
        mapper.setSampleDistance(this._raysDistance);
        // update picking plane
        let camera = this._renderer.getActiveCamera();
        if (this._pickingPlane)
          this._pickingPlane.setNormal(camera.getDirectionOfProjection());
        this._renderWindow.render();
      });
    }
  }

  /**
   * Toggle slice plane visibility
   * @type {bool}
   */
  set slicePlane(visible) {
    if (!this._sliceActor) this._initSlicePlane();
    if (!this._sliceWidget) this._initSliceWidget();

    this._sliceActor.setVisibility(visible);
    if (this._sliceWidgetInstance) {
      this._sliceWidgetInstance.setEnabled(visible);
    }
    this._widgetManager.renderWidgets();
    this._renderWindow.render();
  }

  /**
   * Set slice opacity
   * @type {Number}
   */
  set sliceOpacity(opacity) {
    if (!this._sliceActor) this._initSlicePlane();
    this._sliceProperty.setOpacity(opacity);
    this._renderWindow.render();
  }

  get sliceOpacity() {
    return this._sliceProperty ? this._sliceProperty.getOpacity() : 1;
  }

  /**
   * sliceWwwl
   * @type {Array}
   */
  set sliceWwwl([ww, wl]) {
    if (!this._sliceActor) this._initSlicePlane();
    this._sliceProperty.setColorWindow(ww);
    this._sliceProperty.setColorLevel(wl);
    this._renderWindow.render();
  }

  get sliceWwwl() {
    if (!this._sliceProperty) return [0, 0];
    return [
      this._sliceProperty.getColorWindow(),
      this._sliceProperty.getColorLevel()
    ];
  }

  /**
   * Set slice position [0, 1] along its normal
   * @type {Number}
   */
  set slicePosition(value) {
    if (!this._slicePlane || !this._actor) return;

    const bounds = this._actor.getBounds();
    const normal = this._slicePlane.getNormal();

    // Compute min/max distance along normal for all 8 corners
    let minD = Infinity;
    let maxD = -Infinity;

    for (let i = 0; i < 8; i++) {
      const corner = [
        bounds[i % 2],
        bounds[2 + (Math.floor(i / 2) % 2)],
        bounds[4 + (Math.floor(i / 4) % 2)]
      ];
      const d = dot(corner, normal);
      if (d < minD) minD = d;
      if (d > maxD) maxD = d;
    }

    const targetD = minD + value * (maxD - minD);
    const currentOrigin = this._slicePlane.getOrigin();
    const currentD = dot(currentOrigin, normal);

    // Move origin along normal to reach targetD
    const offset = targetD - currentD;
    const newOrigin = [
      currentOrigin[0] + offset * normal[0],
      currentOrigin[1] + offset * normal[1],
      currentOrigin[2] + offset * normal[2]
    ];

    this.setSlicePlane(newOrigin);
  }

  get slicePosition() {
    if (!this._slicePlane || !this._actor) return 0.5;

    const bounds = this._actor.getBounds();
    const normal = this._slicePlane.getNormal();
    const origin = this._slicePlane.getOrigin();

    let minD = Infinity;
    let maxD = -Infinity;

    for (let i = 0; i < 8; i++) {
      const corner = [
        bounds[i % 2],
        bounds[2 + (Math.floor(i / 2) % 2)],
        bounds[4 + (Math.floor(i / 4) % 2)]
      ];
      const d = dot(corner, normal);
      if (d < minD) minD = d;
      if (d > maxD) maxD = d;
    }

    const currentD = dot(origin, normal);
    const pos = (currentD - minD) / (maxD - minD);
    return Math.max(0, Math.min(1, pos));
  }

  /**
   * Set slice plane origin and normal
   * @param {Array} origin
   * @param {Array} normal
   */
  setSlicePlane(origin, normal) {
    if (!this._sliceActor) this._initSlicePlane();
    if (!this._sliceWidget) this._initSliceWidget();

    if (origin) this._slicePlane.setOrigin(origin);
    if (normal) this._slicePlane.setNormal(normal);

    // update reslice axes
    this._updateResliceAxes();

    const planeState = this._sliceWidget.getWidgetState();
    planeState.setOrigin(this._slicePlane.getOrigin());
    planeState.setNormal(this._slicePlane.getNormal());

    this._renderWindow.render();
  }

  /**
   * Get slice plane origin and normal
   * @returns {Object} {origin, normal}
   */
  getSlicePlane() {
    if (!this._slicePlane) return null;
    return {
      origin: this._slicePlane.getOrigin(),
      normal: this._slicePlane.getNormal()
    };
  }

  /**
   * Toggle edge enhancement
   */
  set edgeEnhancement([type, value]) {
    let renderPass = getRenderPass(type, value);
    let view = this._renderWindow.getViews()[0];
    view.setRenderPasses([renderPass]);
    this._renderWindow.render();
  }

  // ===========================================================
  // ====== public methods =====================================
  // ===========================================================

  /**
   * Set the image to be rendered
   * @param {ArrayBuffer} image - The image content data as buffer array
   */
  setImage(image) {
    // clean scene
    this._renderer.removeAllVolumes();
    this._actor = createVolumeActor(image);
    this.lut = "Grayscale";
    this.resolution = 2;
    this._renderer.addVolume(this._actor);

    if (this._reslice) {
      const data = this._actor.getMapper().getInputData();
      this._reslice.setInputData(data);
      const center = this._actor.getCenter();
      this._slicePlane.setOrigin(center);
      this._updateResliceAxes();

      // initialize slice wwwl based on data range
      const range = data.getPointData().getScalars().getRange();
      this.sliceWwwl = [range[1] - range[0], (range[0] + range[1]) / 2];

      if (this._sliceWidget) {
        this._sliceWidget.placeWidget(this._actor.getBounds());
        const planeState = this._sliceWidget.getWidgetState();
        planeState.setOrigin(this._slicePlane.getOrigin());
        planeState.setNormal(this._slicePlane.getNormal());
      }
    }

    // center camera on new volume
    this._renderer.resetCamera();
    setCamera(this._renderer.getActiveCamera(), this._actor.getCenter());

    if (this._PGwidget) {
      this._updateWidget();
      this._setWidgetCallbacks();
    }

    // TODO if crop widget, update to new image (or set to null so that it will be initialized again)

    // TODO implement a strategy to set rays distance
    setActorProperties(this._actor);

    this._setupInteractor();

    this.blurOnInteraction = true;

    this._genericRenderWindow.resize();
    this._renderWindow.render();
  }

  // This is another method, providing the url
  //   reader.setUrl("./demo/die.stl").then(data => {
  //     console.log("read", data);
  //     const mapper = vtkMapper.newInstance({ scalarVisibility: false });
  //      ... etc ...
  //   });

  /**
   * Add surfaces to be rendered
   * @param {Object} - {buffer: bufferarray, fileType?: string, props: Object}
   * Props contains color, label, opacity, wireframe
   */
  addSurface({ buffer, fileType, props }) {
    if (this._surfaces.has(props.label)) {
      console.warn(
        `DTK: A surface with label ${props.label} is already present. I will ignore this.`
      );
      return;
    }

    const surfaceData = createSurfaceActor(buffer, fileType);
    if (!surfaceData) {
      return;
    }

    const { actor } = surfaceData;

    const properties = actor.getProperty();
    properties.setColor(...props.color);
    properties.setOpacity(props.opacity || 1);
    this._surfaces.set(props.label, actor);

    this._renderer.addActor(actor);
    this._renderer.resetCamera();
    this._renderWindow.render();
  }

  /**
   * Add landmarks to be rendered as spheres
   * @param {Array} landmarks - [{label, x, y, z, color, radius}]
   * Use replaceLandmarks() to replace existing landmarks.
   * @param {Boolean} render - Optional render toggle (default true)
   */
  addLandmarks(landmarks, render = true) {
    landmarks.forEach(landmark => {
      if (this._landmarks.has(landmark.label)) {
        console.warn(
          `DTK: A landmark with label ${landmark.label} is already present. I will ignore this.`
        );
        return;
      }

      const sphereSource = vtkSphereSource.newInstance();
      sphereSource.setCenter(landmark.x, landmark.y, landmark.z);
      sphereSource.setRadius(landmark.radius || 1.0);

      const mapper = vtkMapper.newInstance();
      mapper.setInputConnection(sphereSource.getOutputPort());

      const actor = vtkActor.newInstance();
      actor.setMapper(mapper);
      actor.getProperty().setColor(landmark.color);

      this._landmarks.set(landmark.label, { actor, sphereSource });
      this._renderer.addActor(actor);
    });

    if (render) {
      this._renderWindow.render();
    }
  }

  /**
   * Remove all landmarks from the scene
   * @param {Boolean} render - Optional render toggle (default true)
   */
  resetLandmarks(render = true) {
    if (!this._renderer || !this._renderWindow || !this._landmarks) {
      return;
    }

    // Ensure any highlight indicator is cleared when landmarks are reset.
    this.clearHighlightedLandmark(false);

    this._landmarks.forEach(({ actor, sphereSource }) => {
      if (actor) {
        this._renderer.removeActor(actor);
        const mapper = actor.getMapper();
        if (mapper) {
          mapper.delete();
        }
        actor.delete();
      }

      if (sphereSource) {
        sphereSource.delete();
      }
    });

    this._landmarks.clear();
    if (render) {
      this._renderWindow.render();
    }
  }

  /**
   * Replace all landmarks in a single render pass
   * @param {Array} landmarks - [{label, x, y, z, color, radius}]
   */
  replaceLandmarks(landmarks) {
    this.resetLandmarks(false);
    this.addLandmarks(landmarks, false);
    if (this._renderWindow) {
      this._renderWindow.render();
    }
  }

  /**
   * Highlight a landmark with a red cross indicator
   * @param {String|Object} labelOrId - Landmark label or landmark-like object
   * @param {Object} options
   * @param {Number} options.radius - Override landmark radius
   * @param {Number} options.lengthFactor - Scale factor for cross length (default 4)
   */
  setHighlightedLandmark(labelOrId, options = {}) {
    if (!this._renderer || !this._renderWindow) {
      return;
    }

    let landmark = null;
    let landmarkRadius = null;
    if (labelOrId && typeof labelOrId === "object") {
      if (
        Number.isFinite(labelOrId.x) &&
        Number.isFinite(labelOrId.y) &&
        Number.isFinite(labelOrId.z)
      ) {
        landmark = labelOrId;
        if (Number.isFinite(labelOrId.radius)) {
          landmarkRadius = labelOrId.radius;
        }
      } else if (
        labelOrId.label &&
        this._landmarks &&
        this._landmarks.has(labelOrId.label)
      ) {
        const entry = this._landmarks.get(labelOrId.label);
        const [x, y, z] = entry.sphereSource.getCenter();
        landmark = { x, y, z };
        landmarkRadius = entry.sphereSource.getRadius();
      }
    } else if (this._landmarks && this._landmarks.has(labelOrId)) {
      const entry = this._landmarks.get(labelOrId);
      const [x, y, z] = entry.sphereSource.getCenter();
      landmark = { x, y, z };
      landmarkRadius = entry.sphereSource.getRadius();
    }

    if (
      !landmark ||
      !Number.isFinite(landmark.x) ||
      !Number.isFinite(landmark.y) ||
      !Number.isFinite(landmark.z)
    ) {
      this.clearHighlightedLandmark();
      return;
    }

    if (!this._highlightLineX || !this._highlightLineY) {
      const lineX = vtkLineSource.newInstance();
      const lineY = vtkLineSource.newInstance();

      const mapperX = vtkMapper.newInstance();
      const mapperY = vtkMapper.newInstance();
      mapperX.setInputConnection(lineX.getOutputPort());
      mapperY.setInputConnection(lineY.getOutputPort());

      const actorX = vtkActor.newInstance();
      const actorY = vtkActor.newInstance();
      actorX.setMapper(mapperX);
      actorY.setMapper(mapperY);

      actorX.getProperty().setColor(1, 0, 0);
      actorY.getProperty().setColor(1, 0, 0);
      actorX.getProperty().setLineWidth(3);
      actorY.getProperty().setLineWidth(3);
      actorX.setVisibility(false);
      actorY.setVisibility(false);

      this._renderer.addActor(actorX);
      this._renderer.addActor(actorY);

      this._highlightLineX = lineX;
      this._highlightLineY = lineY;
      this._highlightActorX = actorX;
      this._highlightActorY = actorY;
    }

    const baseRadius = Number.isFinite(options.radius)
      ? options.radius
      : Number.isFinite(landmarkRadius)
        ? landmarkRadius
        : Number.isFinite(landmark.radius)
          ? landmark.radius
          : 1;
    const lengthFactor = Number.isFinite(options.lengthFactor)
      ? options.lengthFactor
      : 4;
    const length = Math.max(baseRadius * lengthFactor, 1);

    this._highlightLineX.setPoint1(landmark.x - length, landmark.y, landmark.z);
    this._highlightLineX.setPoint2(landmark.x + length, landmark.y, landmark.z);
    this._highlightLineY.setPoint1(landmark.x, landmark.y - length, landmark.z);
    this._highlightLineY.setPoint2(landmark.x, landmark.y + length, landmark.z);

    this._highlightActorX.setVisibility(true);
    this._highlightActorY.setVisibility(true);
    this._renderWindow.render();
  }

  /**
   * Hide highlighted landmark indicator
   * @param {Boolean} render - Optional render toggle (default true)
   */
  clearHighlightedLandmark(render = true) {
    if (!this._highlightActorX || !this._highlightActorY) {
      return;
    }

    this._highlightActorX.setVisibility(false);
    this._highlightActorY.setVisibility(false);
    if (render && this._renderWindow) {
      this._renderWindow.render();
    }
  }

  /**
   * Get current landmarks state (for debug)
   * @returns {Array} - [{label, x, y, z, color, radius}]
   */
  getLandmarks() {
    if (!this._landmarks) {
      return [];
    }

    const result = [];
    this._landmarks.forEach(({ actor, sphereSource }, label) => {
      const [x, y, z] = sphereSource.getCenter();
      const radius = sphereSource.getRadius();
      const color = actor.getProperty().getColor();
      result.push({ label, x, y, z, color, radius });
    });

    return result;
  }

  /**
   * Update the position of an existing landmark.
   * @param {String} label - The label of the landmark.
   * @param {Array<Number>} position - The new position as [x, y, z].
   */
  updateLandmarkPosition(label, [x, y, z]) {
    const landmark = this._landmarks.get(label);
    if (!landmark) {
      console.warn(`DTK: No landmark found with label ${label} to update.`);
      return;
    }

    const { sphereSource } = landmark;
    sphereSource.setCenter(x, y, z);
    this._renderWindow.render();
  }

  /**
   * Toggle surface visibility on/off
   * @param {String} label - The string that identifies the surface
   * @param {Boolean} toggle
   */
  setSurfaceVisibility(label, toggle) {
    this._surfaces.get(label).setVisibility(toggle);
    this._renderWindow.render();
  }

  /**
   * Update surface buffer
   * TODO maybe there is a more efficient way
   * @param {String} label - The string that identifies the surface
   * @param {ArrayBuffer} buffer
   * @param {String} fileType - Optional file type ('stl' or 'vtp')
   */
  updateSurface(label, buffer, fileType) {
    const actor = this._surfaces.get(label);
    if (!actor) {
      console.warn(`DTK: No surface found with label ${label}`);
      return;
    }

    // Get the current color from the existing actor
    const currentColor = actor.getProperty().getColor();

    const surfaceData = createSurfaceActor(buffer, fileType);
    if (!surfaceData) {
      return; // Error already logged in createSurfaceActor
    }

    const { mapper } = surfaceData;
    actor.setMapper(mapper);
    actor.getProperty().setColor(currentColor);

    this._renderer.resetCamera();
    this._renderWindow.render();
  }

  /**
   * Get vtk LUTs list
   * @returns {Array} - Lut list as array of strings
   */
  getLutList() {
    return vtkColorMaps.rgbPresetNames;
  }

  /**
   * Reset measurement state to default
   * @param {*} measurementState
   */
  resetMeasurementState(state) {
    if (this._measurementState) {
      this._measurementState.p1 = new Array(2);
      this._measurementState.p2 = new Array(2);
      this._measurementState.p3 = new Array(2);
      this._measurementState.p1_world = new Array(2);
      this._measurementState.p2_world = new Array(2);
      this._measurementState.p3_world = new Array(2);
      this._measurementState.label = null;
    } else if (state) {
      state.p1 = new Array(2);
      state.p2 = new Array(2);
      state.p3 = new Array(2);
      state.p1_world = new Array(2);
      state.p2_world = new Array(2);
      state.p3_world = new Array(2);
      state.label = null;
    }
  }

  /**
   * Set active tool
   * ("Length/Angle", {mouseButtonMask:1}, measurementState)
   * @param {*} toolName
   * @param {*} options
   * @param {*} measurementState
   */
  setTool(toolName, options, measurementState) {
    if (this._leftButtonCb) {
      this._leftButtonCb.unsubscribe();
    }

    switch (toolName) {
      case "Length":
        this._initPicker(measurementState, toolName);
        break;
      case "Angle":
        this._initPicker(measurementState, toolName);
        break;
      case "Rotation":
        this.resetMeasurementState(measurementState);
        this._setupInteractor();
        break;
      default:
        console.warn("No tool found for", toolName);
    }
  }

  /**
   * Reset view
   */
  resetView() {
    let center = this._actor.getCenter();
    let camera = this._renderer.getActiveCamera();
    setCamera(camera, center);
    this._renderWindow.render();
  }

  /**
   * on resize callback
   */
  resize() {
    // TODO: debounce for performance reasons?
    this._genericRenderWindow.resize();
  }

  /**
   * Destroy webgl content and release listeners
   */
  destroy() {
    this._element = null;
    this._genericRenderWindow.delete();
    this._genericRenderWindow = null;

    if (this._actor) {
      this._actor.getMapper().delete();
      this._actor.delete();
      this._actor = null;
    }

    if (this._planeActor) {
      this._planeActor.getMapper().delete();
      this._planeActor.delete();
      this._planeActor = null;
    }

    if (this._highlightActorX) {
      this._highlightActorX.getMapper().delete();
      this._highlightActorX.delete();
      this._highlightActorX = null;
    }
    if (this._highlightActorY) {
      this._highlightActorY.getMapper().delete();
      this._highlightActorY.delete();
      this._highlightActorY = null;
    }
    if (this._highlightLineX) {
      this._highlightLineX.delete();
      this._highlightLineX = null;
    }
    if (this._highlightLineY) {
      this._highlightLineY.delete();
      this._highlightLineY = null;
    }

    this._landmarks.forEach(({ actor, sphereSource }) => {
      actor.getMapper().delete();
      actor.delete();
      sphereSource.delete();
    });
    this._landmarks.clear();
    this._landmarks = null;

    if (this._PGwidgetElement) {
      this._PGwidgetElement = null;
      this._PGwidget.getCanvas().remove();
      this._PGwidget.delete();
      this._PGwidget = null;
      this._gaussians = null;
    }

    if (this._cropWidget) {
      this._cropWidget.delete();
      this._cropWidget = null;
    }

    if (this._sliceActor) {
      this._sliceActor.getMapper().delete();
      this._sliceActor.delete();
      this._sliceActor = null;
    }

    if (this._sliceMapper) {
      this._sliceMapper.delete();
      this._sliceMapper = null;
    }

    if (this._reslice) {
      this._reslice.delete();
      this._reslice = null;
    }

    if (this._slicePlane) {
      this._slicePlane.delete();
      this._slicePlane = null;
    }

    if (this._sliceWidget) {
      this._sliceWidget.delete();
      this._sliceWidget = null;
      this._widgetManager.delete();
      this._widgetManager = null;
    }
  }

  // ===========================================================
  // ====== private methods ====================================
  // ===========================================================

  /**
   * Initialize rendering scene
   * @private
   */
  _init() {
    const genericRenderWindow = vtkGenericRenderWindow.newInstance();
    genericRenderWindow.setContainer(this._element);
    genericRenderWindow.setBackground([0, 0, 0]);

    //add custom resize cb
    genericRenderWindow.onResize(() => {
      // bypass genericRenderWindow resize method (do not consider devicePixelRatio)
      // https://kitware.github.io/vtk-js/api/Rendering_Misc_GenericRenderWindow.html
      let size = [
        genericRenderWindow.getContainer().getBoundingClientRect().width,
        genericRenderWindow.getContainer().getBoundingClientRect().height
      ];
      genericRenderWindow.getRenderWindow().getViews()[0].setSize(size);

      if (this.VERBOSE) console.log("resize", size);
    });

    // resize callback
    window.addEventListener("resize", evt => {
      genericRenderWindow.resize();
    });

    genericRenderWindow.resize();

    this._renderer = genericRenderWindow.getRenderer();
    this._renderWindow = genericRenderWindow.getRenderWindow();
    this._genericRenderWindow = genericRenderWindow;
  }

  /**
   * Update the PGwidget after an image has been loaded
   * @private
   */
  _updateWidget() {
    const dataArray = this._actor
      .getMapper()
      .getInputData()
      .getPointData()
      .getScalars();

    this._PGwidget.setDataArray(dataArray.getData());

    let gaussians = this._PGwidget.getGaussians();

    if (gaussians.length > 0) {
      let gaussian = gaussians[0];

      gaussian.position = this._wl;
      gaussian.width = this._ww;

      this._PGwidget.setGaussians([gaussian]);
    } else {
      // TODO initilize in a smarter way
      const default_opacity = 1.0;
      const default_bias = 0.0; // xBias
      const default_skew = 1.8; // yBias
      this._PGwidget.addGaussian(
        this._wl,
        default_opacity,
        this._ww,
        default_bias,
        default_skew
      ); // x, y, ampiezza, sbilanciamento, andamento
    }

    this._PGwidget.applyOpacity(this.ofun);
    this._PGwidget.setColorTransferFunction(this.ctfun);
    this.ctfun.onModified(() => {
      this._PGwidget.render();
      this._renderWindow.render();
    });

    this._PGwidgetLoaded = true;
  }

  /**
   * Binds callbacks to user interactions on PGwidget
   * @private
   */
  _setWidgetCallbacks() {
    this._PGwidget.bindMouseListeners();

    this._PGwidget.onAnimation(start => {
      if (start) {
        this._renderWindow.getInteractor().requestAnimation(this._PGwidget);
      } else {
        this._renderWindow.getInteractor().cancelAnimation(this._PGwidget);
      }
    });

    this._PGwidget.onOpacityChange(widget => {
      this._PGwidget = widget;
      this._gaussians = widget.getGaussians().slice(); // store
      this._PGwidget.applyOpacity(this.ofun);
      if (!this._renderWindow.getInteractor().isAnimating()) {
        this._renderWindow.render();
      }

      if (this._rescaleLUT && this._PGwidget) {
        const range = this._PGwidget.getOpacityRange();
        this.ctfun.setMappingRange(...range);
        this.ctfun.updateRange();
      }
    });
  }

  /**
   * Setup crop widget
   */
  _initCropWidget() {
    let cropWidget = setupCropWidget(this._renderer, this._actor.getMapper());

    this._widgetManager = cropWidget.widgetManager;
    this._cropWidget = cropWidget.widget;

    this._renderWindow.render();
  }

  /**
   * Init interactor
   * @private
   */
  _setupInteractor() {
    // TODO setup from user
    const rotateManipulator =
      vtkMouseCameraTrackballRotateManipulator.newInstance({ button: 1 });
    const panManipulator = vtkMouseCameraTrackballPanManipulator.newInstance({
      button: 3,
      control: true
    });
    const zoomManipulator = vtkMouseCameraTrackballZoomManipulator.newInstance({
      button: 3,
      scrollEnabled: true
    });
    const rangeManipulator = vtkMouseRangeManipulator.newInstance({
      button: 1,
      shift: true
    });

    let self = this;

    function getWL() {
      return self._wl;
    }

    function getWW() {
      return self._ww;
    }

    function setWL(v) {
      self._wl = self._wl + (v - self._wl) / 25; // 25 is a tweaking parameter
      let gaussians = self._PGwidget.getGaussians().slice(); // NOTE: slice() to clone!
      gaussians[0].position = self._wl; //TODO: foreach
      self._PGwidget.setGaussians(gaussians);
    }

    function setWW(v) {
      self._ww = self._ww + (v - self._ww) / 5; // 5 is a tweaking parameter
      let gaussians = self._PGwidget.getGaussians().slice(); // NOTE: slice() to clone!
      gaussians[0].width = self._ww; //TODO: foreach
      self._PGwidget.setGaussians(gaussians);
    }

    rangeManipulator.setVerticalListener(-1, 1, 0.001, getWL, setWL);
    rangeManipulator.setHorizontalListener(0.1, 2.1, 0.001, getWW, setWW);

    const interactorStyle = vtkInteractorStyleManipulator.newInstance();
    interactorStyle.addMouseManipulator(rangeManipulator);
    interactorStyle.addMouseManipulator(rotateManipulator);
    interactorStyle.addMouseManipulator(panManipulator);
    interactorStyle.addMouseManipulator(zoomManipulator);
    interactorStyle.setCenterOfRotation(this._actor.getCenter());
    this._renderWindow.getInteractor().setInteractorStyle(interactorStyle);

    // clear measurements on interactions
    this._renderWindow
      .getInteractor()
      .onMouseWheel(() => this.resetMeasurementState());
    this._renderWindow
      .getInteractor()
      .onRightButtonPress(() => this.resetMeasurementState());
  }

  /**
   * Register a callback for picking on a list of actors.
   * The callback will receive an object with { worldPosition, displayPosition, actorLabel }
   * @param {Function} callback - The function to call on pick.
   * @param {Array<String>} targetLabels - A list of actor labels to pick from.
   */
  turnPickingOn(callback, targetLabels) {
    this.turnPickingOff(); // Remove any existing pick listener

    const targetActors = new Map();
    targetLabels.forEach(label => {
      if (this._surfaces.has(label)) {
        targetActors.set(this._surfaces.get(label), label);
      } else if (this._landmarks.has(label)) {
        targetActors.set(this._landmarks.get(label).actor, label);
      } else {
        console.warn(`DTK: No actor found with label ${label} for picking.`);
      }
    });

    if (targetActors.size === 0) {
      console.warn("DTK: onPick called with no valid target labels.");
      return;
    }

    const picker = vtkPointPicker.newInstance();
    picker.setPickFromList(1);
    picker.initializePickList();
    targetActors.forEach((label, actor) => picker.addPickList(actor));

    this._pickCb = this._renderWindow
      .getInteractor()
      .onLeftButtonPress(callData => {
        if (this._renderer !== callData.pokedRenderer) {
          return;
        }

        const pos = callData.position;
        const point = [pos.x, pos.y, 0.0];
        picker.pick(point, this._renderer);

        if (picker.getActors().length > 0) {
          const pickedActor = picker.getActors()[0];
          if (targetActors.has(pickedActor)) {
            const closestPointId = picker.getPointId();
            if (closestPointId >= 0) {
              const worldPosition = pickedActor.getMapper().getInputData()
                .getPoints()
                .getPoint(closestPointId);
              const displayPosition = point.slice(0, 2);
              const actorLabel = targetActors.get(pickedActor);

              callback({ worldPosition, displayPosition, actorLabel });
            }
          }
        }
      });
  }

  /**
   * Unregister the picking callback.
   */
  turnPickingOff() {
    if (this._pickCb) {
      this._pickCb.unsubscribe();
      this._pickCb = null;
    }
  }

  /**
   * Toggle picking with stored or provided options
   * @param {Boolean} enabled
   * @param {Object} options
   * @param {Function} options.onPick
   * @param {Array<String>} options.labels
   * @param {Boolean} options.preserveCallback - Keep previous callback when updating labels
   * labels null/[] means "all pickable actors"
   */
  setPickingEnabled(enabled, options = {}) {
    const shouldEnable = Boolean(enabled);
    if (shouldEnable) {
      const hasLabels =
        Object.prototype.hasOwnProperty.call(options, "labels") ||
        Object.prototype.hasOwnProperty.call(options, "targetLabels");

      const preserveCallback = Boolean(options.preserveCallback);
      // Backwards compatibility with { callback, targetLabels }.
      const onPick =
        typeof options.onPick === "function"
          ? options.onPick
          : typeof options.callback === "function"
            ? options.callback
            : null;
      // If preserveCallback is true, prefer the previously registered callback.
      const nextOnPick =
        preserveCallback && this._pickOptions
          ? this._pickOptions.onPick
          : onPick || (this._pickOptions ? this._pickOptions.onPick : null);

      let nextLabels;
      if (hasLabels) {
        nextLabels =
          options.labels !== undefined ? options.labels : options.targetLabels;
      } else if (this._pickOptions) {
        nextLabels = this._pickOptions.labels;
      } else {
        nextLabels = null;
      }

      if (typeof nextOnPick !== "function") {
        console.warn(
          "DTK: setPickingEnabled(true) requires { onPick, labels } (or preserveCallback)."
        );
        this.turnPickingOff();
        this._pickingEnabled = false;
        return;
      }

      let targetLabels;
      if (nextLabels == null) {
        targetLabels = this._getPickableLabels();
      } else if (Array.isArray(nextLabels)) {
        targetLabels =
          nextLabels.length === 0 ? this._getPickableLabels() : nextLabels;
      } else {
        console.warn(
          "DTK: setPickingEnabled(true) expects labels as an array (or null for all)."
        );
        this.turnPickingOff();
        this._pickingEnabled = false;
        return;
      }

      if (targetLabels.length === 0) {
        console.warn("DTK: No pickable actors found.");
        this.turnPickingOff();
        this._pickingEnabled = false;
        return;
      }

      // Re-register to rebuild the pick list (callback preserved if requested).
      this._pickOptions = { onPick: nextOnPick, labels: nextLabels };
      this.turnPickingOn(nextOnPick, targetLabels);
      this._pickingEnabled = Boolean(this._pickCb);
      return;
    }

    this.turnPickingOff();
    this._pickingEnabled = false;
  }

  /**
   * Collect labels for all pickable actors (surfaces + landmarks)
   * @private
   */
  _getPickableLabels() {
    const labels = [];
    if (this._surfaces) {
      this._surfaces.forEach((_, label) => labels.push(label));
    }
    if (this._landmarks) {
      this._landmarks.forEach((_, label) => labels.push(label));
    }
    return labels;
  }

  /**
   * initPicker
   */
  _initPicker(state, mode) {
    // no blur when measure
    this.blurOnInteraction = false;

    // de-activate rotation
    let rotateManipulator = this._renderWindow
      .getInteractor()
      .getInteractorStyle()
      .getMouseManipulators()
      .filter(i => {
        return i.getClassName() == "vtkMouseCameraTrackballRotateManipulator";
      })
      .pop();
    this._renderWindow
      .getInteractor()
      .getInteractorStyle()
      .removeMouseManipulator(rotateManipulator);

    // Setup picking interaction
    // TODO this is slow the first time we pick, maybe we could use cellPicker and decrease resolution
    const picker = vtkPointPicker.newInstance();
    picker.setPickFromList(1);
    picker.initializePickList();

    if (!this._pickingPlane) {
      // add a 1000x1000 plane
      let camera = this._renderer.getActiveCamera();
      let { plane, planeActor } = setupPickingPlane(camera, this._actor);
      this._renderer.addActor(planeActor);
      this._pickingPlane = plane;
      this._planeActor = planeActor;
    }

    // add picking plane to pick list
    picker.addPickList(this._planeActor);

    // Pick on mouse left click
    this._leftButtonCb = this._renderWindow
      .getInteractor()
      .onLeftButtonPress(callData => {
        if (this._renderer !== callData.pokedRenderer) {
          return;
        }

        const pos = callData.position;
        const point = [pos.x, pos.y, 0.0];
        picker.pick(point, this._renderer);

        if (picker.getActors().length === 0) {
          const pickedPoint = picker.getPickPosition();
          if (this.VERBOSE)
            console.log(`No point picked, default: ${pickedPoint}`);
          // addSphereInPoint(pickedPoint, this._renderer);
        } else {
          const pickedPoints = picker.getPickedPositions();
          const pickedPoint = pickedPoints[0]; // always a single point on a plane
          if (this.VERBOSE) console.log(`Picked: ${pickedPoint}`);
          // addSphereInPoint(pickedPoint, this._renderer);

          // canvas coord
          const wPos = vtkCoordinate.newInstance();
          wPos.setCoordinateSystemToWorld();
          wPos.setValue(...pickedPoint);
          const displayPosition = wPos.getComputedDisplayValue(this._renderer);

          // apply changes on state based on active tool
          applyStrategy(state, displayPosition, pickedPoint, mode);

          if (this.VERBOSE) console.log(state);
          this._measurementState = state;
        }

        this._renderWindow.render();
      });
  }

  /**
   * Initialize slice plane
   * @private
   */
  _initSlicePlane() {
    this._slicePlane = vtkPlane.newInstance();
    this._slicePlane.setNormal(0, 0, 1);

    this._reslice = vtkImageReslice.newInstance();
    this._reslice.setTransformInputSampling(false);
    this._reslice.setAutoCropOutput(true);
    this._reslice.setOutputDimensionality(2);

    this._sliceMapper = vtkImageMapper.newInstance();
    this._sliceMapper.setInputConnection(this._reslice.getOutputPort());

    this._sliceActor = vtkImageSlice.newInstance();
    this._sliceActor.setMapper(this._sliceMapper);

    this._sliceProperty = vtkImageProperty.newInstance();
    this._sliceProperty.setInterpolationType(InterpolationType.LINEAR);
    this._sliceActor.setProperty(this._sliceProperty);

    this._renderer.addActor(this._sliceActor);
    this._sliceActor.setVisibility(false);

    if (this._actor) {
      this._reslice.setInputData(this._actor.getMapper().getInputData());
      this._slicePlane.setOrigin(this._actor.getCenter());
      this._updateResliceAxes();
    }
  }

  /**
   * Update reslice axes based on plane origin and normal
   * @private
   */
  _updateResliceAxes() {
    if (!this._reslice || !this._slicePlane) return;

    const origin = this._slicePlane.getOrigin();
    const normal = this._slicePlane.getNormal();

    // Create orthonormal basis
    const z = [...normal];
    normalize(z);

    const x = [0, 0, 0];
    const y = [0, 0, 0];

    // Find a vector not parallel to z
    const tempX = [1, 0, 0];
    if (Math.abs(dot(z, tempX)) > 0.9) {
      tempX[0] = 0;
      tempX[1] = 1;
      tempX[2] = 0;
    }

    cross(z, tempX, y);
    normalize(y);
    cross(y, z, x);
    normalize(x);

    const axes = [
      x[0], x[1], x[2], 0,
      y[0], y[1], y[2], 0,
      z[0], z[1], z[2], 0,
      origin[0], origin[1], origin[2], 1
    ];

    this._reslice.setResliceAxes(axes);

    if (this._sliceActor) {
      this._sliceActor.setUserMatrix(axes);
    }
  }

  /**
   * Initialize slice widget
   * @private
   */
  _initSliceWidget() {
    this._widgetManager = vtkWidgetManager.newInstance();
    this._widgetManager.setRenderer(this._renderer);

    this._sliceWidget = vtkImplicitPlaneWidget.newInstance();
    this._sliceWidgetInstance = this._widgetManager.addWidget(this._sliceWidget);

    const planeState = this._sliceWidget.getWidgetState();
    planeState.setOrigin(this._slicePlane.getOrigin());
    planeState.setNormal(this._slicePlane.getNormal());

    planeState.onModified(() => {
      this._slicePlane.setOrigin(planeState.getOrigin());
      this._slicePlane.setNormal(planeState.getNormal());
      this._updateResliceAxes();
      this._renderWindow.render();
    });

    this._sliceWidget.setHandleVisibility(false);
    this._sliceWidget.setContextVisibility(false);
  }
}