3

This seems like it should be pretty simple but I could not find any clear answers on it. Say I have a single circle and rectangle. If the circle is outside of the rectangle, it should maintain its current position. However, if it is inside the rectangle at all, it should be displaced minimally such that it is barely outside the rectangle.

I have created a full demo below that demonstrates my current work-in-progress. My initial idea was to clamp the circle to the closest edge, but that seemed to not be working properly. I think there might be a solution involving Separating Axis Theorem, but I'm not sure if that applies here or if it's overkill for this sort of thing.

let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");

function draw() {
  ctx.fillStyle = "#b2c7ef";
  ctx.fillRect(0, 0, 800, 800);
  
  ctx.fillStyle = "#fff";
  drawCircle(circlePos.x, circlePos.y, circleR);
  drawSquare(squarePos.x, squarePos.y, squareW, squareH);
}

function drawCircle(xCenter, yCenter, radius) {
  ctx.beginPath();
  ctx.arc(xCenter, yCenter, radius, 0, 2 * Math.PI);
  ctx.fill();
}

function drawSquare(x, y, w, h) {
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.stroke();
}

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function getCircleRectangleDisplacement(rX, rY, rW, rH, cX, cY, cR) {
  let nearestX = clamp(cX, rX, rX + rW);
  let nearestY = clamp(cY, rY, rY + rH);

  let newX = nearestX - cR / 2;
  let newY = nearestY - cR / 2;

  return { x: newX, y: newY };
}

function displace() {
  circlePos = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR);
  
  draw();
}

let circlePos = { x: 280, y: 70 };
let squarePos = { x: 240, y: 110 };

let circleR = 50;

let squareW = 100;
let squareH = 100;

draw();

setTimeout(displace, 500);
canvas { display: flex; margin: 0 auto; }
<canvas width="800" height="800"></canvas>

As you can see in the demo, after 500 milliseconds the circle jumps a bit in an attempt to displace itself properly, but it does not move to the correct location. Is there an algorithm to find the circle's new location that would require as little movement as possible to move it outside of the bounds of the rectangle?

Ryan Peschel
  • 11,087
  • 19
  • 74
  • 136
  • Are you assuming that the rectangle is large? That is, that it is possible to place the circle entirely inside the rectangle? – Beta Apr 23 '21 at 20:57
  • Hmm, not sure, I didn't think of that. Does that change things much? I _think_ that in my specific use-case all my rectangles are 16x16 tiles and the circles that need to be displaced will all be smaller than that, but I suppose it's possible I could have larger units in the future. – Ryan Peschel Apr 23 '21 at 21:03
  • I suggest you start with simpler problems. For example, given a circle and a line y=k, find the position of the circle nearest to its starting position that puts it tangent to the line and below it (y – Beta Apr 23 '21 at 21:18
  • There's not an existing algorithm for this problem? – Ryan Peschel Apr 23 '21 at 21:29
  • Here's the algorithm: draw a line normal to the edge through the center of the circle. Mark a point on the line a distance R inside the rectangle. Put the center of the circle there. If you care about an adjacent edge, to the same thing with that edge. If the diameter of the circle is greater than the width of the rectangle, use the point where the line crosses the midline of the rectangle instead. – Beta Apr 23 '21 at 23:56
  • @RyanPeschel believe you need to make use of the function that calculates the distance of a point (the center of a circle) to a line segment. See https://stackoverflow.com/a/6853926/7696162. – Trentium Apr 24 '21 at 01:06
  • Rocco's deleted answer is the simplest way to do this correctly. There's no escaping that you need to hit-test a rounded rectangle, and on success then choose a minimal correction from 8 cases. Maybe some code could be refactored into functions, but the only effect of that on the execution time would be to (slightly) increase it. – j_random_hacker Apr 24 '21 at 06:11
  • How can I see his deleted answer? – Ryan Peschel Apr 24 '21 at 07:16
  • I suggest commenting on one of his other answers, asking him to undelete it. (If he doesn't want to I suppose I could copy it into pastebin, but I'd much rather he knows and gets credit.) – j_random_hacker Apr 24 '21 at 07:56
  • @ryanpeschel I deleted it to leave momentanly unanswered the question in order to stimulate more answers. If you need I can resurrect It – Rocco Apr 24 '21 at 08:09

2 Answers2

1

Have a look here, core is in calc() function, it's Java, not JavaScript , but I think that you can easily translate it.

package test;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;

import javax.swing.JComponent;
import javax.swing.JFrame;

public class CircleOutside extends JComponent {
    protected Rectangle2D rect;
    protected Point2D originalCenter;
    protected double radius;
    protected Point2D movedCenter;
    
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);

        Graphics2D g2=(Graphics2D) g;
        g2.draw(rect);
        g.setColor(Color.red);
        g2.draw(new Ellipse2D.Double(originalCenter.getX()-radius, originalCenter.getY()-radius, 2*radius, 2*radius));
        g.setColor(Color.green);
        g2.draw(new Ellipse2D.Double(movedCenter.getX()-radius, movedCenter.getY()-radius, 2*radius, 2*radius));
        
        addMouseListener(new MouseAdapter() {
            @Override
            public void mouseClicked(MouseEvent e) {
                originalCenter=e.getPoint();
                calc();
                repaint();
            }
        });
    }
    
    public void calc() {
        movedCenter=originalCenter;
        
        //Circle center distance from edges greater than radius, do not move 
        if (originalCenter.getY()+radius<=rect.getY()) {
            return;
        }
        if (originalCenter.getY()-radius>=rect.getY()+rect.getHeight()) {
            return;
        }
        if (originalCenter.getX()+radius<=rect.getX()) {
            return;
        }
        if (originalCenter.getX()-radius>=rect.getX()+rect.getWidth()) {
            return;
        }

        double moveX=0;
        double moveY=0;
        boolean movingY=false;
        boolean movingX=false;

        //Center projects into rectangle's width, move up or down
        if (originalCenter.getX()>=rect.getX()&&originalCenter.getX()<=rect.getX()+rect.getWidth()) {
            System.out.println("X in width");
            double moveUp=rect.getY()-originalCenter.getY()-radius;
            double moveDown=rect.getY()+rect.getHeight()-originalCenter.getY()+radius;
            if (Math.abs(moveUp)<=Math.abs(moveDown)) {
                moveY=moveUp;
            } else {
                moveY=moveDown;
            }
            System.out.println("UP "+moveUp+" DOWN "+moveDown);
            movingY=true;
        }

        //Center projects into rectangle's height, move left or right
        if (originalCenter.getY()>=rect.getY()&&originalCenter.getY()<=rect.getY()+rect.getHeight()) {
            double moveLeft=rect.getX()-originalCenter.getX()-radius;
            double moveRight=rect.getX()+rect.getWidth()-originalCenter.getX()+radius;
            if (Math.abs(moveLeft)<=Math.abs(moveRight)) {
                moveX=moveLeft;
            } else {
                moveX=moveRight;
            }
            movingX=true;
        }
            
        //If circle can be moved both on X or Y, choose the lower distance
        if (movingX&&movingY) {
            if (Math.abs(moveY)<Math.abs(moveX)) {
                moveX=0;
            } else {
                moveY=0;
            }
        }

        //Note that the following cases are mutually excluding with the previous ones
        
        //Center is in the arc [90-180] centered in upper left corner with same radius as circle, calculate distance from corner and adjust both axis 
        if (originalCenter.getX()<rect.getX()&&originalCenter.getY()<rect.getY()) {
            double dist=originalCenter.distance(rect.getX(),rect.getY());
            if (dist<radius) {
                double factor=(radius-dist)/dist;
                moveX=factor*(originalCenter.getX()-rect.getX());
                moveY=factor*(originalCenter.getY()-rect.getY());
            }
        }

        //Center is in the arc [0-90] centered in upper right corner with same radius as circle, calculate distance from corner and adjust both axis 
        if (originalCenter.getX()>rect.getX()+rect.getWidth()&&originalCenter.getY()<rect.getY()) {
            double dist=originalCenter.distance(rect.getX()+rect.getWidth(),rect.getY());
            if (dist<radius) {
                double factor=(radius-dist)/dist;
                moveX=factor*(originalCenter.getX()-rect.getX()-rect.getWidth());
                moveY=factor*(originalCenter.getY()-rect.getY());
            }
        }

        //Center is in the arc [270-360] centered in lower right corner with same radius as circle, calculate distance from corner and adjust both axis 
        if (originalCenter.getX()>rect.getX()+rect.getWidth()&&originalCenter.getY()>rect.getY()+rect.getHeight()) {
            double dist=originalCenter.distance(rect.getX()+rect.getWidth(),rect.getY()+rect.getHeight());
            if (dist<radius) {
                double factor=(radius-dist)/dist;
                moveX=factor*(originalCenter.getX()-rect.getX()-rect.getWidth());
                moveY=factor*(originalCenter.getY()-rect.getY()-rect.getHeight());
            }
        }

        //Center is in the arc [180-270] centered in lower left corner with same radius as circle, calculate distance from corner and adjust both axis 
        if (originalCenter.getX()<rect.getX()&&originalCenter.getY()>rect.getY()+rect.getHeight()) {
            double dist=originalCenter.distance(rect.getX(),rect.getY()+rect.getHeight());
            if (dist<radius) {
                double factor=(radius-dist)/dist;
                moveX=factor*(originalCenter.getX()-rect.getX());
                moveY=factor*(originalCenter.getY()-rect.getY()-rect.getHeight());
            }
        }

        movedCenter=new Point2D.Double(originalCenter.getX()+moveX,originalCenter.getY()+moveY);
    }
    
    
    public static void main(String[] args) {
        Rectangle2D rect=new Rectangle2D.Double(240, 110, 100, 100);
        Point2D center=new Point2D.Double(280, 70);
        double radius=50;
        
        CircleOutside o=new CircleOutside();
        o.rect=rect;
        o.originalCenter=center;
        o.radius=radius;
        o.calc();
        o.setPreferredSize(new Dimension(800,600));
        JFrame frame=new JFrame("Test circle");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        
        frame.setContentPane(o);
        frame.pack();
        frame.setVisible(true);
    }
}
Rocco
  • 1,098
  • 5
  • 11
  • 1
    I don't mean any offense but is there truly no better way to accomplish this than a 100-line function? This code will be called quite often in my project so it'll have to be pretty efficient and ideally quite terse – Ryan Peschel Apr 23 '21 at 21:48
  • 1
    @RyanPeschel No offense, and of course this just a first (working) draft, probably the code can be somewhat reduce but, you have to distinguish several cases. What the code is doing is checking if the center of the circle is inside a rounded-corner shape where each point has distance equal to radius from the original rectangle. If this is the case then calculate the minimum distance vector from the rectangle and adjust it to have radius length. Don't think that it can be reduced too much without using a library that manages arbitrary shapes. – Rocco Apr 23 '21 at 21:56
0

Made the following modifications to the code:

  • Added function pointToSegmentDistance which calculates both the distance of a point to a line segment in addition to the corresponding perpendicular point on the line segment.

  • Added function pointInPolygon which determines whether a point resides inside or outside of a polygon.

  • Modified function getCircleRectangleDisplacement to perform the following:

    • Create a bounding polygon that extends the edges of the rectangle by the length of the radius. Then, if the circle center resides inside this bounding polygon, it needs to be moved to one of the four (4) extended edges. Function pointInPolygon determines whether the circle center is in the bounding polygon, and if so, then pointToSegmentDistance is used to find the closest point on one of the four (4) extended edges, a point which now represents the new circle center.
    • Otherwise, if the circle center is outside the bounding polygon, then the function checks if the circle center is less than the length of the radius to one of the four vertices, and if so, moves the circle center away from the vertex such that the distance is now the radius.

<html><head>

<style>
canvas { display: flex; margin: 0 auto; }
</style>

</head><body>

<canvas width="800" height="800"></canvas>


<script>

let canvas = document.querySelector("canvas");
let ctx = canvas.getContext("2d");

function draw() {
  ctx.fillStyle = "#fff";
  drawCircle(circlePos.x, circlePos.y, circleR);
  drawSquare(squarePos.x, squarePos.y, squareW, squareH);
}

function drawCircle(xCenter, yCenter, radius) {
  ctx.fillStyle = "#fff";
  ctx.beginPath();
  ctx.arc(xCenter, yCenter, radius, 0, 2 * Math.PI);
  ctx.fill();
}

function drawSquare(x, y, w, h) {
  ctx.fillStyle = "#f0f";
  ctx.beginPath();
  ctx.rect(x, y, w, h);
  ctx.fill();
}

// Sourced and adapted from https://stackoverflow.com/a/6853926/7696162
function pointToSegmentDistance(point, segBeg, segEnd) {

  var A = point.x - segBeg.x;
  var B = point.y - segBeg.y;
  var C = segEnd.x - segBeg.x;
  var D = segEnd.y - segBeg.y;

  var dot = A * C + B * D;
  var len_sq = C * C + D * D;
  var param = -1;
  if (len_sq != 0) //in case of 0 length line
      param = dot / len_sq;

 let intersectPoint;

  if (param < 0) {
    intersectPoint = segBeg;
  }
  else if (param > 1) {
    intersectPoint = segEnd;
  }
  else {
    intersectPoint = { x: segBeg.x + param * C, y:segBeg.y + param * D };
  }

  var dx = point.x - intersectPoint.x;
  var dy = point.y - intersectPoint.y;
  return { intersect: intersectPoint, distance: Math.sqrt(dx * dx + dy * dy) };
}

// Sourced and adapted from https://www.algorithms-and-technologies.com/point_in_polygon/javascript
function pointInPolygon( point, polygon ) {
  let vertices = polygon.vertex;
  //A point is in a polygon if a line from the point to infinity crosses the polygon an odd number of times
  let odd = false;
  //For each edge (In this case for each point of the polygon and the previous one)
  for (let i = 0, j = polygon.length - 1; i < polygon.length; i++) {
    //If a line from the point into infinity crosses this edge
    if (((polygon[i].y > point.y) !== (polygon[j].y > point.y)) // One point needs to be above, one below our y coordinate
      // ...and the edge doesn't cross our Y corrdinate before our x coordinate (but between our x coordinate and infinity)
      && (point.x < ((polygon[j].x - polygon[i].x) * (point.y - polygon[i].y) / (polygon[j].y - polygon[i].y) + polygon[i].x))) {
      // Invert odd
      odd = !odd;
    }
    j = i;
  }
  //If the number of crossings was odd, the point is in the polygon
  return odd;
}

function getCircleRectangleDisplacement( rX, rY, rW, rH, cX, cY, cR ) {

  let rect = [
    { x: rX, y:rY },
    { x: rX + rW, y:rY },
    { x: rX + rW, y:rY + rH },
    { x: rX, y:rY + rH }
  ];

  let boundingPolygon = [
    { x: rX, y: rY },
    { x: rX, y: rY - cR },
    { x: rX + rW, y: rY - cR },
    { x: rX + rW, y: rY },
    { x: rX + rW + cR, y: rY },
    { x: rX + rW + cR, y: rY + rH },
    { x: rX + rW, y: rY + rH },
    { x: rX + rW, y: rY + rH + cR },
    { x: rX, y: rY + rH + cR },
    { x: rX, y: rY + rH },
    { x: rX - cR, y: rY + rH },
    { x: rX - cR, y: rY }
  ];
  
  // Draw boundingPolygon... This can be removed...
  ctx.setLineDash([2,2]);ctx.beginPath();ctx.moveTo(boundingPolygon[0].x,boundingPolygon[0].y);for (let p of boundingPolygon) {ctx.lineTo(p.x,p.y);} ctx.lineTo(boundingPolygon[0].x,boundingPolygon[0].y);ctx.stroke();
  
  circleCenter = { x: cX, y: cY };
  // If the circle center is inside the bounding polygon...
  if ( pointInPolygon( circleCenter, boundingPolygon ) ) {
    let newCircleCenter;
    let minDistance = Number.MAX_VALUE;
    // ...then loop through the 4 segments of the bounding polygon that are
    // extensions of the original rectangle, looking for the point that is
    // closest to the circle center.
    for ( let i = 1; i < boundingPolygon.length; i += 3 ) {
      let pts = pointToSegmentDistance( circleCenter, boundingPolygon[ i ], boundingPolygon[ i + 1 ] );
      if ( pts.distance < minDistance ) {
          newCircleCenter = pts.intersect;
          minDistance = pts.distance;
      }
    }
    circleCenter = newCircleCenter;
  } else {
    // ...otherwise, if the circle center is outside the bounding polygon,
    // let's check to see if the circle center is closer than the radius
    // to one of the corners of the rectangle.
    let newCircleCenter;
    let minDistance = Number.MAX_VALUE;
    for ( let i = 0; i < boundingPolygon.length; i += 3 ) {
      let d = Math.sqrt( ( circleCenter.x - boundingPolygon[ i ].x ) ** 2 + ( circleCenter.y - boundingPolygon[ i ].y ) ** 2 );
      if ( d < cR && d < minDistance ) {
        // Okay, the circle is too close to a corner.  Let's move it away...
        newCircleCenter = {
          x: boundingPolygon[ i ].x  + ( circleCenter.x - boundingPolygon[ i ].x ) * cR / d,
          y: boundingPolygon[ i ].y  + ( circleCenter.y - boundingPolygon[ i ].y ) * cR / d
        }
        minDistance = d;
      }
    }
    if ( newCircleCenter ) {
      circleCenter = newCircleCenter;
    }
  }
  return circleCenter;
}

function displace() {

  ctx.fillStyle = "#b2c7ef";
  ctx.fillRect(0, 0, 800, 800);
  
  circlePos.x += 1;
  circlePos.y += 1;
  circlePos = getCircleRectangleDisplacement(squarePos.x, squarePos.y, squareW, squareH, circlePos.x, circlePos.y, circleR);
  
  draw();
  
  if ( maxIterations < iterations++ ) {
    clearInterval( timer );
  }
}

let circlePos = { x: 280, y: 40 };
circlePos={ x: 240, y: 110 };
let squarePos = { x: 240, y: 110 };

let circleR = 50;

let squareW = 100;
let squareH = 100;

let iterations = 0;
let maxIterations = 200;
let timer = setInterval(displace, 50);

</script>


</body></html>

I believe this algorithm can be extended to simple polygons (ie, convex polygons, not concave polygons) although with a bit more trigonometry and/or matrix math...

Trentium
  • 3,419
  • 2
  • 12
  • 19