Source: papertools/ellipse.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 { paper } from '../paperjs.mjs';
import { makeFaIcon } from '../utils/faIcon.mjs';
/**
 * Represents an Ellipse Tool in the Annotation Toolkit program.
 * This tool allows users to create and modify ellipses on the canvas.
 * @class
 * @memberof OSDPaperjsAnnotation
 * @extends AnnotationUITool
 * @description The `EllipseToolbar` class provides a user interface toolbar for the ellipse annotation tool. It inherits from the `AnnotationUIToolbarBase` class and includes methods to configure, enable, and update instructions for the ellipse tool.
 */
class EllipseTool extends AnnotationUITool{
  /**
   * Create an EllipseTool instance.
   * @param {paper.PaperScope} paperScope - The Paper.js PaperScope instance.
   * @property {paper.Tool} tool - The Paper.js tool instance for handling mouse events.
   * @property {paper.Layer} toolLayer - The Paper.js project's tool layer where the crosshairTool is added.
   * @property {string|null} mode - The current mode of the Ellipse Tool.
   *     Possible values are 'creating', 'segment-drag', 'modifying', or null.
   * @property {paper.Path.Ellipse|null} creating - The currently active ellipse being created or modified.
   * @property {EllipseToolbar} toolbarControl - The EllipseToolbar instance associated with this EllipseTool.
   */
    constructor(paperScope){
        super(paperScope);
        let self=this;

        this.crosshairTool = new paper.Group({visible:false});
        this.h1 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'black'});
        this.h2 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'white',dashArray:[6,6]});
        this.v1 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'black'});
        this.v2 = new paper.Path({segments:[new paper.Point(0,0),new paper.Point(0,0)],strokeScaling:false,strokeWidth:1,strokeColor:'white',dashArray:[6,6]});
        this.crosshairTool.addChildren([this.h1, this.h2, this.v1, this.v2]);
        this.project.toolLayer.addChild(this.crosshairTool);
        
        this.mode = null;
        this.creating = null;
        
        this.setToolbarControl(new EllipseToolbar(this));
        
        
        this.extensions.onActivate = this.onSelectionChanged = function(){
            if(self.itemToCreate){
                self.mode='creating';
                self.crosshairTool.visible = true;
                self.creating = null;//reset reference to actively creating item
                self.toolbarControl.updateInstructions('new');
            }
            else if(self.creating && self.creating.parent==self.item){
                self.mode='creating';
                self.crosshairTool.visible = true;
                self.toolbarControl.updateInstructions('new');
            }
            else if (self.item){
                self.creating=null;//reset reference to actively creating item
                self.mode='modifying';
                self.crosshairTool.visible = false;
                self.toolbarControl.updateInstructions('Point:Ellipse');
            }
            else {
                self.creating=null;//reset reference to actively creating item
                self.mode=null;
                self.crosshairTool.visible = false;
                self.toolbarControl.updateInstructions('Point:Ellipse');
            }
        }
        this.extensions.onDeactivate = function(finished){
            if(finished) self.creating = null;
            self.crosshairTool.visible=false;
            self.mode=null;
            self.project.overlay.removeClass('rectangle-tool-resize');
        }
        
    }

    onMouseDown(ev){
        if(this.itemToCreate){
            this.itemToCreate.initializeGeoJSONFeature('Point', 'Ellipse');
            this.refreshItems();
            
            let r=new paper.Path.Ellipse(ev.point,ev.point);
            this.creating = r;
            this.item.removeChildren();
            this.item.addChild(r);
            this.mode='creating';
        } else if(this.item){
            // first do a hit test on the segments
            let result = this.item.hitTest(ev.point,{fill:false,stroke:false,segments:true,tolerance:this.getTolerance(5)})
            if(result){
                
                this.mode='segment-drag';
                let idx=result.segment.path.segments.indexOf(result.segment);
                let oppositeIdx=(idx+2) % result.segment.path.segments.length;
                //save reference to the original points of the ellipse before the drag started
                this.points = {
                    opposite: result.segment.path.segments[oppositeIdx].point.clone(),
                    drag: result.segment.point.clone(),
                    p1: result.segment.next.point.clone(),
                    p2: result.segment.previous.point.clone(),
                }
                return;
            } 
            // next hit test on "fill"
            if(this.item.contains(ev.point)){
                // crosshairTool.visible=true;
                this.mode='fill-drag';
                return;
            }
        }
    }
    onMouseDrag(ev){
        let currPt;
        let center = this.item.bounds.center;
        if(this.mode=='creating'){
            let angle = -(this.item.view.getRotation() + this.item.layer.getRotation());
            if(this.item.view.getFlipped()){
                angle = 180 - angle;
            }
            
            if(ev.modifiers.command || ev.modifiers.control){
                let delta = ev.point.subtract(ev.downPoint);
                let axes = [[1,1],[1,-1],[-1,-1],[-1,1]].map(p=>new paper.Point(p[0],p[1]).rotate(angle));
                let closestAxis = axes.sort( (a, b) => a.dot(delta) - b.dot(delta))[0];
                let proj = delta.project(closestAxis);
                currPt = ev.downPoint.add(proj);
            } else {
                currPt = ev.point;
            }
            
            let r=new paper.Rectangle(ev.downPoint.rotate(-angle,center),currPt.rotate(-angle, center));
            let ellipse = new paper.Path.Ellipse(r); 
            ellipse.rotate(angle, center);
            this.item.children[0].set({segments: ellipse.segments});
            ellipse.remove();

        } else if(this.mode=='segment-drag'){
            let dragdelta = ev.point.subtract(this.points.opposite);
            let axis = this.points.drag.subtract(this.points.opposite);
            let proj = dragdelta.project(axis);
            let angle = axis.angle;
            
            if(ev.modifiers.command || ev.modifiers.control){
                //scale proportionally
                let scalefactor = proj.length / axis.length;
                let halfproj = proj.divide(2);
                let center = this.points.opposite.add(halfproj);
                let r1 = halfproj.length;
                let r2 = Math.abs(this.points.p1.subtract(this.points.opposite).multiply(scalefactor).cross(proj.normalize()));
                let ellipse = new paper.Path.Ellipse({center:center, radius: [r1, r2]}).rotate(angle);
                this.item.children[0].set({segments: ellipse.segments});
                ellipse.remove();
            } else {
                //scale in one direction only
                let halfproj = proj.divide(2);
                let center = this.points.opposite.add(halfproj);
                let r1 = halfproj.length;
                let r2 = Math.abs(this.points.p1.subtract(this.points.opposite).cross(proj.normalize()));
                let ellipse = new paper.Path.Ellipse({center:center, radius: [r1, r2]}).rotate(angle);
                this.item.children[0].set({segments: ellipse.segments});
                ellipse.remove();
            }

        } else if(this.mode == 'fill-drag') {
            this.item.translate(ev.delta);
            return;
        } else{
            this.setCursorPosition(ev.original.point);
            return;
        }
        this.setCursorPosition(this.targetLayer.matrix.transform(currPt));
        
    }
    onMouseMove(ev){
        this.setCursorPosition(ev.original.point);
        if(this.mode == 'modifying'){
            let hitResult = this.item.hitTest(ev.point,{fill:false,stroke:false,segments:true,tolerance:this.getTolerance(5)});
            if(hitResult){
                this.project.overlay.addClass('rectangle-tool-resize');
            } else { 
                this.project.overlay.removeClass('rectangle-tool-resize');
            }

            if(this.item.contains(ev.point)){
                this.project.overlay.addClass('rectangle-tool-move');
            } else {
                this.project.overlay.removeClass('rectangle-tool-move');
            }
        }
    }
   
    
    onMouseUp(){
        this.mode='modifying';
        this.crosshairTool.visible=false;
        this.creating=null;
        this.toolbarControl.updateInstructions('Point:Ellipse');
    }

    /**
     * Sets the cursor position and updates the crosshairTool to provide visual feedback.
     * This function calculates the position of the crosshair lines based on the current cursor position.
     * The crosshairTool displays lines intersecting at the cursor position, providing a reference for alignment and positioning.
     * @private
     * @param {paper.Point} point - The current cursor position in Paper.js coordinate system.
     */
    setCursorPosition(point){
        //to do: account for view rotation
        // let viewBounds=tool.view.bounds;
        let pt = this.tool.view.projectToView(point);
        let left = this.tool.view.viewToProject(new paper.Point(0, pt.y))
        let right = this.tool.view.viewToProject(new paper.Point(this.tool.view.viewSize.width, pt.y))
        let top = this.tool.view.viewToProject(new paper.Point(pt.x, 0))
        let bottom = this.tool.view.viewToProject(new paper.Point(pt.x, this.tool.view.viewSize.height))
        // console.log(viewBounds)

        let h1 = this.h1;
        let h2 = this.h2;
        let v1 = this.v1;
        let v2 = this.v2;

        h1.segments[0].point = left;
        h2.segments[0].point = left;
        h1.segments[1].point = right;
        h2.segments[1].point = right;
        v1.segments[0].point = top;
        v2.segments[0].point = top;
        v1.segments[1].point = bottom;
        v2.segments[1].point = bottom;
    }
    
}
export{EllipseTool};

/**
 * Represents an ellipse annotation tool's user interface toolbar.
 * @class
 * @memberof OSDPaperjsAnnotation.EllipseTool
 * @extends AnnotationUIToolbarBase
 * @description The `EllipseToolbar` class provides a user interface toolbar for the ellipse annotation tool. It inherits from the `AnnotationUIToolbarBase` class and includes methods to configure, enable, and update instructions for the ellipse tool.
 */
class EllipseToolbar extends AnnotationUIToolbarBase{
    /**
     * Create a new EllipseToolbar instance.
     * @param {AnnotationTool} tool - The annotation tool associated with the toolbar.
     * @description This constructor initializes a new `EllipseToolbar` instance by providing the associated annotation tool.
     */
    constructor(tool){
        super(tool);
        
        const i = makeFaIcon('fa-circle');
        this.button.configure(i,'Ellipse Tool');
        
        this.instructions = document.createElement('span');
        this.instructions.innerHTML = 'Click and drag to create an ellipse';
        this.dropdown.appendChild(this.instructions);

    }
     /**
     * Check if the ellipse tool is enabled for the given mode.
     * @param {string} mode - The mode of the annotation tool.
     * @returns {boolean} Returns `true` if the mode is 'new' or 'Point:Ellipse', otherwise `false`.
     * @description This method checks if the ellipse tool is enabled for the given mode by comparing it with the supported modes.
     */
    isEnabledForMode(mode){
        return ['new','Point:Ellipse'].includes(mode);
    }
    /**
     * Update the instructions based on the annotation tool's mode.
     * @param {string} mode - The mode of the annotation tool.
     * @description This method updates the instructions text based on the annotation tool's mode. It provides appropriate instructions for different modes.
     */
    updateInstructions(mode){
        const text = mode=='new'?'Click and drag to create an ellipse' : mode=='Point:Ellipse' ? 'Drag a point to resize' : '???';
        this.instructions.innerHTML = text;
    }
}