Введение

Уже не раз на habr затрагивалась данная тема: раз, два.

Я лишь хочу поделиться своей реализацией. Думаю, это кому-то пригодится.

Предупреждаю, данный метод не учитывает некоторые пограничные случаи и вхождение отрезков друг в друга.

Подготовка

Весь последующий код будет приведён на языке Java

Для начала создадим вспомогательный класс Dot. В нём всего две переменные x и y:

static class Dot {
        float x,y;
        Dot(float x, float y){
            this.x =x;
            this.y = y;
        }
  			
  			@Override
        public String toString() {
            return "x:"+x+" y:"+y;
        }
    }

И класс Vector2. В нём всего две функции: crs (cross) - векторное произведение, scl (scale) - масштабирование вектора.

public static class Vector2 {
        float x,y;

        public Vector2(Dot d1, Dot d2) {
            this.x = d2.x-d1.x;
            this.y = d2.y-d1.y;
        }

        /** Calculates the 2D cross product between this and the given vector.
         * @param v2 the other vector
         * @return the cross product (Z vector) */
        public static float crs(Vector2 v1, Vector2 v2) {
            return v1.x * v2.y - v1.y * v2.x;
        }

        /** Multiplies this vector by a scalar */
        public Vector2 scl (float scalar) {
            x *= scalar;
            y *= scalar;
            return this;
        }
}

Задача 1: определить, касаются ли отрезки.

Решение:
Из векторного произведения мы можем узнать, обхо́дим мы вектор по часовой или против часовой. Это поможет понять где находятся точки. Соединим точки A и D, A и C.

Перемножим вектора main и v2, main и v1. Если полученные произведения имеют разные знаки, значит точки C и D находятся по разные стороны относительно отрезка AB. Назовём такой метод fromDifferentSides:

public static boolean fromDifferentSides(Vector2 main, Vector2 v1, Vector2 v2){
    float product1 =crs(main ,v1), product2 = crs(main ,v2);
    return (product1>=0&&product2<=0 || product1<=0&&product2>=0);
}

Таким же образом необходимо проверить точки A и B относительно отрезка CD. Соединим это в один метод и получится следующее:

public static boolean linesIntersect(Dot a, Dot b, Dot c, Dot d) {
    Vector2 main = new Vector2(a,b);
    Vector2 v1 = new Vector2(a,c);
    Vector2 v2 = new Vector2(a,d);

    if (fromDifferentSides(main,v1,v2)) {
      main = new Vector2(c,d);
      v1 =new Vector2(c,a);
      v2 =new Vector2(c,b);
      return fromDifferentSides(main,v1,v2);
    }
    return false;
}

Задача 2: определить точку касания.

Решение:
Как и в предыдущей задаче определяем, касаются ли отрезки. Если касаются, начинаем определять эту точку. Через подобие найдём длину DO. Коэффициент подобия (k) равен отношению уже известных нам векторных произведений:

//коэффициент подобия
double k = Math.abs(product2 / product1);
if (Float.isInfinite((float) k)) return c;

В третей строчке проверяем пограничный случай. Теперь узнаем чему равно DO. Для этого решим систему уравнений:

\begin{cases}  & \text{ DO/CO = k }  \\   & \text{ DO+CO = CD}   \end{cases}

DO = CO*k
Подставляем во второе уравнение:
CO*k+CO = CD
CO(k+1)= CD
CO = CD/(k+1)
В итоге:
DO = (CD/(k+1))*k

Теперь создаём вектор CD и уменьшаем его до длинны DO. Но поскольку мы будем его умножать, нужно взять обратное от (k+1)*k:

//вектор DC
Vector2 dc = new Vector2(d, c);

//уменьшаем вектор
dc.scl((float) (1 / (k + 1)*k));

Теперь осталось добавить вектор к точке D:

//добавляем вектор к точке
return new Dot(d.x + dc.x, d.y + dc.y);

Вот и всё! Мы получили заветную точку.

Предупреждаю, данный метод не учитывает некоторые пограничные случаи и вхождение отрезков друг в друга.

Функцию нахождения точки я назвал pointOfIntersection. Привожу полный код на Java:


public class Main {
    public static void main(String[] args) {
        System.out.println(pointOfIntersection(new Dot(0, 0),new Dot(5, 0), new Dot(2, 3),new Dot(2, 0) ));
    }

    public static Dot pointOfIntersection(Dot a, Dot b, Dot c, Dot d) {

        Vector2 main = new Vector2(c,d);
        Vector2 v1= new Vector2(c,a);
        Vector2 v2 = new Vector2(c,b);
        if (fromDifferentSides(main,v1,v2)) {
            main = new Vector2(a,b);
            v1 = new Vector2(a,c);
            v2 = new Vector2(a,d);

            double product1 = crs(main ,v1), product2 = crs(main ,v2);
            if (product1>=0&&product2<=0 || product1<=0&&product2>=0) {
                //коэффициент подобия
                double k = Math.abs(product2 / product1);
                if (Float.isInfinite((float) k)) return c;

                //вектор DC
                Vector2 dc = new Vector2(d, c);

                //уменьшаем вектор
                dc.scl((float) (1 / (k + 1)*k));

                //добавляем вектор к точке
                return new Dot(d.x + dc.x, d.y + dc.y);
            }

        }

        return null;
    }

    public static boolean linesIntersect(Dot a, Dot b, Dot c, Dot d) {
        Vector2 main = new Vector2(a,b);
        Vector2 v1 = new Vector2(a,c);
        Vector2 v2 = new Vector2(a,d);

        if (fromDifferentSides(main,v1,v2)) {
            main = new Vector2(c,d);
            v1 =new Vector2(c,a);
            v2 =new Vector2(c,b);
            return fromDifferentSides(main,v1,v2);
        }
        return false;
    }

    static class Dot {
        float x,y;
        Dot(float x, float y){
            this.x =x;
            this.y = y;
        }

        @Override
        public String toString() {
            return "x:"+x+" y:"+y;
        }
    }


    public static class Vector2 {
        float x,y;

        public Vector2(Dot d1, Dot d2) {
            this.x = d2.x-d1.x;
            this.y = d2.y-d1.y;
        }

        /** Calculates the 2D cross product between this and the given vector.
         * @param v2 the other vector
         * @return the cross product (Z vector) */
        public static float crs(Vector2 v1, Vector2 v2) {
            return v1.x * v2.y - v1.y * v2.x;
        }

        /** Multiplies this vector by a scalar */
        public Vector2 scl (float scalar) {
            x *= scalar;
            y *= scalar;
            return this;
        }

        public static boolean fromDifferentSides(Vector2 main, Vector2 v1, Vector2 v2){
            float product1 =crs(main ,v1), product2 = crs(main ,v2);
            return (product1>=0&&product2<=0 || product1<=0&&product2>=0);
        }
				
      //функция округления
        public static double round(double value, int places) {
            if (places < 0) throw new IllegalArgumentException();
            BigDecimal bd = new BigDecimal(Double.toString(value));
            bd = bd.setScale(places, RoundingMode.HALF_UP);
            return bd.doubleValue();
        }
    }
}

Надеюсь, вам понравилось. Удачи!

Комментарии (8)


  1. mvv
    19.09.2021 02:29
    +4

    На фоне новостей о Google, Apple и "беспринципном негодяе" Дурове - блестящая статья!


  1. KaneUA
    19.09.2021 04:05
    +3

    Предупреждаю, данный метод не учитывает некоторые пограничные случаи

    Какие?

    и вхождение отрезков друг в друга.

    Почему?

    И почему часть комментариев на русском, а часть на английском?


  1. technic93
    19.09.2021 05:05
    +2

    Задача о нахождении отрезков не такая простая как кажется на первый взгляд, легко пропустить какой-то крайний случай, так что советую покрыть ваше решение тестами. К оформлению решения есть ряд замечаний. Код оформлен небрежно, в одном месте есть пробел перед = в другом нет, пробелы иногда перед запятой а не после (!) - надо бы использовать какой-то линтер. Названия методов crs и scl слишком короткие. Вместо названия fromDifferentSides по смыслу больше подходит atDifferentSides. Последнюю простыню кода можно убрать под спойлер. По окончанию статьи так и не понятно какой мне метод брать, ваш или какой-то из двух других по ссылкам, или в комментариях по ссылкам? :) В любом случае на фоне засилия гугло-переводов на хабре, всегда приятно видеть когда человек пытается разобраться сам.


    1. Tiriet
      19.09.2021 09:12
      +14

      Кмк, на фоне качества выбранного алгоритма недостатки оформления теряются, как муха на фоне слона.

      Ведь есть же гораздо более простой и эффективный метод: точка пересечения одновременно удовлетворяет уравнениям двух прямых

      Line1: C + alfa1*(D-C)

      Line2: A + alfa2*(B-A)

      alfa1*(D.x-C.x) - alfa2*(B.x-A.x) = A.x-C.x

      alfa1*(D.y-C.y) - alfa2*(B.y-A.y) = A.y-C.y

      И все, что требуется для нахождения пересечения двух отрезков- это убедиться, что эта система имеет решение и хотя-бы одно из этих решений удовлетворяет условия alfa1, alfa2 in [0.0 .. 1.0 ]

      Решение единственное, если (D.x-C.x)*(B.y-A.y)-(D.y-C.y)*(B.x-A.x) <> 0. надо проверить, что alfa1,alfa2 удовлетворяют требованиям 0.0<=a<=1.0.

      Отрезки коллинеарные, если Det = (D.x-C.x)*(B.y-A.y)-(D.y-C.y)*(B.x-A.x) = 0, надо проверить, что один отрезок лежит целиком левее или целиком правее другого. если да- то пересечения нет, если нет- то отрезки перекрываются и пересечений много.

      Такое решение реализуется в виде простой функции, не требует создания кучи объектов, не грузит гарбажколлектор, и укладывается в 20 строк кода. А тут - куски решения раскиданы по пяти методам, каждый из которых делает непойми что и непойми зачем, а на выходе еще и "пограничные случаи". (Пограничные случаи- это когда Abs(Det) < eps=1,0e-12*(L2_norm(A-B)+L2_norm(C-D)) - но пересечение отрезков на float-числах- при малых eps- это несколько лотерея или тема отдельной большой статьи).


      1. Tiriet
        19.09.2021 09:25

        https://habr.com/ru/post/523440/#comment_22182564

        чуть меньше года назад размещено на хабре.


  1. GeMir
    19.09.2021 09:41

    Раз уж скриншоты из GeoGebra, а она open source, не стоит ли для начала заглянуть «под капот» оной и посмотреть на имеющееся решение?


  1. DistortNeo
    19.09.2021 14:53
    +2

    Думаю, это пригодится разработчикам игр.

    Исходя из того, что


    Предупреждаю, данный метод не учитывает некоторые пограничные случаи и вхождение отрезков друг в друга.

    Крайне сомневаюсь в том что это будет иметь практическую ценность.
    Ведь именно корректная обработка пограничных случаев и представляет основной интерес.


  1. RigelGL
    19.09.2021 18:04

    Приятно видеть попытки разобраться в линейной алгебре на плоскости, это поможет не только в разработке игр, но и при создании разметки изображений.

    А теперь расскажу о простом для понимания, но несколько дорогом для cpu (задействуется извлечение корня) алгоритме о пересечении отрезков. Поехали.

    Первым делом зададим точки A, B, C, D на двумерной плоскости, А и В задают первый отрезок, C и D - второй.

    Основная идея - привести систему к вырожденному случаю, когда точка А' имеет координаты (0, 0), а точка В' лежит на оси ОХ.

    Первым делом вычтем из точек В, С и D точку А, так мы достигнем первого условия. Теперь нужно повернуть точки С и D вокруг точки А (точку В поворачивать не нужно, её координаты будут (length(A, B), 0)). Для этого воспользуемся матрицей поворота, для неё нужны синус и косинус угла линии AB, благо их легко вычислить, зная длину AB и расстояние по оси ОХ и ОY между точками А и В.

    После умножения точек С и D на матрицу поворота мы получим С' и D' в локальных координатах отрезка АВ.

    Задача о пересечении становится тривиальной:

    1. Точки С' и D' с разных сторон оси ОХ, или одна из точек близка к отрезку А'В' на некоторую эпсилон.

    Теперь когда точки С' и D' с разных сторон оси ОХ мы можем найти точку пересечения С'D' и ОХ:

    1. Найдем расстояние по ОХ между С' и D' (cdLy)

    2. Найдём параметр alpha, поделив координату Y точки С' на расстояние по OY точек С' и D'.

    3. Найдём координату X точки М' (пересечение), помножив cdLy на alpha. Координата X должна оказаться больше или равна 0 и меньше или равна длине АВ, в противном случае отрезки не пересекаются.

    4. Вернём точку М' в мировое пространство, помножив её на инверсную матрицу поворота (в данном случае нужно всего лишь поменять знаки). По факту здесь можно умножить кординату X точки М' на синус и косинус угла линии АВ.

    5. После этого к точке М нужно прибавить координаты точки А.

    Весьма долгий путь, зато общий. Так можно найти расстояние от отрезка до точки (а заодно самую ближайшую точку на отрезке к заданной точке), то же можно провернуть с лучом или линией, приведя их к форме отрезка и убрав проверки на ось ОY и/или длину АВ.

    Подобный подход можно использовать для пересечения отрезка и треугольника в трёхмерном пространстве. В таком случае пусть треугольник будет АВС, а отрезок DE. Аналогично нужно из точек B, C, D и Е отнять точку А и помножить на матрицу треугольника АВС (тангент - сторона АВ, нормаль - векторное произведение АВ и АС, битангент - тангент х нормаль, получится матрица TBN. Все вектора T, B, N нужно нормализовать). После этого задача опять тривиальна: точка А' лежит в начале координат, точка В' на оси ОХ, а точка С' выше оси ОХ. Найти точку пересечения в этом случае тривиально, описывать не стану.