Source: core/sceneObjs/special/Handle.js

/*
 * Copyright 2024 The Ray Optics Simulation authors and contributors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import BaseSceneObj from '../BaseSceneObj.js';
import geometry from '../../geometry.js';
import Mouse from '../../Mouse.js';
import i18next from 'i18next';

/**
 * The handle created when holding ctrl and click several points.
 * @class
 * @extends BaseSceneObj
 * @memberof sceneObjs
 * @property {Point} p1 - The position of the handle.
 * @property {Point} p2 - The position of the rotation/scale center.
 * @property {Array<number>} objIndices - The indices of the objects bound to the handle.
 * @property {Array<ControlPoint>} controlPoints - The individual control points (in addition to the bound objects) bound to the handle.
 * @property {string} transformation - The transformation applied to the control points when dragging the handle, with the corresponding arrows marked on the handle. Possible values are "default", "translation", "xTranslation", "yTranslation", "rotation", "scaling". The "default" is the only behavior in older versions where no arrow is marked on the handle, and the transformation is determined by the ctrl and shift keys.
 * @property {number} moveStep - The step size for translation.
 * @property {number} rotateStep - The step size for rotation in degrees.
 * @property {number} scaleStep - The step size for scaling in percentage. For example, 10 means (1/1.1)x, 1x, 1.1x, (1.1)^2 x, etc; -10 means 0.9x, 1x, (1/0.9)x, (1/0.9)^2 x, etc.
 * @property {boolean} notDone - Whether the construction of the handle is complete.
 */
class Handle extends BaseSceneObj {
  static type = 'Handle';
  static isOptical = true; // As the handle may bind to objects which are optical, this should be regarded as true.
  static serializableDefaults = {
    p1: null,
    p2: null,
    objIndices: [],
    controlPoints: [],
    transformation: "default",
    moveStep: 0,
    rotateStep: 0,
    scaleStep: 0,
    notDone: false
  };

  serialize() {
    let jsonObj = super.serialize();

    if (jsonObj.controlPoints) {
      // Remove some redundent properties in the control points to reduce the size of the JSON.
      jsonObj.controlPoints = jsonObj.controlPoints.map(controlPoint => {
        let controlPointCopy = JSON.parse(JSON.stringify(controlPoint));
        delete controlPointCopy.dragContext.originalObj; // This should be inferred from `scene.objs[controlPoint.targetObjIndex]` directly.
        delete controlPointCopy.dragContext.hasDuplicated; // Always false.
        delete controlPointCopy.dragContext.isByHandle; // Always true.
        delete controlPointCopy.dragContext.targetPoint; // The target point is already stored in the newPoint.
        delete controlPointCopy.dragContext.snapContext; // Snapping is not possible with the handle.

        return controlPointCopy;
      });
    }

    return jsonObj;
  }

  populateObjBar(objBar) {
    objBar.setTitle(i18next.t('simulator:sceneObjs.Handle.handle'));
    objBar.createDropdown(i18next.t('simulator:sceneObjs.Handle.transformation'), this.transformation, {
      'default': i18next.t('simulator:common.defaultOption'),
      'translation': i18next.t('simulator:sceneObjs.Handle.transformations.translation'),
      'xTranslation': i18next.t('simulator:sceneObjs.Handle.transformations.xTranslation'),
      'yTranslation': i18next.t('simulator:sceneObjs.Handle.transformations.yTranslation'),
      'rotation': i18next.t('simulator:sceneObjs.Handle.transformations.rotation'),
      'scaling': i18next.t('simulator:sceneObjs.Handle.transformations.scaling')
    }, function (obj, value) {
      obj.transformation = value;
    }, null, true);

    switch (this.transformation) {
      case "xTranslation":
      case "yTranslation":
        if (objBar.showAdvanced(!this.arePropertiesDefault(['moveStep']))) {
          objBar.createNumber(i18next.t('simulator:sceneObjs.Handle.step'), 0, 100, 1, this.moveStep, function (obj, value) {
            obj.moveStep = Math.abs(value);
          }, i18next.t('simulator:sceneObjs.common.lengthUnitInfo'), true);
        }
        break;
      case "rotation":
        if (objBar.showAdvanced(!this.arePropertiesDefault(['rotateStep']))) {
          objBar.createNumber(i18next.t('simulator:sceneObjs.Handle.step') + ' (\u00b0)', 0, 360, 1, this.rotateStep, function (obj, value) {
            obj.rotateStep = Math.abs(value);
          }, null, true);
        }
        break;
      case "scaling":
        if (objBar.showAdvanced(!this.arePropertiesDefault(['scaleStep']))) {
          objBar.createNumber(i18next.t('simulator:sceneObjs.Handle.step') + ' (%)', 1, 200, 1, this.scaleStep, function (obj, value) {
            obj.scaleStep = value;
          }, null, true);
        }
        break;
    }
  }

  getZIndex() {
    return -Infinity;
  }

  draw(canvasRenderer, isAboveLight, isHovered) {
    const ctx = canvasRenderer.ctx;
    const ls = canvasRenderer.lengthScale;
    ctx.lineWidth = 1 * ls;

    if (this.transformation == "default" || isHovered) {
      for (var i in this.controlPoints) {
        ctx.globalAlpha = 1;
        ctx.beginPath();
        ctx.strokeStyle = this.notDone ? 'cyan' : isHovered ? 'cyan' : ('gray');
        ctx.setLineDash([2 * ls, 2 * ls]);
        ctx.arc(this.controlPoints[i].newPoint.x, this.controlPoints[i].newPoint.y, 5 * ls, 0, Math.PI * 2, false);
        ctx.stroke();
        ctx.setLineDash([]);
      }
    }

    const arrowLineWidthCross = 5 * ls;
    const arrowLineWidthSingle = 8 * ls;
    const arrowLengthCross = 30 * ls;
    const arrowLengthSingle = 28 * ls;
    const arrowHeadSizeCross = 8 * ls;
    const arrowHeadSizeSingle = 12 * ls;

    if (!this.notDone) {
      ctx.globalAlpha = 1;
      // Draw the handle arrow according to the transformation
      switch (this.transformation) {
        case "default":
          ctx.beginPath();
          ctx.strokeStyle = isHovered ? 'cyan' : ('gray');
          ctx.arc(this.p1.x, this.p1.y, 2 * ls, 0, Math.PI * 2, false);
          ctx.stroke();
          ctx.beginPath();
          ctx.arc(this.p1.x, this.p1.y, 5 * ls, 0, Math.PI * 2, false);
          ctx.stroke();
          break;
        case "translation":
          // Cross arrows
          ctx.beginPath();
          ctx.strokeStyle = isHovered ? 'cyan' : ('white');
          ctx.fillStyle = isHovered ? 'cyan' : ('white');
          ctx.lineWidth = arrowLineWidthCross;
          ctx.moveTo(this.p1.x - arrowLengthCross / 2, this.p1.y);
          ctx.lineTo(this.p1.x + arrowLengthCross / 2, this.p1.y);
          ctx.stroke();
          ctx.beginPath();
          ctx.moveTo(this.p1.x, this.p1.y - arrowLengthCross / 2);
          ctx.lineTo(this.p1.x, this.p1.y + arrowLengthCross / 2);
          ctx.stroke();
          this.drawArrowHead(canvasRenderer, this.p1.x + (arrowLengthCross + arrowHeadSizeCross) / 2, this.p1.y, 0, arrowHeadSizeCross);
          this.drawArrowHead(canvasRenderer, this.p1.x, this.p1.y + (arrowLengthCross + arrowHeadSizeCross) / 2, Math.PI / 2, arrowHeadSizeCross);
          this.drawArrowHead(canvasRenderer, this.p1.x - (arrowLengthCross + arrowHeadSizeCross) / 2, this.p1.y, Math.PI, arrowHeadSizeCross);
          this.drawArrowHead(canvasRenderer, this.p1.x, this.p1.y - (arrowLengthCross + arrowHeadSizeCross) / 2, -Math.PI / 2, arrowHeadSizeCross);
          break;
        case "xTranslation":
          // Horizontal arrow
          ctx.beginPath();
          ctx.strokeStyle = isHovered ? 'cyan' : ('white');
          ctx.fillStyle = isHovered ? 'cyan' : ('white');
          ctx.lineWidth = arrowLineWidthSingle;
          ctx.moveTo(this.p1.x - arrowLengthSingle / 2, this.p1.y);
          ctx.lineTo(this.p1.x + arrowLengthSingle / 2, this.p1.y);
          ctx.stroke();
          this.drawArrowHead(canvasRenderer, this.p1.x + (arrowLengthSingle + arrowHeadSizeSingle) / 2, this.p1.y, 0, arrowHeadSizeSingle);
          this.drawArrowHead(canvasRenderer, this.p1.x - (arrowLengthSingle + arrowHeadSizeSingle) / 2, this.p1.y, Math.PI, arrowHeadSizeSingle);
          break;
        case "yTranslation":
          // Vertical arrow
          ctx.beginPath();
          ctx.strokeStyle = isHovered ? 'cyan' : ('white');
          ctx.fillStyle = isHovered ? 'cyan' : ('white');
          ctx.lineWidth = arrowLineWidthSingle;
          ctx.moveTo(this.p1.x, this.p1.y - arrowLengthSingle / 2);
          ctx.lineTo(this.p1.x, this.p1.y + arrowLengthSingle / 2);
          ctx.stroke();
          this.drawArrowHead(canvasRenderer, this.p1.x, this.p1.y + (arrowLengthSingle + arrowHeadSizeSingle) / 2, Math.PI / 2, arrowHeadSizeSingle);
          this.drawArrowHead(canvasRenderer, this.p1.x, this.p1.y - (arrowLengthSingle + arrowHeadSizeSingle) / 2, -Math.PI / 2, arrowHeadSizeSingle);
          break;
        case "rotation":
          // A bent arrow
          ctx.beginPath();
          ctx.strokeStyle = isHovered ? 'cyan' : ('white');
          ctx.fillStyle = isHovered ? 'cyan' : ('white');
          ctx.lineWidth = arrowLineWidthSingle;
          //const radius = geometry.distance(this.p1, this.p2);
          const radius = 40 * ls;
          const angle = Math.atan2(this.p1.y - this.p2.y, this.p1.x - this.p2.x);
          const center = geometry.point(this.p1.x - radius * Math.cos(angle), this.p1.y - radius * Math.sin(angle));
          const angle1 = angle - arrowLengthSingle / radius / 2;
          const angle1_ = angle - (arrowLengthSingle + arrowHeadSizeSingle) / radius / 2;
          const angle2 = angle + arrowLengthSingle / radius / 2;
          const angle2_ = angle + (arrowLengthSingle + arrowHeadSizeSingle) / radius / 2;
          ctx.arc(center.x, center.y, radius, angle1, angle2);
          ctx.stroke();
          this.drawArrowHead(canvasRenderer, center.x + radius * Math.cos(angle2_), center.y + radius * Math.sin(angle2_), angle2 + Math.PI / 2, arrowHeadSizeSingle);
          this.drawArrowHead(canvasRenderer, center.x + radius * Math.cos(angle1_), center.y + radius * Math.sin(angle1_), angle1 - Math.PI / 2, arrowHeadSizeSingle);
          break;
        case "scaling":
          // An arrow with different arrow head sizes
          ctx.beginPath();
          ctx.strokeStyle = isHovered ? 'cyan' : ('white');
          ctx.fillStyle = isHovered ? 'cyan' : ('white');
          ctx.lineWidth = arrowLineWidthSingle;
          const radialAngle = Math.atan2(this.p1.y - this.p2.y, this.p1.x - this.p2.x);
          const arrowHeadSizeLarge = 14 * ls;
          const arrowHeadSizeSmall = 10 * ls;
          ctx.beginPath();
          ctx.moveTo(this.p1.x - arrowLengthSingle / 2 * Math.cos(radialAngle), this.p1.y - arrowLengthSingle / 2 * Math.sin(radialAngle));
          ctx.lineTo(this.p1.x + arrowLengthSingle / 2 * Math.cos(radialAngle), this.p1.y + arrowLengthSingle / 2 * Math.sin(radialAngle));
          ctx.stroke();
          this.drawArrowHead(canvasRenderer, this.p1.x + (arrowLengthSingle + arrowHeadSizeLarge) / 2 * Math.cos(radialAngle), this.p1.y + (arrowLengthSingle + arrowHeadSizeLarge) / 2 * Math.sin(radialAngle), radialAngle, arrowHeadSizeLarge);
          this.drawArrowHead(canvasRenderer, this.p1.x - (arrowLengthSingle + arrowHeadSizeSmall) / 2 * Math.cos(radialAngle), this.p1.y - (arrowLengthSingle + arrowHeadSizeSmall) / 2 * Math.sin(radialAngle), radialAngle + Math.PI, arrowHeadSizeSmall);
      }

      // Draw the rotation/scale center
      ctx.lineWidth = 1 * ls;
      if (this.transformation == "default" || this.transformation == "rotation" || this.transformation == "scaling") {
        ctx.strokeStyle = isHovered ? 'cyan' : ('gray');
        ctx.beginPath();
        ctx.moveTo(this.p2.x - 5 * ls, this.p2.y);
        ctx.lineTo(this.p2.x + 5 * ls, this.p2.y);
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(this.p2.x, this.p2.y - 5 * ls);
        ctx.lineTo(this.p2.x, this.p2.y + 5 * ls);
        ctx.stroke();
      }
    } else if (this.p1) {
      ctx.beginPath();
      ctx.strokeStyle = 'cyan';
      ctx.beginPath();
      ctx.arc(this.p1.x, this.p1.y, 2 * ls, 0, Math.PI * 2, false);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(this.p1.x, this.p1.y, 5 * ls, 0, Math.PI * 2, false);
      ctx.stroke();
    }
  }

  move(diffX, diffY) {
    if (this.notDone) return;

    // Move handle and rotation/scale center
    this.p1.x = this.p1.x + diffX;
    this.p1.y = this.p1.y + diffY;
    this.p2.x = this.p2.x + diffX;
    this.p2.y = this.p2.y + diffY;

    // Move bound objects
    let allSuccess = true;
    let unsupportedType = null;

    for (var i in this.objIndices) {
      const obj = this.scene.objs[this.objIndices[i]];
      const result = obj.move(diffX, diffY);
      if (!result) {
        unsupportedType = obj.constructor.type;
        allSuccess = false;
      }
    }

    // Move bound control points
    for (var i in this.controlPoints) {
      this.controlPoints[i].dragContext.originalObj = this.scene.objs[this.controlPoints[i].targetObjIndex].serialize();
      this.controlPoints[i].dragContext.isByHandle = true;
      this.controlPoints[i].dragContext.hasDuplicated = false;
      this.controlPoints[i].dragContext.targetPoint = { x: this.controlPoints[i].newPoint.x, y: this.controlPoints[i].newPoint.y };
      this.controlPoints[i].dragContext.snapContext = {};

      this.controlPoints[i].newPoint.x = this.controlPoints[i].newPoint.x + diffX;
      this.controlPoints[i].newPoint.y = this.controlPoints[i].newPoint.y + diffY;
      this.scene.objs[this.controlPoints[i].targetObjIndex].onDrag(new Mouse(JSON.parse(JSON.stringify(this.controlPoints[i].newPoint)), this.scene, false, 2), JSON.parse(JSON.stringify(this.controlPoints[i].dragContext)), false, false);
    }

    // Set or clear warning
    if (!allSuccess) {
      this.warning = i18next.t('simulator:sceneObjs.Handle.transformationWarning', {
        obj: unsupportedType,
        transformation: i18next.t('simulator:sceneObjs.Handle.transformations.translation')
      });
    } else {
      this.warning = null;
    }

    return allSuccess;
  }

  rotate(angle, center = null) {
    if (this.notDone) return;

    // Use p2 as the default center if none provided
    center = center || this.p2;
    
    // Apply rotation to p1 (the handle position)
    let x = this.p1.x - center.x;
    let y = this.p1.y - center.y;
    this.p1.x = Math.cos(angle) * x - Math.sin(angle) * y + center.x;
    this.p1.y = Math.sin(angle) * x + Math.cos(angle) * y + center.y;
    
    // Only rotate p2 if it's not the center of rotation
    if (center !== this.p2) {
      x = this.p2.x - center.x;
      y = this.p2.y - center.y;
      this.p2.x = Math.cos(angle) * x - Math.sin(angle) * y + center.x;
      this.p2.y = Math.sin(angle) * x + Math.cos(angle) * y + center.y;
    }
    
    // Rotate bound objects
    let allSuccess = true;
    let unsupportedType = null;

    for (var i in this.objIndices) {
      const obj = this.scene.objs[this.objIndices[i]];
      const result = obj.rotate(angle, center);
      if (!result) {
        unsupportedType = obj.constructor.type;
        allSuccess = false;
      }
    }
    
    // Rotate bound control points
    for (var i in this.controlPoints) {
      this.controlPoints[i].dragContext.originalObj = this.scene.objs[this.controlPoints[i].targetObjIndex].serialize();
      this.controlPoints[i].dragContext.isByHandle = true;
      this.controlPoints[i].dragContext.hasDuplicated = false;
      this.controlPoints[i].dragContext.targetPoint = { x: this.controlPoints[i].newPoint.x, y: this.controlPoints[i].newPoint.y };
      this.controlPoints[i].dragContext.snapContext = {};
      
      // Rotate the control point
      x = this.controlPoints[i].newPoint.x - center.x;
      y = this.controlPoints[i].newPoint.y - center.y;
      this.controlPoints[i].newPoint.x = Math.cos(angle) * x - Math.sin(angle) * y + center.x;
      this.controlPoints[i].newPoint.y = Math.sin(angle) * x + Math.cos(angle) * y + center.y;
      
      // Update the target object
      this.scene.objs[this.controlPoints[i].targetObjIndex].onDrag(
        new Mouse(JSON.parse(JSON.stringify(this.controlPoints[i].newPoint)), this.scene, false, 2),
        JSON.parse(JSON.stringify(this.controlPoints[i].dragContext)),
        false,
        false
      );
    }
    
    // Set or clear warning
    if (!allSuccess) {
      this.warning = i18next.t('simulator:sceneObjs.Handle.transformationWarning', {
        obj: unsupportedType,
        transformation: i18next.t('simulator:sceneObjs.Handle.transformations.rotation')
      });
    } else {
      this.warning = null;
    }
    
    return allSuccess;
  }

  scale(scale, center = null) {
    if (this.notDone) return;
    
    // Use p2 as the default center if none provided
    center = center || this.p2;
    
    // Apply scaling to p1 (the handle position)
    this.p1.x = (this.p1.x - center.x) * scale + center.x;
    this.p1.y = (this.p1.y - center.y) * scale + center.y;
    
    // Only scale p2 if it's not the center of scaling
    if (center !== this.p2) {
      this.p2.x = (this.p2.x - center.x) * scale + center.x;
      this.p2.y = (this.p2.y - center.y) * scale + center.y;
    }
        
    // Scale bound objects
    let allSuccess = true;
    let unsupportedType = null;

    for (var i in this.objIndices) {
      const obj = this.scene.objs[this.objIndices[i]];
      const result = obj.scale(scale, center);
      if (!result) {
        unsupportedType = obj.constructor.type;
        allSuccess = false;
      }
    }
    
    // Scale bound control points
    for (var i in this.controlPoints) {
      this.controlPoints[i].dragContext.originalObj = this.scene.objs[this.controlPoints[i].targetObjIndex].serialize();
      this.controlPoints[i].dragContext.isByHandle = true;
      this.controlPoints[i].dragContext.hasDuplicated = false;
      this.controlPoints[i].dragContext.targetPoint = { x: this.controlPoints[i].newPoint.x, y: this.controlPoints[i].newPoint.y };
      this.controlPoints[i].dragContext.snapContext = {};
      
      // Scale the control point
      this.controlPoints[i].newPoint.x = (this.controlPoints[i].newPoint.x - center.x) * scale + center.x;
      this.controlPoints[i].newPoint.y = (this.controlPoints[i].newPoint.y - center.y) * scale + center.y;
      
      // Update the target object
      this.scene.objs[this.controlPoints[i].targetObjIndex].onDrag(
        new Mouse(JSON.parse(JSON.stringify(this.controlPoints[i].newPoint)), this.scene, false, 2),
        JSON.parse(JSON.stringify(this.controlPoints[i].dragContext)),
        false,
        false
      );
    }
    
    // Set or clear warning
    if (!allSuccess) {
      this.warning = i18next.t('simulator:sceneObjs.Handle.transformationWarning', {
        obj: unsupportedType,
        transformation: i18next.t('simulator:sceneObjs.Handle.transformations.scaling')
      });
    } else {
      this.warning = null;
    }
    
    return allSuccess;
  }

  checkMouseOver(mouse) {
    let dragContext = {};
    if (this.notDone) return;
    if (mouse.isOnPoint(this.p1) || (this.transformation != "default" && geometry.distance(this.p1, mouse.pos) < 20 * this.scene.lengthScale)) {
      dragContext.part = 1;
      dragContext.targetPoint_ = geometry.point(this.p1.x, this.p1.y);
      dragContext.mousePos0 = geometry.point(this.p1.x, this.p1.y);
      dragContext.snapContext = {};
      return dragContext;
    }
    if ((this.transformation == "default" || this.transformation == "rotation" || this.transformation == "scaling") && mouse.isOnPoint(this.p2)) {
      dragContext.part = 2;
      dragContext.targetPoint = geometry.point(this.p2.x, this.p2.y);
      dragContext.mousePos0 = geometry.point(this.p2.x, this.p2.y);
      dragContext.snapContext = {};
      return dragContext;
    }
  }

  onDrag(mouse, dragContext, ctrl, shift) {
    if (this.notDone) return;
    
    if (shift) {
      var mousePos = mouse.getPosSnappedToDirection(dragContext.mousePos0, [{ x: 1, y: 0 }, { x: 0, y: 1 }], dragContext.snapContext);
    } else {
      var mousePos = mouse.getPosSnappedToGrid();
      dragContext.snapContext = {}; // Unlock the dragging direction when the user release the shift key
    }
    
    if (dragContext.part == 1) {
      if ((ctrl && shift) || this.transformation == "scaling") { 
        // Scaling
        var factor = geometry.distance(this.p2, mouse.pos) / geometry.distance(this.p2, dragContext.targetPoint_);
        if (factor < 1e-5) return;
        
        // Apply scale step if enabled
        if (this.scaleStep !== 0) {
          // Calculate the step size
          const stepFactor = this.scaleStep > 0 ? 
                            (1 + this.scaleStep / 100) : 
                            (1 / (1 - Math.abs(this.scaleStep) / 100));
          
          // Find the closest power of stepFactor
          // ln(factor) / ln(stepFactor) gives us how many steps we need
          const stepCount = Math.round(Math.log(factor) / Math.log(stepFactor));
          
          // Replace with the exact power of the step factor
          factor = Math.pow(stepFactor, stepCount);
        }
        
        // Call the scale method with the snapped factor
        this.scale(factor, this.p2);
      } else if (ctrl || this.transformation == "rotation") {
        // Rotation
        var theta = Math.atan2(this.p2.y - mouse.pos.y, this.p2.x - mouse.pos.x) - 
                    Math.atan2(this.p2.y - dragContext.targetPoint_.y, this.p2.x - dragContext.targetPoint_.x);
        
        // Apply rotation step if enabled
        if (this.rotateStep > 0) {
          // Convert step from degrees to radians
          const stepRadians = this.rotateStep * Math.PI / 180;
          
          // Normalize to -180 to 180 degree range
          while (theta > Math.PI) theta -= 2 * Math.PI;
          while (theta <= -Math.PI) theta += 2 * Math.PI;

          // Round to nearest step
          theta = Math.round(theta / stepRadians) * stepRadians;
          
        }
        
        // Call the rotate method with the snapped angle
        this.rotate(theta, this.p2);
      } else {
        // Translation
        var diffX = this.transformation == "yTranslation" ? 0 : mousePos.x - dragContext.targetPoint_.x;
        var diffY = this.transformation == "xTranslation" ? 0 : mousePos.y - dragContext.targetPoint_.y;
        
        // Apply movement step if enabled
        if (this.moveStep > 0) {
          // Round to nearest step
          // Scale the step by the scene's lengthScale to make it consistent with grid size
          const step = this.moveStep * this.scene.lengthScale;
          diffX = Math.round(diffX / step) * step;
          diffY = Math.round(diffY / step) * step;
        }
        
        // Call the move method with the snapped differences
        this.move(diffX, diffY);
      }
      
      // Update the target point to the new p1 position
      dragContext.targetPoint_.x = this.p1.x;
      dragContext.targetPoint_.y = this.p1.y;
    }

    if (dragContext.part == 2) {
      this.p2.x = mousePos.x;
      this.p2.y = mousePos.y;
    }
  }

  /**
   * Add (bind) a control point to the handle.
   * @param {ControlPoint} controlPoint - The control point to be bound.
   */
  addControlPoint(controlPoint) {
    controlPoint.dragContext.originalObj = this.scene.objs[controlPoint.targetObjIndex];
    controlPoint.dragContext.isByHandle = true;
    controlPoint.dragContext.hasDuplicated = false;
    controlPoint.newPoint = controlPoint.dragContext.targetPoint;
    controlPoint = JSON.parse(JSON.stringify(controlPoint));
    this.controlPoints.push(controlPoint);
  }

  /**
   * Add (bind) an entire object to the handle.
   * @param {number} objIndex - The index of the object to be bound.
   */
  addObject(objIndex) {
    // Check if this object is already bound to avoid duplicates
    if (!this.objIndices.includes(objIndex)) {
      this.objIndices.push(objIndex);
    }
  }

  /**
   * Finish creating the handle.
   * @param {Point} point - The position of the handle.
   */
  finishHandle(point) {
    this.p1 = point;
    
    let totalX = 0;
    let totalY = 0;
    let pointCount = 0;
    
    // Add all control points to the average
    for (let i in this.controlPoints) {
      totalX += this.controlPoints[i].newPoint.x;
      totalY += this.controlPoints[i].newPoint.y;
      pointCount++;
    }
    
    // Check default centers from bound objects
    for (let i in this.objIndices) {
      const obj = this.scene.objs[this.objIndices[i]];
      const defaultCenter = obj.getDefaultCenter && obj.getDefaultCenter();
      
      if (defaultCenter) {
        totalX += defaultCenter.x;
        totalY += defaultCenter.y;
        pointCount++;
      }
    }
    
    // Calculate the average position if we have any points
    if (pointCount > 0) {
      this.p2 = geometry.point(totalX / pointCount, totalY / pointCount);
    } 
    // If no points to average, fall back to viewport center
    else {
      const centerX = (this.scene.width * 0.5 - this.scene.origin.x) / this.scene.scale;
      const centerY = (this.scene.height * 0.5 - this.scene.origin.y) / this.scene.scale;
      this.p2 = geometry.point(centerX, centerY);
    }
    
    this.notDone = false;
  }

  /**
   * Draw an filled arrow head at the given position with the given angle and size.
   * @param {CanvasRenderer} canvasRenderer - The canvas renderer.
   * @param {number} x - The x-coordinate of the arrow head.
   * @param {number} y - The y-coordinate of the arrow head.
   * @param {number} angle - The angle of the arrow head.
   * @param {number} size - The size of the arrow head.
   */
  drawArrowHead(canvasRenderer, x, y, angle, size) {
    const ctx = canvasRenderer.ctx;
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(angle);
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(-size, -size);
    ctx.lineTo(-size, size);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

};

export default Handle;