Рваная линия (Jagged Line)

Зазубренные или рваные линии (Jagged lines) подпадают под определение квазифракталов сразу в нескольких контекстах:

Природные объекты. Береговые линии, горные хребты, траектории разрядов молнии или трещины во льду выглядят как классические jagged lines. Они обладают свойством самоподобия: если посмотреть на участок береговой линии через лупу, он будет так же изрезан, как и весь залив издалека. Однако при приближении к молекулярному уровню эта структура обрывается. Поэтому Бенуа Мандельброт изначально использовал их как главный пример природных квазифракталов.

Цифровая графика (aliasing). Ступенчатые края («лесенка») на экране компьютера — это законченная геометрическая структура. Она состоит из отрезков, которые визуально имитируют наклонную прямую. Если бы разрешение экрана было бесконечным, эти ступеньки превратились бы в гладкую евклидову линию. Но на доступных нам масштабах пикселей они ведут себя как усеченный фрактальный алгоритм.

Прикладная математика. Как упоминается в научных работах, пути объектов, движущихся в ограниченном пространстве (например, шары на бильярдном столе), могут описываться линиями, обладающими сложной заостренной структурой, которую математики классифицируют как одномерные фракталы или квазифракталы для решения задач квантового хаоса.

Таким образом, любая реальная зазубренная линия является квазифракталом, поскольку она либо имеет предел детализации (атомы, пиксели), либо ее самоподобие сохраняется только в узком интервале масштабов.

Рассмотрим задачу: нужно провести из точки (x1, y1) в точку (x2, y2) случайную рваную линию, состоящую из шагов длины 1.

Для начала будем считать, что x1 < x2 и y1 < y2.

Простой вариант

Самая простая идея — хранить текущую точку (x, y) и на каждом шаге случайно увеличивать либо x, либо y на единицу.

Вероятность выбора направления сделаем пропорциональной оставшемуся расстоянию по соответствующей координате. Если по X осталось пройти больше, то и шаг по X должен выпадать чаще.

function drawJaggedLineSimple(x1, y1, x2, y2) {
    ctx.moveTo(x1, y1);
 
    let x = x1, y = y1;
    while (x < x2 || y < y2) {
        let dx = Math.max(0, x2 - x), dy = Math.max(0, y2 - y);
 
        if (Math.random() < dx / (dx + dy)) x += 1;
        else y += 1;
 
        ctx.lineTo(x, y);
    }
 
    ctx.lineTo(x2, y2); // чтобы перо canvas точно было там, где ожидается
}

Например, если по оси X осталось пройти значительно больше, чем по оси Y, то шаг вправо будет выбираться чаще.

Ограничения x1 < x2 и y1 < y2 сами по себе не являются серьёзной проблемой: поддержку остальных направлений легко добавить несколькими условиями.

Но у такого подхода есть более важный недостаток.

Проблема вырожденного прямоугольника

Фактически мы строим случайный путь внутри прямоугольника между начальной и конечной точками.

Если прямоугольник становится очень узким, случайности становится всё меньше. В крайнем случае, когда y1 = y2, координата Y вообще не меняется, поэтому наша рваная линия превращается в обычную прямую.

Исправлять отдельно горизонтальные, вертикальные и остальные частные случаи не очень удобно. Лучше сделать так, чтобы сам алгоритм вообще не зависел от направления исходной линии.

Строим линию в квадрате

Для этого сначала будем генерировать рваную линию в собственной удобной системе координат. Пусть расстояние между исходными точками равно:

$$r = \sqrt{(x2-x1)^2 + (y2-y1)^2}$$

Возьмём квадрат, диагональ которого имеет длину r. Если сторона квадрата равна d, то:

$$d\sqrt{2}=r$$

откуда:

$$d=\frac{r}{\sqrt{2}}$$

Теперь всегда строим случайный путь от (0, 0) к (d, d):

function getJaggedPoints(r) {
    let x = 0, y = 0, d = r / Math.sqrt(2);
    let ans = [];
 
    while (x < d || y < d) {
        let dx = Math.max(0, d - x), dy = Math.max(0, d - y);
 
        if (Math.random() < dx / (dx + dy)) x += 1;
        else y += 1;
 
        ans.push([x, y]);
    }
 
    return ans;
}

То есть теперь алгоритм всегда работает с квадратом. Направление исходного отрезка на генерацию случайной линии больше не влияет.

Функцию рисования пока можно записать так:

function drawJaggedLine(x1, y1, x2, y2) {
    let dx = x2 - x1, dy = y2 - y1;
    let r = Math.sqrt(dx * dx + dy * dy);
 
    function lineTo(x, y) {
        ctx.lineTo(x1 + x, y1 + y);
    }
 
    ctx.moveTo(x1, y1);
 
    for (let [x, y] of getJaggedPoints(r))
        lineTo(x, y);
 
    ctx.lineTo(x2, y2); // чтобы перо canvas точно было там, где ожидается
}

Пока есть одна проблема: полученная линия всегда направлена по диагонали квадрата, то есть под углом 45°.

Поворот

Но теперь исправить это очень просто: достаточно повернуть все полученные точки на нужный угол.

Направление от исходной точки к конечной можно получить через:

Math.atan2(dy, dx)

При этом наша сгенерированная линия изначально уже имеет угол 45° = π / 4. Поэтому повернуть её нужно на угол:

$$\alpha = atan2(dy, dx) - \frac{\pi}{4}$$

Для поворота точки (x, y) используется стандартное линейное преобразование:

$$x' = x\cos\alpha-y\sin\alpha$$
$$y' = x\sin\alpha+y\cos\alpha$$

После поворота остаётся только перенести точку из начала координат в (x1, y1).

function drawJaggedLine(x1, y1, x2, y2) {
    let dx = x2 - x1, dy = y2 - y1;
    let r = Math.sqrt(dx * dx + dy * dy);
 
    let a = Math.atan2(dy, dx) - Math.PI / 4;
 
    function lineTo(x, y) {
        ctx.lineTo(
            x1 + Math.cos(a) * x - Math.sin(a) * y,
            y1 + Math.sin(a) * x + Math.cos(a) * y
        );
    }
 
    ctx.moveTo(x1, y1);
 
    for (let [x, y] of getJaggedPoints(r))
        lineTo(x, y);
 
    ctx.lineTo(x2, y2); // чтобы перо canvas точно было там, где ожидается
}

Теперь одна и та же случайная линия сначала строится в удобном квадрате, а затем поворачивается в нужном направлении. На картинке для сравнения показаны оба алгоритма: "без поворота" - простой самый первый код, "с поворотом" - применение идеи с построением в квадрате

Итог

Основная идея получившегося решения: сначала решить задачу в удобной системе координат, а затем с помощью линейного преобразования перенести решение в исходную задачу.

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

Теперь направление не имеет значения - форма линии всегда одного типа.

Применять рваную линию можно при рисовании трещин, разрыва бумаги, береговой линии (см. пример).

Направление: