chromium/chrome/browser/resources/lens/overlay/hit.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type {PointF, RectF} from '//resources/mojo/ui/gfx/geometry/mojom/geometry.mojom-webui.js';

import type {Word} from './text.mojom-webui.js';
import {WritingDirection} from './text.mojom-webui.js';

// Percentage of image size to add to the word bounding boxes hit box.
const BOUNDING_BOX_HIT_MARGIN_PERCENT = 0.02;

/**
 * Helper function that implements hit testing for words. A hit is used to
 * determine the users intent when their mouse is not on a specific word.
 *
 * @param words The list of words to check for a hit with
 * @param target The point normalized to image coordinates to check for a hit.
 * @return The best hit, null if no matches are found.
 */
export function bestHit(words: Word[], target: PointF): Word|null {
  if (words.length === 0) {
    return null;
  }

  return bestHorizontallyExtendedHit(words, target) ||
      bestVerticalHit(words, target);
}

// Finds the closes hit when extending alal word hit boxes horizontally.
function bestHorizontallyExtendedHit(words: Word[], target: PointF): Word|null {
  const hits: Word[] = [];
  for (const word of words) {
    const hit = horizontallyExtendedHit(word, target);
    if (hit) {
      hits.push(hit);
    }
  }

  return getBestHit(hits, target);
}

// Finds the best hit that is vertically close.
function bestVerticalHit(words: Word[], target: PointF): Word|null {
  const closestProjectedDistances: Word[] =
      getShortedProjectedDistanceHits(words, target);

  return getBestHit(closestProjectedDistances, target);
}

function horizontallyExtendedHit(word: Word, target: PointF): Word|null {
  const position = rotateCoordinate(word, target);
  const boundingBox = boundingBoxWithMargin(word);

  if (boxContainsTarget(boundingBox, position)) {
    return word;
  }

  if (isInExtension(position, boundingBox, word.writingDirection)) {
    return word;
  }

  return null;
}

// Finds the hit closest the the target.
function getBestHit(hits: Word[], target: PointF): Word|null {
  let closestHit = null;
  let closestDistance = Number.MAX_SAFE_INTEGER;

  for (const hit of hits) {
    const wordBoundingBox = hit.geometry?.boundingBox.box;
    if (!wordBoundingBox) {
      continue;
    }

    const distX = target.x - wordBoundingBox.x;
    const distY = target.y - wordBoundingBox.y;
    const totalDistance = distX * distX + distY * distY;
    if (totalDistance < closestDistance) {
      closestHit = hit;
      closestDistance = totalDistance;
    }
  }

  return closestHit;
}

// Rotates the target coordinates to be in relation to the word rotation.
function rotateCoordinate(word: Word, target: PointF): PointF {
  const wordBoundingBox = word.geometry?.boundingBox;
  if (!wordBoundingBox) {
    return {x: 0, y: 0};
  }
  // Translate to origin 0,0.
  const translatedX = target.x - wordBoundingBox.box.x;
  const translatedY = target.y - wordBoundingBox.box.y;

  // Rotate clockwise around origin.
  const rotatedX = translatedX * Math.cos(wordBoundingBox.rotation) +
      translatedY * Math.sin(wordBoundingBox.rotation);
  const rotatedY = -translatedX * Math.sin(wordBoundingBox.rotation) +
      translatedY * Math.cos(wordBoundingBox.rotation);

  // Translate the coordinate back.
  const finalX = rotatedX + wordBoundingBox.box.x;
  const finalY = rotatedY + wordBoundingBox.box.y;

  return {x: finalX, y: finalY};
}

// Creates a bounding box for the word, with additional BOUNDING_BOX_HIT_MARGIN.
function boundingBoxWithMargin(word: Word): RectF {
  const wordBoundingBox = word.geometry?.boundingBox.box;
  if (!wordBoundingBox) {
    return {x: 0, y: 0, width: 0, height: 0};
  }

  return {
    x: wordBoundingBox.x,
    y: wordBoundingBox.y,
    width: wordBoundingBox.width + BOUNDING_BOX_HIT_MARGIN_PERCENT,
    height: wordBoundingBox.height + BOUNDING_BOX_HIT_MARGIN_PERCENT,
  };
}

/** @return True if target is within the box. */
function boxContainsTarget(box: RectF, target: PointF): boolean {
  const boxMinX = box.x - (box.width / 2);
  const boxMaxX = box.x + (box.width / 2);
  const boxMinY = box.y - (box.height / 2);
  const boxMaxY = box.y + (box.height / 2);

  return target.x >= boxMinX && target.x <= boxMaxX && target.y >= boxMinY &&
      target.y <= boxMaxY;
}

/**
 * Determines if the position is within the giving bounding box, if the bounding
 * box expanded horizontally infinetely.
 * @returns True if the position is within the horizontal expansion of the word.
 */
function isInExtension(
    position: PointF, boundingBox: RectF,
    writingDirection: WritingDirection|null): boolean {
  const boxLeft = boundingBox.x - (boundingBox.width / 2);
  const boxRight = boundingBox.x + (boundingBox.width / 2);
  const boxTop = boundingBox.y - (boundingBox.height / 2);
  const boxBottom = boundingBox.y + (boundingBox.height / 2);

  if (writingDirection === WritingDirection.kTopToBottom) {
    const isAboveBox = position.x > boxLeft && position.x < boxRight &&
        position.y <= boundingBox.y;
    const isBelowBox = position.x > boxLeft && position.x < boxRight &&
        position.y >= boundingBox.y;
    return isAboveBox || isBelowBox;
  }

  // If writingDirection is null, assume left to right.
  const isLeftOfBox = position.y > boxTop && position.y < boxBottom &&
      position.x <= boundingBox.x;
  const isRightOfBox = position.y > boxTop && position.y < boxBottom &&
      position.x >= boundingBox.x;
  return isLeftOfBox || isRightOfBox;
}

/**
 * Gets the hits with the shortest projected distance as defined by
 * getProjectedDistance().
 * @returns The list of hits with the shortest distance to the target.
 */
function getShortedProjectedDistanceHits(
    words: Word[], target: PointF): Word[] {
  let closestHits: Word[] = [];
  let closestProjectedDistance = Number.MAX_SAFE_INTEGER;

  for (const word of words) {
    const projectedDistance = getProjectedDistance(word, target);
    if (!projectedDistance) {
      continue;
    }

    if (Math.abs(closestProjectedDistance - projectedDistance) <=
        BOUNDING_BOX_HIT_MARGIN_PERCENT) {
      // This porjected distance is close enough within the hit margin, so add
      // to closest hits.
      closestHits.push(word);
    } else if (projectedDistance <= closestProjectedDistance) {
      // Sizable new closest projected distance, so reset closest hits.
      closestHits = [word];
      closestProjectedDistance = projectedDistance;
    }
  }
  return closestHits;
}


/**
 * Calculates the projected distance from the target to the word. The projected
 * distance is the "vertical" distance from the target to the top/bottom of the
 * word. For top to bottom reading languages, the projected distance is the
 * distance from the right/left of the word.
 * @returns The projected distance from the target to the word. Null if the word
 *          does not have a bounding box.
 */
function getProjectedDistance(word: Word, target: PointF): number|null {
  const rotatedTarget = rotateCoordinate(word, target);
  const boundingBox = word.geometry?.boundingBox.box;
  if (!boundingBox) {
    return null;
  }

  const deltaX = Math.max(
      Math.abs(boundingBox.x - rotatedTarget.x) - boundingBox.width / 2, 0);
  const deltaY = Math.max(
      Math.abs(boundingBox.y - rotatedTarget.y) - boundingBox.height / 2, 0);
  return word.writingDirection === WritingDirection.kTopToBottom ? deltaX :
                                                                   deltaY;
}