Source: papertools/brush.mjs

/**
 * OpenSeadragon paperjs overlay plugin based on paper.js
 * @version 0.4.13
 * 
 * Includes additional open source libraries which are subject to copyright notices
 * as indicated accompanying those segments of code.
 * 
 * Original code:
 * Copyright (c) 2022-2024, Thomas Pearce
 * All rights reserved.
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 * 
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * 
 * * Neither the name of osd-paperjs-annotation nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 * 
 */

import {AnnotationUITool, AnnotationUIToolbarBase} from './annotationUITool.mjs';
import {PaperOffset} from '../paper-offset.mjs';
import { paper } from '../paperjs.mjs';
import { makeFaIcon } from '../utils/faIcon.mjs';
/**
 * Represents a brush tool for creating and modifying annotations.
 * @class
 * @memberof OSDPaperjsAnnotation
 * @extends AnnotationUITool
 * @description The `BrushTool` constructor initialize a brush tool for creating and modifying annotations. It inherits from the `AnnotationUITool` class and includes methods to configure the tool's behavior, set the radius, set erase mode, and handle mouse events for drawing and erasing.
 */
 class BrushTool extends AnnotationUITool{
    /**
   * Create a BrushTool instance.
   * @param {paper.PaperScope} paperScope - The Paper.js PaperScope instance.
   * @property {paper.Tool} tool - The Paper.js tool instance for handling mouse events.
   * @property {boolean} eraseMode - A flag indicating whether the tool is in Erase Mode or Draw Mode.
   * @property {paper.Color} drawColor - The color for drawing strokes.
   * @property {paper.Color} eraseColor - The color for erasing strokes.
   * @property {number} radius - The current radius of the brush tool.
   * @property {paper.Shape.Circle} cursor - The Paper.js Shape.Circle representing the cursor.
   * @property {paper.Group} pathGroup - The Paper.js Group containing the drawing path and the cursor.
   * @description This constructor initializes a new brush tool instance with configurable properties, including the erase mode, draw and erase colors, brush radius, and user interaction handlers.
   */
    constructor(paperScope){
        super(paperScope);
        let self = this;
        this.setToolbarControl(new BrushToolbar(this));

        this.eraseMode = false;
        this.drawColor = new paper.Color('green');
        this.eraseColor= new paper.Color('red');
        this.drawColor.alpha=0.5;
        this.eraseColor.alpha=0.5;

        this.radius = 0;
        this.cursor = new paper.Shape.Circle(new paper.Point(0,0), this.radius);
        this.cursor.set({
            strokeWidth:1,
            strokeColor:'black',
            fillColor:this.drawColor,
            opacity:1,
            visible:false,
        });
        this.cursor.name = 'brushtool';
        this.pathGroup = new paper.Group([new paper.Path(), new paper.Path()]);
        self.project.toolLayer.addChild(this.pathGroup);
        self.project.toolLayer.addChild(this.cursor);

        this.extensions.onActivate = function(){
            self.cursor.radius = self.radius/self.project.getZoom();
            self.cursor.strokeWidth=1/self.project.getZoom();
            self.cursor.visible=true;
            self.tool.minDistance=3/self.project.getZoom();
            self.tool.maxDistance=10/self.project.getZoom();
            self.targetLayer.addChild(self.pathGroup);
        }
        this.extensions.onDeactivate = function(finished){
            self.cursor.visible=false;
            self.project.toolLayer.addChild(self.pathGroup);
            if(finished){
                self.finish();
            } 
        }

        
        this.tool.onMouseWheel = function(ev){
            // console.log('Wheel event',ev);
            ev.preventDefault();
            ev.stopPropagation();
            if(ev.deltaY==0) return;//ignore lateral "scrolls"
            self.toolbarControl.updateBrushRadius({larger:ev.deltaY < 0});
        }
        /**
         * Handle the key down event for the brush tool.
         * @param {paper.KeyEvent} ev - The key down event.
         * @private
         * @description This method handles the key down event for the brush tool, toggling the erase mode using the 'e' key.
         */
        this.tool.extensions.onKeyDown=function(ev){
            if(ev.key=='e'){
                if(self.eraseMode===false){
                    self.setEraseMode(true);
                }
                else {
                    self.eraseMode='keyhold';
                }
            }
        }
        /**
         * Handle the key up event for the brush tool.
         * @param {paper.KeyEvent} ev - The key up event.
         * @private
         * @description This method handles the key up event for the brush tool, releasing the erase mode when the 'e' key is released.
         */
        this.tool.extensions.onKeyUp=function(ev){
            if(ev.key=='e' && self.eraseMode=='keyhold'){
                self.setEraseMode(false);
            }
        }
    }

    onMouseDown(ev){
        ev.preventDefault(); //TODO is this necessary?
        ev.stopPropagation();
        
        if(this.itemToCreate){
            this.itemToCreate.initializeGeoJSONFeature('MultiPolygon');
            this.refreshItems();
        }
        
        this.cursor.position=ev.original.point;

        let path = new paper.Path([ev.point]);
        path.mode = this.eraseMode ? 'erase' : 'draw';
        path.radius = this.radius/this.project.getZoom();

        const strokeWidth = this.cursor.radius * 2 / this.targetLayer.scaling.x;
        
        this.pathGroup.lastChild.replaceWith(path);
        this.pathGroup.lastChild.set({strokeWidth: strokeWidth, fillColor:null, strokeCap:'round'});
        if(path.mode=='erase'){
            this.pathGroup.firstChild.fillColor=this.eraseColor;
            this.pathGroup.lastChild.strokeColor=this.eraseColor;        
        }
        else{
            this.pathGroup.firstChild.fillColor=this.drawColor;
            this.pathGroup.lastChild.strokeColor=this.drawColor;
        }
    }
    onMouseUp(ev){
        this.modifyArea();
    }
    onMouseMove(ev){
        this.cursor.position=ev.original.point;
    }
    onMouseDrag(ev){
        this.cursor.position=ev.original.point;
        if(this.item){
            this.pathGroup.lastChild.add(ev.point);
            this.pathGroup.lastChild.smooth({ type: 'continuous' })
        }
    }
    /**
     * Set the radius of the brush tool.
     * @param {number} r - The new radius value for the brush.
     * @description This method sets the radius of the brush tool, affecting the size of the brush strokes.
     */
    setRadius(r){
        this.radius = r;
        this.cursor.radius=r/this.project.getZoom();
    }

    /**
         * Set the erase mode of the brush tool.
         * @param {boolean} erase - A flag indicating whether the tool should be in Erase Mode or Draw Mode.
         * @description This method toggles the erase mode of the brush tool, changing whether it adds or subtracts strokes.
         */
    setEraseMode(erase){
        this.eraseMode=erase;
        this.cursor.fillColor= erase ? this.eraseColor : this.drawColor;
        this.toolbarControl.setEraseMode(this.eraseMode);
    }  

    finish(){
        this.deactivate();
    }


  /**
   * Modify the drawn area based on the brush strokes.
   * This method is responsible for creating the final shape by modifying the drawn area with the brush strokes.
   * @private
   */ 
    modifyArea(){
        let path = this.pathGroup.lastChild;
        let shape;

        const radius = path.radius / this.targetLayer.scaling.x;
        
        if(path.segments.length>1){                
            shape = PaperOffset.offsetStroke(path, radius, {join:'round',cap:'round', insert:true });
            if(!shape.contains(path.segments[0].point)){
                console.error('Oops! Bad stroke offset! Trying to correct');
                path.segments[0].point.x += 0.001;
                shape = PaperOffset.offsetStroke(path, radius, {join:'round',cap:'round', insert:true });
            }
        }
        else{
            shape = new paper.Path.RegularPolygon({center: path.firstSegment.point, radius: radius, sides: 360 });
        }

        shape.strokeWidth = 1/this.project.getZoom();
        shape.strokeColor = 'black'
        shape.fillColor='yellow'
        shape.flatten();
        shape.name='shapeobject';
        if(!this.item.isBoundingElement){
            let boundingItems = this.item.parent.children.filter(i=>i.isBoundingElement);
            shape.applyBounds(boundingItems);
        }

        path.visible=false;
        let result;
        if(this.eraseMode){
            result = this.item.subtract(shape,{insert:false});
        }
        else{
            result = this.item.unite(shape,{insert:false});  
            // The below code is useful for debugging tiny holes in united paths  
            // if(result?.children){
            //     console.log('Num children', result.children.length);
            //     result.children.forEach(c => console.log('area', c.area));
            // }
        }
        if(result){
            result=result.toCompoundPath();
            const childrenToAdd = result.children.filter(c => {
                // filter out holes with tiny area (area <= 10) - an arbitrary, empirical threshold
                return c.area > 0 || Math.abs(c.area) > 10;
            });
            
            this.item.removeChildren();
            this.item.addChildren(childrenToAdd);
            
            result.remove();     
        }
        shape.remove();
    }  
}
export {BrushTool};

/**
 * Represents the Brush Tool's toolbar in the Annotation Toolkit program.
 * This toolbar provides options to set the brush radius and toggle Erase Mode.
 * @extends AnnotationUIToolbarBase
 * @memberof OSDPaperjsAnnotation.BrushTool
 */
class BrushToolbar extends AnnotationUIToolbarBase{
    /**
   * Create a BrushToolbar instance.
   * @param {BrushTool} brushTool - The parent BrushTool instance.
   */
    constructor(brushTool){
        super(brushTool);
        
        const i = makeFaIcon('fa-brush');
        i.classList.add('rotate-by');
        i.style.setProperty('--rotate-angle','225deg');
        this.button.configure(i,'Brush Tool');

        const fdd = document.createElement('div');
        fdd.classList.add('dropdown','brush-toolbar');
        fdd.setAttribute('data-tool','brush');
        this.dropdown.appendChild(fdd);
        const label = document.createElement('label');
        label.innerHTML = 'Radius';
        fdd.appendChild(label);

        let defaultRadius=20;
        
        this.rangeInput = document.createElement('input');
        fdd.appendChild(this.rangeInput);
        Object.assign(this.rangeInput, {type:'range', min:1, max: 100, step: 1, value:defaultRadius});
        this.rangeInput.addEventListener('change', function(){
            brushTool.setRadius(this.value);
        });

        this.eraseButton = document.createElement('button');
        fdd.appendChild(this.eraseButton);
        this.eraseButton.innerHTML = 'Eraser';
        this.eraseButton.setAttribute('data-action','erase');
        this.eraseButton.addEventListener('click',function(){
            let erasing = this.classList.toggle('active');
            brushTool.setEraseMode(erasing);
        });

        setTimeout(()=>brushTool.setRadius(defaultRadius), 0);
    }
  /**
   * Check if the Brush Tool is enabled for the given mode.
   * @param {string} mode - The current mode of the Annotation Toolkit program.
   * @returns {boolean} A flag indicating if the Brush Tool is enabled for the given mode.
   */
    isEnabledForMode(mode){
        return ['new','Polygon','MultiPolygon'].includes(mode);
    }
  /**
   * Update the brush radius based on the provided update.
   * @param {Object} update - The update object specifying whether to make the brush radius larger or smaller.
   * @property {boolean} update.larger - A flag indicating whether to make the brush radius larger or smaller.
   */
    updateBrushRadius(update){
        if(update.larger){
            this.rangeInput.value = parseInt(this.rangeInput.value) + parseInt(this.rangeInput.step);
            this.rangeInput.dispatchEvent(new Event('change'));
        }
        else{
            this.rangeInput.value = parseInt(this.rangeInput.value) - parseInt(this.rangeInput.step);
            this.rangeInput.dispatchEvent(new Event('change'));
        }
    }
  /**
   * Set the Erase Mode on the toolbar.
   * @param {boolean} erasing - A flag indicating whether the Erase Mode is active or not.
   */
    setEraseMode(erasing){
        erasing ? this.eraseButton.classList.add('active') : this.eraseButton.classList.remove('active');
    }
}