src_tools_brush.js


import {logger} from '../utils/logger.js';
import {Point2D} from '../math/point.js';
import {Index} from '../math/index.js';
import {getEllipseIndices} from '../math/ellipse.js';
import {Image} from '../image/image.js';
import {Size} from '../image/size.js';
import {Geometry} from '../image/geometry.js';
import {ColourMap} from '../image/luts.js';
import {getDefaultDicomSegJson} from '../image/maskFactory.js';
import {getDwvUIDPrefix} from '../dicom/dicomParser.js';
import {getElementsFromJSONTags} from '../dicom/dicomWriter.js';
import {DicomData} from '../app/dataController.js';
import {ViewConfig} from '../app/application.js';
import {getLayerDetailsFromEvent} from '../gui/layerGroup.js';
import {ScrollWheel} from './scrollWheel.js';

// doc imports
/* eslint-disable no-unused-vars */
import {Point, Point3D} from '../math/point.js';
import {App} from '../app/application.js';
import {LayerGroup} from '../gui/layerGroup.js';
import {ViewLayer} from '../gui/viewLayer.js';
/* eslint-enable no-unused-vars */

const ERROR_MESSAGES = {
  brush: {
    noSourceDataId: 'No source data ID defined',
    noSourceDataIdAdd: 'No source data ID defined when adding mask slices',
    noSourceImage: 'No source image to get origins, ID: {0}',
    noSourceImageCreateMask: 'No source image to create mask',
    noSourceImageGetOffset: 'No source image to get offsets, ID: {0}',
    noBrushOrigins: 'No brush origins',
    noBrushColour: 'No brush colour',
    noMaskDefined: 'No mask defined when adding mask slices',
    noCreatedMaskImage: 'No created mask image',
    noMaskImage: 'No mask image for temporary draw command, ID: {0}',
    noMaskImageGetOffset: 'No mask image to get offsets from',
    noMaskImageDraw: 'No mask image for draw command, ID: {0}',
    noMaskId: 'No mask ID to apply mask index',
    noMaskImageForApply: 'No mask image for apply index, ID: {0}',
    noSegments: 'No segments have been set for a new mask',
    noMaskViewLayers: 'No mask view layers',
    noSelectedSegmentNumber: 'No selected segment number',
    tooManyMaskLayers: 'Too many mask view layers: {0}',
    moreMaskLayers: 'More mask layers than expected',
    cannotCreateMask: 'Cannot create mask with no source ID',
    cannotDisplayMask: 'Cannot display mask with no mask ID',
    cannotDrawNoMaskId: 'Cannot draw with no mask data ID',
    cannotDrawNoOffset: 'Cannot draw with no offsets',
    cannotDrawNoSegment: 'Cannot draw with no selected segment',
    cannotDrawNoColourList: 'Cannot draw with no colour list',
    cannotGetMaskLayers: 'Cannot get mask layers with no mask ID',
    cannotGetMaskVCNoMaskId: 'Cannot get mask view controller: no mask ID',
    cannotGetMaskVCNoMaskLayers:
      'Cannot get mask view controller: no mask layers',
    cannotSaveNoSourceId: 'Cannot save with no source data ID',
    cannotSaveNoMask: 'Cannot save with no mask',
    cannotFindSourceData:
      'Cannot find source data for an existing mask, ID: {0}',
    cannotFindSegment: 'Cannot find a segment for the selected number: {0}',
    unsupportedScrollIndex: 'Unsupported scroll index: {0}'
  }
};

/**
 * Format string.
 *
 * @param {*} template The template where to add values.
 * @param  {...any} values The values to add to the template.
 * @returns {string} The formated string.
 */
function formatString(template, ...values) {
  return template.replace(/{(\d+)}/g, (_match, index) => values[index] || '');
};

/**
 * Retrieves the unique div ids in the current data view configs.
 *
 * @param {object} dataViewConfigs The data view configs.
 * @returns {string[]} Array of unique div ids.
 */
function getUniqueDataViewConfigsDivIds(dataViewConfigs) {
  let allDivIds = [];
  if (!dataViewConfigs) {
    return [];
  }
  for (const key in dataViewConfigs) {
    if (dataViewConfigs[key]) {
      const viewConfigs = dataViewConfigs[key];
      if (Array.isArray(viewConfigs)) {
        const divIds = viewConfigs.map(function (config) {
          return config.divId;
        });
        allDivIds = [...allDivIds, ...divIds];
      }
    }
  }
  return [...new Set(allDivIds)];
};

// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------

const _MouseEventButtons = {
  left: 0,
  middle: 1,
  right: 2
};

const _BrushMode = {
  Del: 'del',
  Add: 'add'
};

/**
 * Get an array sort callback:
 * - f(a,b) > 0 -> b,a,
 * - f(a,b) < 0 -> a,b,
 * - f(a,b) = 0 -> original order.
 *
 * @param {number} direction The direction to use to compare indices.
 * @returns {object} A function that compares two Index.
 */
function getIndexCompareFunction(direction) {
  return function (a, b) {
    let result = 0;
    const va = a.get(direction);
    const vb = b.get(direction);
    if (typeof va !== 'undefined' && typeof vb !== 'undefined') {
      result = va - vb;
    }
    return result;
  };
}

/**
 * Get a dimension organisation used to index a DICOM seg.
 *
 * @returns {object} The indices and organisations.
 */
function getDimensionOrganization() {
  // 681051091011101: first 15 of charCode('DimensionOrganizationUID')
  const organizationUID = getDwvUIDPrefix() + '681051091011101.1';
  return {
    indices: {
      value: [
        {
          DimensionOrganizationUID: organizationUID,
          DimensionDescriptionLabel: 'ReferencedSegmentNumber',
          DimensionIndexPointer: '(0062,000B)',
          FunctionalGroupPointer: '(0062,000A)'
        },
        {
          DimensionOrganizationUID: organizationUID,
          DimensionDescriptionLabel: 'ImagePositionPatient',
          DimensionIndexPointer: '(0020,0032)',
          FunctionalGroupPointer: '(0020,9113)'
        }
      ]
    },
    organizations: {
      value: [
        {
          DimensionOrganizationUID: organizationUID
        }
      ]
    }
  };
}

/**
 * Get the indices that form a circle.
 * Can be an ellipse to adapt to view.
 *
 * @param {Geometry} geometry The geometry.
 * @param {Point} position The circle center.
 * @param {number[]} radiuses The circle radiuses.
 * @param {number[]} dims The 2 dimensions.
 * @returns {Index[]} The indices of the circle.
 */
function getCircleIndices(
  geometry,
  position,
  radiuses,
  dims
) {
  const centerIndex = geometry.worldToIndex(position);
  return getEllipseIndices(centerIndex, radiuses, dims);
}

/**
 * Get the range of origin indices that correspond to input new
 * mask indices.
 *
 * @param {Geometry} geometry The geometry.
 * @param {Index[]} indices An array of indices.
 * @returns {number[]} Range of indices in the input origins.
 */
function getOriginIndexRangeFromMaskIndices(geometry, indices) {
  // sort indices according to Z
  const sorted = indices.sort(getIndexCompareFunction(2));

  // lowest origin
  const z0 = sorted[0].get(2);
  if (typeof z0 === 'undefined') {
    return [];
  }
  const index0 = new Index([0, 0, z0]);
  const origin0 = geometry.indexToWorld(index0);

  // highest origin
  const z1 = sorted.at(-1).get(2);
  if (typeof z1 === 'undefined') {
    return [];
  }
  const index1 = new Index([0, 0, z1]);
  const origin1 = geometry.indexToWorld(index1);

  const origins = geometry.getOrigins();
  // threshold for distance warning
  const spacing = geometry.getSpacing().get(2);
  const threshold = 0.1 * spacing;

  // index of origin closest to lowest point
  const indexStart = origin0.get3D().getClosest(origins);
  const originStart = origins[indexStart];
  const d0 = origin0.get3D().getDistance(originStart);
  if (d0 > threshold) {
    logger.warn(
      'Large distance between origin and origin for first index: ' + d0);
  }

  // index of origin closest to highest point
  const indexEnd = origin1.get3D().getClosest(origins);
  const originEnd = origins[indexEnd];
  const d1 = origin1.get3D().getDistance(originEnd);
  if (d1 > threshold) {
    logger.warn(
      'Large distance between origin and origin for last index: ' + d1);
  }

  return [indexStart, indexEnd];
}

/**
 * Get the data offsets that correspond to input indices.
 *
 * @param {Geometry} geometry The geometry.
 * @param {Index[]} indices An array of indices.
 * @returns {number[]} An array of offsets.
 */
function getOffsetsFromIndices(geometry, indices) {
  const imageSize = geometry.getSize();
  const offsets = [];
  for (const index of indices) {
    const offset = imageSize.indexToOffset(index);
    if (offset >= 0) {
      offsets.push(offset);
    }
  }
  return offsets.sort(function compareNumbers(a, b) {
    return a - b;
  });
}

class DrawBrushCommandProperties {
  mask;
  dataId;
  offsetsLists;
  mode;
  segmentNumber;
  srclayerid;
  originalValuesLists;
  isSilent;
}
/**
 * Draw brush command.
 */
class DrawBrushCommand {
  #mask;
  #dataId;
  #offsetsLists;
  #mode;
  #segmentNumber;
  #srclayerid;
  #originalValuesLists;
  #isSilent;

  #exeType;
  #undoType;

  /**
   * @param {DrawBrushCommandProperties} properties The command properties.
   */
  constructor(properties) {
    this.#mask = properties.mask;
    this.#dataId = properties.dataId;
    this.#offsetsLists = properties.offsetsLists;
    this.#mode = properties.mode;
    this.#segmentNumber = properties.segmentNumber;
    this.#srclayerid = properties.srclayerid;

    if (typeof properties.originalValuesLists !== 'undefined') {
      this.#originalValuesLists = properties.originalValuesLists;
    }
    this.#isSilent = properties.isSilent ?? false;
    // event types
    this.#exeType = this.#mode === _BrushMode.Del ? 'brushremove' : 'brushdraw';
    this.#undoType =
      this.#exeType === 'brushdraw' ? 'brushremove' : 'brushdraw';
  }

  /**
   * Get the original values before applying brush.
   *
   * @returns {Array|undefined} Lists of original value iterators,
   *   undefined when erasing.
   */
  getOriginalValuesLists() {
    return this.#originalValuesLists;
  }

  /**
   * Get the execute event.
   *
   * @returns {CustomEvent} The event.
   */
  getExecuteEvent() {
    const segNumber =
      this.#exeType === 'brushdraw' ? this.#segmentNumber : undefined;
    return new CustomEvent(this.#exeType, {
      detail: {
        segmentnumber: segNumber,
        dataid: this.#dataId,
        srclayerid: this.#srclayerid
      }
    });
  }

  /**
   * Get the command name.
   *
   * @returns {string} The command name.
   */
  getName() {
    return 'Draw-brush';
  }

  /**
   * Execute the command.
   *
   * @fires DrawBrushCommand#brushdraw
   */
  execute() {
    if (typeof this.#segmentNumber === 'undefined') {
      return;
    }

    let segNumber = this.#segmentNumber;
    if (this.#exeType === 'brushremove') {
      segNumber = 0;
    }

    // draw
    if (typeof this.#originalValuesLists === 'undefined') {
      this.#originalValuesLists = this.#mask.setAtOffsetsAndGetOriginals(
        this.#offsetsLists,
        segNumber
      );
    } else {
      this.#mask.setAtOffsetsWithIterator(this.#offsetsLists, segNumber);
    }

    // callback
    if (!this.#isSilent) {
      /**
       * Draw create event.
       *
       * @event DrawBrushCommand#brushdraw
       * @type {object}
       * @property {number} id The id of the created brush.
       */
      this.onExecute(this.getExecuteEvent());
    }
  }

  /**
   * Undo the command.
   *
   * @fires DrawBrushCommand#brushremove
   */
  undo() {
    if (typeof this.#originalValuesLists === 'undefined') {
      this.#originalValuesLists = this.#mask.setAtOffsetsAndGetOriginals(
        this.#offsetsLists,
        0
      );
    } else {
      this.#mask.setAtOffsetsWithIterator(
        this.#offsetsLists, this.#originalValuesLists);
    }

    // callback
    const number =
      this.#undoType === 'brushdraw' ? this.#segmentNumber : undefined;
    const undoEvent = new CustomEvent(this.#undoType, {
      detail: {
        segmentnumber: number,
        dataid: this.#dataId,
        srclayerid: this.#srclayerid
      }
    });
    this.onUndo(undoEvent);
  }

  /**
   * Handle an execute event.
   *
   * @param {CustomEvent} _event The execute event with type and id.
   */
  onExecute(_event) {
    // default does nothing.
  }

  /**
   * Handle an undo event.
   *
   * @param {CustomEvent} _event The undo event with type and id.
   */
  onUndo(_event) {
    // default does nothing.
  }
} // DrawBrushCommand class

/**
 * Brush class.
 */
export class Brush extends EventTarget {
  #app;

  /**
   * Scroll wheel handler.
   *
   * @type {ScrollWheel}
   */
  #scrollWhell;

  /**
   * @param {App} app The associated application.
   */
  constructor(app) {
    super();
    this.#app = app;
    this.#scrollWhell = new ScrollWheel(app);
  }

  /**
   * Interaction start flag.
   *
   * @type {boolean}
   */
  #started = false;

  /**
   * Mask image.
   *
   * @type {Image}
   */
  #mask;

  /**
   * Mask data index.
   *
   * @type {string}
   */
  #maskDataId;

  /**
   * The brush size.
   *
   * @type {number}
   */
  #brushSize = 10;

  /**
   * The brush size range.
   *
   * @type {object}
   */
  #brushSizeRange = {min: 1, max: 20};

  /**
   * The brush mode: 'add' or 'del'.
   *
   * @type {string}
   */
  #brushMode = _BrushMode.Del;

  /**
   * The selected segment number.
   *
   * @type {number}
   */
  #selectedSegmentNumber;

  /**
   * UID counter.
   *
   * @type {number}
   */
  #uid = 0;

  /**
   * Current layer group.
   *
   * @type {LayerGroup}
   */
  #currentLayerGroup;

  /**
   * Interaction start point.
   *
   * @type {Point2D}
   */
  #startPoint;

  // temporary variables
  #tmpOffsetsLists;
  #tmpOriginalValuesLists;

  /**
   * Black list: series instance uid list
   * for which brush segmentation creation
   * is forbidden.
   *
   * @type {string[]}
   */
  #blacklist = [];

  /**
   * Get a mask slice.
   *
   * @param {Geometry} geometry The mask geometry.
   * @param {Point3D} origin The slice origin.
   * @param {object} meta The mask meta.
   * @returns {Image} The slice.
   */
  #createMaskImage(geometry, origin, meta) {
    // create data
    const sizeValues = geometry.getSize().getValues();
    sizeValues[2] = 1;
    const maskSize = new Size(sizeValues);
    const maskGeometry = new Geometry(
      [origin],
      maskSize,
      geometry.getSpacing(),
      geometry.getOrientation()
    );
    const values = new Uint8Array(maskSize.getDimSize(2));
    values.fill(0);
    ++this.#uid;
    const uids = [this.#uid.toString()];
    const maskSlice = new Image(maskGeometry, values, uids);
    maskSlice.setMeta(meta);
    maskSlice.setPhotometricInterpretation('PALETTE COLOR');
    maskSlice.setPaletteColourMap(new ColourMap([0], [0], [0]));

    return maskSlice;
  }

  /**
   * Add slices to mask if needed.
   *
   * @param {Geometry} sourceGeometry The source geometry.
   * @param {Geometry} maskGeometry The mask geometry.
   * @param {Point} position The circle center.
   * @param {number[]} circleDims The circle dimensions.
   * @param {number[]} radiuses The circle radiuses.
   * @param {object} sliceMeta The slice meta.
   */
  #addMaskSlices(
    sourceGeometry,
    maskGeometry,
    position,
    circleDims,
    radiuses,
    sliceMeta
  ) {
    // circle indices in the image geometry
    const circleIndices = getCircleIndices(
      sourceGeometry,
      position,
      radiuses,
      circleDims
    );
    // origin index range represented by the circle indicies
    const newOrigIndexRange = getOriginIndexRangeFromMaskIndices(
      sourceGeometry,
      circleIndices
    );
    if (typeof newOrigIndexRange === 'undefined' ||
      newOrigIndexRange.length === 0) {
      throw new Error(ERROR_MESSAGES.brush.noBrushOrigins);
    }

    const sourceOrigins = sourceGeometry.getOrigins();
    const maskOrigins = maskGeometry.getOrigins();

    // min and max mask origin closest source origin indices
    const maskOrigIndexStart = maskOrigins[0].getClosest(sourceOrigins);
    const maskOrigIndexEnd = maskOrigins.at(-1).getClosest(sourceOrigins);

    // index in source origin array of slices to add
    const indicesToAdd = [];

    // first index compare
    // (go from closest to mask to avoid variable spacing warning
    //  when appending image slices)
    if (newOrigIndexRange[0] < maskOrigIndexStart) {
      for (
        let index = maskOrigIndexStart - 1;
        index >= newOrigIndexRange[0];
        --index
      ) {
        indicesToAdd.push(index);
      }
    }
    // last index compare
    if (newOrigIndexRange[1] > maskOrigIndexEnd) {
      for (
        let index = maskOrigIndexEnd + 1;
        index <= newOrigIndexRange[1];
        ++index
      ) {
        indicesToAdd.push(index);
      }
    }

    // convert index to origin
    const originsToAdd = [];
    for (const index of indicesToAdd) {
      originsToAdd.push(sourceOrigins[index]);
    }

    // append slices
    if (typeof this.#mask === 'undefined') {
      throw new Error(ERROR_MESSAGES.brush.noMaskDefined);
    }
    const tags = this.#mask.getMeta();
    for (const element of originsToAdd) {
      tags.numberOfFiles += 1;
      this.#mask.appendSlice(
        this.#createMaskImage(maskGeometry, element, sliceMeta));
    }
  }

  /**
   * Paint the mask at the given offsets.
   *
   * @param {Array} offsets The mask offsets.
   */
  #paintMaskAtOffsets(offsets) {
    const maskVl = this.#getMaskViewLayer();
    const srclayerid = maskVl.getId();

    // get mask image
    if (typeof this.#maskDataId === 'undefined') {
      throw new Error(ERROR_MESSAGES.brush.noMaskId);
    }
    const maskData = this.#app.getData(this.#maskDataId);
    if (!maskData) {
      throw new Error(
        formatString(ERROR_MESSAGES.brush.noMaskImage, this.#maskDataId));
    }

    // temporary command
    const props = new DrawBrushCommandProperties();
    props.mask = maskData.image;
    props.dataId = this.#maskDataId;
    props.offsetsLists = [offsets];
    props.mode = this.#brushMode;
    props.segmentNumber = this.#selectedSegmentNumber;
    props.srclayerid = srclayerid;
    const command = new DrawBrushCommand(props);
    command.execute();

    // store offsets and colours for final command
    this.#tmpOffsetsLists.push(offsets);
    // only one element in original colours
    const originalValues = command.getOriginalValuesLists();
    if (typeof originalValues !== 'undefined') {
      this.#tmpOriginalValuesLists.push(originalValues[0]);
    }
  }

  /**
   * Create the mask.
   *
   * @param {Point} position The first slice position.
   * @param {Image} sourceImage The source image.
   * @returns {string} The mask data id.
   */
  #createMask(position, sourceImage) {
    // check souce image
    if (!sourceImage) {
      throw new Error(
        formatString(ERROR_MESSAGES.brush.noSourceImageCreateMask));
    }
    const sourceGeometry = sourceImage.getGeometry();

    const imgK = sourceGeometry.worldToIndex(position).get(2);
    if (typeof imgK === 'undefined') {
      throw new Error('Z position is undefined');
    }
    const index = new Index([0, 0, imgK]);

    // default tags
    const firstSliceMeta = getDefaultDicomSegJson();
    // dicom seg dimension
    const dimension = getDimensionOrganization();
    firstSliceMeta.DimensionOrganizationSequence = dimension.organizations;
    firstSliceMeta.DimensionIndexSequence = dimension.indices;
    // local
    firstSliceMeta.PixelRepresentation = 0;
    firstSliceMeta.numberOfFiles = 1;

    const tags = sourceImage.getMeta();

    firstSliceMeta.PatientID = tags.PatientID;
    firstSliceMeta.StudyInstanceUID = tags.StudyInstanceUID;
    firstSliceMeta.SeriesInstanceUID = tags.SeriesInstanceUID;
    const referencedSOPs = [
      {
        referencedSOPClassUID: tags.SOPClassUID,
        referencedSOPInstanceUID: sourceImage.getImageUid(index)
      }
    ];
    const referenceSeriesTag = [];
    referenceSeriesTag.push({
      ReferencedInstanceSequence: {
        value: referencedSOPs
      },
      SeriesInstanceUID: tags.SeriesInstanceUID
    });
    firstSliceMeta.ReferencedSeriesSequence = {
      value: referenceSeriesTag
    };
    firstSliceMeta.custom = {
      frameInfos: [
        {
          dimIndex: [1, 1],
          refSegmentNumber: 1,
          imagePosPat: tags.ImageOrientationPatient,
          derivationImages: [
            {
              sourceImages: referencedSOPs
            }
          ]
        }
      ]
    };

    // get length unit from ref image
    firstSliceMeta.lengthUnit = sourceImage.getMeta().lengthUnit;

    this.#mask = this.#createMaskImage(
      sourceGeometry,
      sourceGeometry.getOrigins()[imgK],
      firstSliceMeta
    );

    // fires load events and renders data
    // (will create viewLayer for it)
    const elements = getElementsFromJSONTags(firstSliceMeta);
    const data = new DicomData(elements);
    data.image = this.#mask;
    return this.#app.addData(data);
  }

  /**
   * Get the orientation of the first data view config of the input
   * divId.
   *
   * @param {string} divId The divId.
   * @returns {string} The orientation.
   */
  #getDataViewConfigOrientation(divId) {
    const dataConfigs = this.#app.getDataViewConfigs();
    let orient;
    for (const key in dataConfigs) {
      const config = dataConfigs[key].find(function (item) {
        return item.divId === divId;
      });
      if (typeof config !== 'undefined') {
        orient = config.orientation;
        break;
      }
    }
    return orient;
  }

  /**
   * Display a newly created mask.
   *
   * @param {string} divId The div id where to display the mask.
   */
  #displayMask(divId) {
    // check mask data id
    if (typeof this.#maskDataId === 'undefined') {
      throw new Error(ERROR_MESSAGES.brush.cannotDisplayMask);
    }
    const viewConfig = new ViewConfig(divId);
    viewConfig.orientation = this.#getDataViewConfigOrientation(divId);
    this.#app.addDataViewConfig(this.#maskDataId, viewConfig);
    this.#app.render(this.#maskDataId);
  }

  /**
   * Get the first referenced UID of a mask image.
   *
   * @param {object} meta The mask image meta.
   * @returns {string|undefined} The UID.
   */
  #getReferenceDataUID(meta) {
    let dataUid;
    const customMeta = meta.custom;
    const frameInfos = customMeta.frameInfos;
    if (frameInfos.length === 0) {
      return dataUid;
    }
    // DerivationImageSequence (0008,9124)
    const derivationImages = frameInfos[0].derivationImages;
    if (typeof derivationImages === 'undefined') {
      return dataUid;
    }
    if (derivationImages.length === 0) {
      return dataUid;
    }
    // SourceImageSequence (0008,2112)
    const sourceImages = derivationImages[0].sourceImages;
    if (typeof sourceImages === 'undefined') {
      return dataUid;
    }
    if (sourceImages.length === 0) {
      return;
    }
    // ReferencedSOPInstanceUID (0008,1155)
    return sourceImages[0].referencedSOPInstanceUID;
  }

  /**
   * Get the source data id from the mask image meta.
   *
   * @param {Image} mask The mask image.
   * @returns {string} The source data id.
   */
  #getSourceDataIdFromMask(mask) {
    // get source id from mask meta
    const meta = mask.getMeta();
    const sourceDataUID = this.#getReferenceDataUID(meta);
    // search app for the data ID of this SOPInstanceUID...
    let ids = [];
    if (sourceDataUID !== 'undefined') {
      ids = this.#app.getDataIdsFromSopUids([sourceDataUID]);
    }
    let sourceDataId = '0';
    if (ids.length > 0) {
      sourceDataId = ids[0];
    } else {
      // mask with no source data...
      logger.warn(
        formatString(ERROR_MESSAGES.brush.cannotFindSourceData, sourceDataUID));
    }
    return sourceDataId;
  }

  /**
   * Get the mask view layer.
   *
   * @param {LayerGroup} layerGroup The layer group to search.
   * @returns {ViewLayer} The view layer.
   */
  #getLayerGroupMaskViewLayer(layerGroup) {
    // check mask data id
    if (typeof this.#maskDataId === 'undefined') {
      throw new Error(ERROR_MESSAGES.brush.cannotGetMaskLayers);
    }

    const maskViewLayers = layerGroup.getViewLayersByDataId(
      this.#maskDataId
    );
    if (maskViewLayers.length === 0) {
      throw new Error(ERROR_MESSAGES.brush.noMaskViewLayers);
    }
    if (maskViewLayers.length !== 1) {
      logger.warn(
        formatString(
          ERROR_MESSAGES.brush.tooManyMaskLayers, maskViewLayers.length)
      );
    }
    return maskViewLayers[0];
  }

  /**
   * Get the mask image.
   *
   * @param {string} maskDataId The mask data id.
   * @returns {Image} The image.
   */
  #getMaskImage(maskDataId) {
    if (typeof maskDataId === 'undefined') {
      throw new Error(ERROR_MESSAGES.brush.noMaskId);
    }
    const maskData = this.#app.getData(maskDataId);
    if (typeof maskData === 'undefined') {
      throw new Error(ERROR_MESSAGES.brush.noMaskImageGetOffset);
    }
    return maskData.image;
  }

  /**
   * Get the mask offset for an event.
   *
   * @param {object} event The event containing the mask position.
   * @returns {Array} The array of offset to paint.
   */
  #getMaskOffsets(event) {
    const layerDetails = getLayerDetailsFromEvent(event);
    const mousePoint = new Point2D(event.offsetX, event.offsetY);
    const layerGroup = this.#app.getLayerGroupByDivId(
      layerDetails.groupDivId
    );
    if (typeof layerGroup === 'undefined') {
      throw new Error('No layergroup to get mask offsets');
    }
    this.#currentLayerGroup = layerGroup;

    let viewLayer;
    if (typeof this.#maskDataId === 'undefined') {
      viewLayer = layerGroup.getBaseViewLayer();
    } else {
      viewLayer = layerGroup.getViewLayersByDataId(this.#maskDataId)[0];
    }
    if (typeof viewLayer === 'undefined') {
      return [];
    }
    const viewController = viewLayer.getViewController();
    const savedPosition = viewController.getCurrentPosition();

    const searchMaskMeta = {
      Modality: 'SEG'
    };

    // update existing mask from current vl or create a new one
    let maskVl;
    let maskVc;
    let sourcePosition;
    let sourceImage;
    if (viewController.equalImageMeta(searchMaskMeta)) {
      this.#mask = this.#getMaskImage(this.#maskDataId);
      // get source image
      const sourceDataId = this.#getSourceDataIdFromMask(this.#mask);
      const sourceData = this.#app.getData(sourceDataId);
      if (!sourceData) {
        throw new Error(formatString(
          ERROR_MESSAGES.brush.noSourceImageGetOffset, sourceDataId
        ));
      }
      sourceImage = sourceData.image;
      //
      const sourceVl = layerGroup.getViewLayersByDataId(sourceDataId)[0];
      const sourceViewController = sourceVl.getViewController();
      const planePos = sourceVl.displayToPlanePos(mousePoint);
      sourcePosition = sourceViewController.getPositionFromPlanePoint(planePos);
      // update locals
      maskVl = viewLayer;
      maskVc = viewController;
    } else {
      // view layer is source
      const sourceDataId = viewLayer.getDataId();
      const sourceData = this.#app.getData(sourceDataId);
      if (!sourceData) {
        throw new Error(formatString(
          ERROR_MESSAGES.brush.noSourceImageGetOffset, sourceDataId
        ));
      }
      sourceImage = sourceData.image;

      const planePos = viewLayer.displayToPlanePos(mousePoint);
      sourcePosition = viewController.getPositionFromPlanePoint(planePos);
      // create mask (sets this.#mask)
      this.#maskDataId = this.#createMask(savedPosition, sourceImage);
      // check
      if (typeof this.#mask === 'undefined') {
        throw new Error(ERROR_MESSAGES.brush.noCreatedMaskImage);
      }
      // display mask
      const divId = layerGroup.getDivId();
      const layerGroupHasDiv = typeof divId !== 'undefined';
      if (layerGroupHasDiv) {
        this.#displayMask(divId);
      }
      // newly create mask case: find the SEG view layer
      maskVl = this.#getLayerGroupMaskViewLayer(layerGroup);
      maskVc = maskVl.getViewController();

      if (layerGroupHasDiv) {
        // this.#displayMask causes the position to get reset,
        // so we have to restore it or we may not be drawing on
        // the correct slice.
        maskVc.setCurrentPosition(savedPosition);
      }
    }

    const sourceGeometry = sourceImage.getGeometry();
    const sliceMeta = this.#mask.getMeta();
    const maskGeometry = this.#mask.getGeometry();

    const spacing2D = viewController.get2DSpacing();
    const rx = Math.round(this.#brushSize / spacing2D.x);
    const ry = Math.round(this.#brushSize / spacing2D.y);
    const radiuses = [rx, ry];

    let circleDims;
    const scrollIndex = viewController.getScrollDimIndex();
    switch (scrollIndex) {
      case 0: {
        circleDims = [1, 2];
        break;
      }
      case 1: {
        circleDims = [0, 2];
        break;
      }
      case 2: {
        circleDims = [0, 1];
        break;
      }
      default: {
        throw new Error(
          formatString(ERROR_MESSAGES.brush.unsupportedScrollIndex, scrollIndex)
        );
      }
    }

    this.#addMaskSlices(
      sourceGeometry,
      maskGeometry,
      sourcePosition,
      circleDims,
      radiuses,
      sliceMeta
    );

    // circle indices in the mask geometry
    const maskPlanePos = maskVl.displayToPlanePos(mousePoint);
    const maskPosition = maskVc.getPositionFromPlanePoint(maskPlanePos);
    const maskCircleIndices = getCircleIndices(
      maskGeometry,
      maskPosition,
      radiuses,
      circleDims
    );

    return getOffsetsFromIndices(maskGeometry, maskCircleIndices);
  }

  /**
   * Determines if the event is over a series inside the blacklist.
   *
   * @param {MouseEvent} event The mouse down event.
   * @returns {boolean} True if in black list.
   */
  #isInBlackList(event) {
    const layerDetails = getLayerDetailsFromEvent(event);
    const layerGroup = this.#app.getLayerGroupByDivId(
      layerDetails.groupDivId
    );
    if (typeof layerGroup === 'undefined') {
      throw new Error('No layergroup to check black list');
    }
    const drawLayer = layerGroup.getActiveDrawLayer();

    if (typeof drawLayer === 'undefined') {
      const viewLayer = layerGroup.getActiveViewLayer();
      const referenceDataId = viewLayer.getDataId();
      const referenceData = this.#app.getData(referenceDataId);
      const referenceMeta = referenceData.image.getMeta();
      const seriesInstanceUID = referenceMeta.SeriesInstanceUID;
      // check black list
      if (this.#blacklist.includes(seriesInstanceUID)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Handle mouse down event.
   *
   * @param {MouseEvent} event The mouse down event.
   */
  mousedown = (event) => {
    if (this.#isInBlackList(event)) {
      return;
    }
    if (typeof this.#selectedSegmentNumber === 'undefined') {
      logger.warn(ERROR_MESSAGES.brush.noSelectedSegmentNumber);
      return;
    }

    // start flag
    this.#started = true;
    // first position
    this.#startPoint = new Point2D(event.offsetX, event.offsetY);

    // reset tmp vars
    this.#tmpOffsetsLists = [];
    this.#tmpOriginalValuesLists = [];

    // check right button
    this.#setEraserOnRightMousedown(event);
    // paint
    const offsets = this.#getMaskOffsets(event);
    if (offsets.length > 0) {
      this.#paintMaskAtOffsets(offsets);
    } else {
      // reset flag
      this.#started = false;
      this.#removeEraserOnRightMousedown(event);
    }
  };

  /**
   * Checks if the mouse down event has been done with right click
   * and if true, set erasing mode to the brush color.
   *
   * @param {MouseEvent} event The mouse event.
   */
  #setEraserOnRightMousedown(event) {
    if (event.button === _MouseEventButtons.right) {
      this.#brushMode = _BrushMode.Del;
      const activateErasingEvent = new CustomEvent('erasingactivated');
      this.dispatchEvent(activateErasingEvent);
    }
  }

  /**
   * Checks if the mouse down event has been done with right click
   * and if true, removes erasing mode from the brush color.
   *
   * @param {MouseEvent} event The mouse event.
   */
  #removeEraserOnRightMousedown(event) {
    if (event.button === _MouseEventButtons.right) {
      this.#brushMode = _BrushMode.Add;
      const deactivateErasingEvent = new CustomEvent('erasingdeactivated');
      this.dispatchEvent(deactivateErasingEvent);
    }
  }

  /**
   * Handle mouse move event.
   *
   * @param {object} event The mouse move event.
   */
  mousemove = (event) => {
    if (!this.#started) {
      return;
    }
    if (typeof this.#startPoint === 'undefined') {
      return;
    }
    const mousePoint = new Point2D(event.offsetX, event.offsetY);
    const diffX = Math.abs(mousePoint.getX() - this.#startPoint.getX());
    const diffY = Math.abs(mousePoint.getY() - this.#startPoint.getY());
    if (diffX > this.#brushSize / 2 || diffY > this.#brushSize / 2) {
      const offsets = this.#getMaskOffsets(event);
      if (offsets.length > 0) {
        this.#paintMaskAtOffsets(offsets);
      }
      this.#startPoint = mousePoint;
    }
  };

  /**
   * Handle mouse up event.
   *
   * @param {MouseEvent} _event The mouse up event.
   */
  mouseup = (_event) => {
    if (this.#started) {
      this.#started = false;
      this.#removeEraserOnRightMousedown(_event);

      if (typeof this.#maskDataId === 'undefined') {
        throw new Error(ERROR_MESSAGES.brush.cannotDrawNoMaskId);
      }
      if (typeof this.#tmpOffsetsLists === 'undefined') {
        throw new Error(ERROR_MESSAGES.brush.cannotDrawNoOffset);
      }
      if (typeof this.#tmpOriginalValuesLists === 'undefined') {
        throw new Error(ERROR_MESSAGES.brush.cannotDrawNoColourList);
      }

      // reverse lists for command to respect original colours
      this.#tmpOffsetsLists.reverse();
      this.#tmpOriginalValuesLists.reverse();
      const maskVl = this.#getMaskViewLayer();
      const srclayerid = maskVl.getId();

      // full draw from mouse down to up
      const maskData = this.#app.getData(this.#maskDataId);
      if (!maskData) {
        throw new Error(
          formatString(ERROR_MESSAGES.brush.noMaskImageDraw, this.#maskDataId)
        );
      }
      const props = new DrawBrushCommandProperties();
      props.mask = maskData.image;
      props.dataId = this.#maskDataId;
      props.offsetsLists = this.#tmpOffsetsLists;
      props.mode = this.#brushMode;
      props.segmentNumber = this.#selectedSegmentNumber;
      props.srclayerid = srclayerid;
      props.originalValuesLists = this.#tmpOriginalValuesLists;
      const command = new DrawBrushCommand(props);
      command.onExecute = (event) => {
        this.dispatchEvent(event);
      };
      command.onUndo = (event) => {
        this.dispatchEvent(event);
        this.#mask.recalculateLabels();
      };

      // save command in undo stack
      this.#app.addToUndoStack(command);
      // fire event
      this.dispatchEvent(command.getExecuteEvent());
      this.#mask.recalculateLabels();
    }
  };

  /**
   * Handle mouse out event.
   *
   * @param {object} event The mouse out event.
   */
  mouseout = (event) => {
    this.mouseup(event);
  };

  /**
   * Handle touch start event.
   *
   * @param {object} event The touch start event.
   */
  touchstart = (event) => {
    // call mouse equivalent
    this.mousedown(event);
  };

  /**
   * Handle touch move event.
   *
   * @param {object} event The touch move event.
   */
  touchmove = (event) => {
    // call mouse equivalent
    this.mousemove(event);
  };

  /**
   * Handle touch end event.
   *
   * @param {object} event The touch end event.
   */
  touchend = (event) => {
    // call mouse equivalent
    this.mouseup(event);
  };

  /**
   * Handle mouse wheel event.
   *
   * @param {WheelEvent} event The mouse wheel event.
   */
  wheel = (event) => {
    this.#scrollWhell.wheel(event);
  };

  /**
   * Get the mask view layer.
   *
   * @returns {ViewLayer} The mask view layer.
   */
  #getMaskViewLayer() {
    if (typeof this.#maskDataId === 'undefined') {
      throw new Error(ERROR_MESSAGES.brush.cannotGetMaskVCNoMaskId);
    }
    if (typeof this.#currentLayerGroup === 'undefined') {
      throw new Error('No current layer group');
    }

    const maskLayers = this.#currentLayerGroup.getViewLayersByDataId(
      this.#maskDataId
    );
    if (maskLayers.length === 0) {
      throw new Error(ERROR_MESSAGES.brush.cannotGetMaskVCNoMaskLayers);
    }
    if (maskLayers.length !== 1) {
      logger.warn(ERROR_MESSAGES.brush.moreMaskLayers);
    }
    return maskLayers[0];
  }

  /**
   * Handle key down event.
   *
   * @param {object} event The key down event.
   */
  keydown = (event) => {
    event.context = 'Brush';
    this.#app.onKeydown(event);

    const ctrlOrAlt = event.ctrlKey || event.altKey;

    if (
      !ctrlOrAlt &&
      event.key === '+' &&
      this.#brushSize + 1 < this.#brushSizeRange.max
    ) {
      this.#brushSize += 1;
      logger.debug('Brush size: ' + this.#brushSize);
    } else if (
      !ctrlOrAlt &&
      event.key === '-' &&
      this.#brushSize - 1 >= this.#brushSizeRange.min
    ) {
      this.#brushSize -= 1;
      logger.debug('Brush size: ' + this.#brushSize);
    } else if (!ctrlOrAlt && !Number.isNaN(Number.parseInt(event.key, 10))) {
      this.#brushMode = _BrushMode.Add;
      //const number = Number.parseInt(event.key, 10);
      //this.#setSelectedSegment2(number);
    } else if (!ctrlOrAlt && event.key === 'a') {
      this.#brushMode = _BrushMode.Add;
      logger.debug('Brush mode: ' + this.#brushMode);
    } else if (!ctrlOrAlt && event.key === 'd') {
      this.#brushMode = _BrushMode.Del;
      logger.debug('Brush mode: ' + this.#brushMode);
    }
  };

  /**
   * Activate the tool and activates/deactivates
   * the context menu of all dwv div ids.
   *
   * @param {boolean} bool The flag to activate or not.
   */
  activate(bool) {
    const viewConfigs = this.#app.getDataViewConfigs();
    const allDivIds = getUniqueDataViewConfigsDivIds(viewConfigs);
    if (bool) {
      this.#deactivateDivIdsContextMenu(allDivIds);
      return;
    }
    this.#reactivateDivIdsContextMenu(allDivIds);
  }

  /**
   * Deactivates the context menu on all dwv div ids.
   *
   * @param {string[]} divIds The div ids whose context menu
   *   should be deactivated.
   */
  #deactivateDivIdsContextMenu(divIds) {
    for (const divId of divIds) {
      const element = document.querySelector('#' + divId);
      if (!element) {
        return;
      }
      element.addEventListener('contextmenu', (event) => {
        event.preventDefault();
        event.stopPropagation();
      });
    }
  }

  /**
   * Reactivates the context menu on all dwv div ids.
   *
   * @param {string[]} divIds The div ids whose context menu
   *   should be reactivated.
   */
  #reactivateDivIdsContextMenu(divIds) {
    for (const divId of divIds) {
      const element = document.querySelector('#' + divId);
      if (!element) {
        return;
      }
      element.addEventListener('contextmenu', (_event) => {
        // Intentionally empty
      });
    }
  }

  /**
   * Set the tool live features.
   * See the documentation of the class members for details.
   *
   * @param {object} features The list of features.
   */
  setFeatures(features) {
    if (typeof features.brushSizeRange !== 'undefined') {
      this.#brushSizeRange = features.brushSizeRange;
    }
    if (
      typeof features.brushSize !== 'undefined' &&
      features.brushSize >= this.#brushSizeRange.min &&
      features.brushSize < this.#brushSizeRange.max
    ) {
      this.#brushSize = features.brushSize;
    }

    if (typeof features.brushMode !== 'undefined') {
      this.#brushMode = features.brushMode;
    }

    // createMask is needed since not all properties are always needed,
    // maskDataId could be undefined for example when
    // just changing the brushSize
    if (features.createMask) {
      this.#maskDataId = undefined;
    } else if (typeof features.maskDataId !== 'undefined') {
      this.#maskDataId = features.maskDataId;
    }

    // used in draw events
    if (typeof features.selectedSegmentNumber !== 'undefined') {
      this.#selectedSegmentNumber = features.selectedSegmentNumber;
    }

    if (typeof features.blacklist !== 'undefined') {
      this.#blacklist = features.blacklist;
    }
  }

  /**
   * Initialise the tool.
   */
  init() {
    // does nothing
  }

  /**
   * Get the list of event names that this tool can fire.
   *
   * @returns {Array} The list of event names.
   */
  getEventNames() {
    return [
      'brushdraw',
      'brushremove',
      'erasingactivated',
      'erasingdeactivated'
    ];
  }

  /**
   * Help for this tool.
   *
   * @returns {object} The help content.
   */
  getHelpKeys() {
    return {
      title: 'tool.Brush.name',
      brief: 'tool.Brush.brief',
      mouse: {
        mouse_click: 'tool.Brush.mouse_click'
      },
      touch: {
        touch_click: 'tool.Brush.touch_click'
      }
    };
  }
} // Brush class