ГЛАВНАЯ RU typewriter

older-tomato

Вращаем пространственный крест

Объёмные фигуры • Матрица поворота • Экспериментальная модель 15.01.2023

Пишем алгоритм для поворота трёхмерной фигуры вокруг своего центра по всем трём осям сразу. В предыдущем примере мы вращали куб в пространстве — в этом примере кубиков будет много, алгоритм будет почти такой же, и формулы будем использовать те же. Для наглядности возьмём два варианта симметричной объёмной фигуры в двух типах проекций — пространственный крест и крест-куб — рассматриваем разницу между ними.

Тестирование экспериментального интерфейса: Объёмный тетрис.

Пространственный крест #

Параллельная проекция

Холст для отображения результатов вычислений

Перспективная проекция

Холст для отображения результатов вычислений

Крест-куб #

Параллельная проекция

Холст для отображения результатов вычислений

Перспективная проекция

Холст для отображения результатов вычислений

Параллельная проекция — все кубики одинакового размера.

Перспективная проекция — кубики выглядят уменьшающимися вдалеке.

Экспериментальная модель #

Слегка усложнённая версия из предыдущего примера — теперь кубиков много. В дополнение к предыдущим настройкам можно поменять: вариант фигуры — пространственный крест или крест-куб, направление сортировки граней — прямая перспектива или обратная перспектива и прозрачность стенок кубиков.

Холст для отображения результатов вычислений

Вращение по осям:
Центр на экране наблюдателя:
150
150
125
Удалённость центра проекции:
300
Прозрачность кубиков:
20%
Вариант фигуры:
Линейная перспектива:

Описание алгоритма #

Подготавливаем трёхмерную матрицу из нулей и единиц, где единица означает кубик в определенном месте фигуры. Затем обходим эту матрицу и заполняем массив кубиков с соответствующими координатами вершин. После этого запускаем вращение по всем трём осям сразу. На каждом шаге обходим массив кубиков и получаем проекции их граней. Затем сортируем массив граней по удалённости от центра проекции, обходим этот массив и выкидываем из него одинаковые пары — это есть смежные стенки между соседними кубиками внутри фигуры. После этого рисуем полупрозрачным цветом грани кубиков — сначала дальние, а затем ближние, чтобы через ближние грани было видно дальние.

Реализация на JavaScript #

Класс Точка трёхмерного пространства содержит методы для поворотов на угол и для получения проекций на плоскость. При получении проекций, вычисляется расстояние от точки до центра проекции. Точка также содержит статический метод для сравнения двух проекций точек.

class Point
class Point {
  // координаты точки
  constructor(x,y,z) {
    this.x=x;
    this.y=y;
    this.z=z;
  }
  // поворачиваем эту точку на угол (deg)
  // по осям (x,y,z) относительно точки (t0)
  rotate(deg, t0) {
    // функции для получения синуса и косинуса угла в радианах
    const sin = (deg) => Math.sin((Math.PI/180)*deg);
    const cos = (deg) => Math.cos((Math.PI/180)*deg);
    // получаем новые координаты точки по формулам
    // матрицы поворота для трёхмерного пространства
    let x,y,z;
    // поворот по оси 'x'
    y = t0.y+(this.y-t0.y)*cos(deg.x)-(this.z-t0.z)*sin(deg.x);
    z = t0.z+(this.y-t0.y)*sin(deg.x)+(this.z-t0.z)*cos(deg.x);
    this.y=y; this.z=z;
    // поворот по оси 'y'
    x = t0.x+(this.x-t0.x)*cos(deg.y)-(this.z-t0.z)*sin(deg.y);
    z = t0.z+(this.x-t0.x)*sin(deg.y)+(this.z-t0.z)*cos(deg.y);
    this.x=x; this.z=z;
    // поворот по оси 'z'
    x = t0.x+(this.x-t0.x)*cos(deg.z)-(this.y-t0.y)*sin(deg.z);
    y = t0.y+(this.x-t0.x)*sin(deg.z)+(this.y-t0.y)*cos(deg.z);
    this.x=x; this.y=y;
  }
  // получаем проекцию типа (type) с расстояния (d)
  // на плоскость экрана наблюдателя (tv)
  projection(type, tv, d) {
    let proj = {};
    // получаем проекцию по экспериментальным формулам
    switch (type) {
      case 'parallel': {
        proj.x = this.x;
        proj.y = this.y+(tv.y-this.z)/4;
        break;
      }
      case 'perspective': {
        proj.x = tv.x+d*(this.x-tv.x)/(this.z-tv.z+d);
        proj.y = tv.y+d*(this.y-tv.y)/(this.z-tv.z+d);
        break;
      }
    }
    // вычисляем расстояние до центра проекции
    proj.dist = Math.sqrt((this.x-tv.x)*(this.x-tv.x)
        +(this.y-tv.y)*(this.y-tv.y)
        +(this.z-tv.z+d)*(this.z-tv.z+d));
    return proj;
  }
  // сравниваем две проекции точек (p1,p2),
  // координаты (x,y) должны совпадать
  static pEquals(p1, p2) {
    return Math.abs(p1.x-p2.x)<0.0001
        && Math.abs(p1.y-p2.y)<0.0001;
  }
};

Класс Куб содержит коллекцию вершин класса Точка и массив граней. Каждая грань — это массив из 4 вершин, выходящих из одной точки и идущих по часовой стрелке. Куб содержит методы для поворота всех вершин на угол и для получения проекций всех граней на плоскость. При получении проекций, вычисляется наклон грани — это удалённость от плоскости проекции. Куб также содержит два статических метода для сравнения двух проекций граней: для определения равноудалённых граней от центра проекции и смежных стенок между соседними кубиками.

class Cube
class Cube {
  // левая верхняя ближняя координата и размер
  constructor(x,y,z,size) {
    // правая нижняя дальняя координата
    let xs=x+size,ys=y+size,zs=z+size;
    let v={ // вершины
      t000: new Point(x,y,z),    // верх
      t001: new Point(x,y,zs),   // верх
      t010: new Point(x,ys,z),   // низ
      t011: new Point(x,ys,zs),  // низ
      t100: new Point(xs,y,z),   // верх
      t101: new Point(xs,y,zs),  // верх
      t110: new Point(xs,ys,z),  // низ
      t111: new Point(xs,ys,zs)};// низ
    this.vertices=v;
    this.faces=[ // грани
      [v.t000,v.t100,v.t110,v.t010], // передняя
      [v.t000,v.t010,v.t011,v.t001], // левая
      [v.t000,v.t001,v.t101,v.t100], // верхняя
      [v.t001,v.t011,v.t111,v.t101], // задняя
      [v.t100,v.t101,v.t111,v.t110], // правая
      [v.t010,v.t110,v.t111,v.t011]];// нижняя
  }
  // поворачиваем вершины куба на угол (deg)
  // по осям (x,y,z) относительно точки (t0)
  rotate(deg, t0) {
    for (let vertex in this.vertices)
      this.vertices[vertex].rotate(deg, t0);
  }
  // получаем проекции типа (type) с расстояния (d)
  // на плоскость экрана наблюдателя (tv)
  projection(type, tv, d) {
    let proj = [];
    for (let face of this.faces) {
      // проекция грани, массив вершин
      let p = [];
      // кумулятивная удалённость вершин
      p.dist = 0;
      // обходим вершины грани
      for (let vertex of face) {
        // получаем проекции вершин
        let proj = vertex.projection(type, tv, d);
        // накапливаем удалённость вершин
        p.dist+=proj.dist;
        // добавляем в массив вершин
        p.push(proj);
      }
      // вычисляем наклон грани, удалённость от плоскости проекции
      p.clock = ((p[1].x-p[0].x)*(p[2].y-p[0].y)
                -(p[1].y-p[0].y)*(p[2].x-p[0].x))<0;
      proj.push(p);
    }
    return proj;
  }
  // сравниваем две проекции граней (f1,f2), вершины
  // должны быть равноудалены от центра проекции
  static pEquidistant(f1, f2) {
    return Math.abs(f1.dist-f2.dist)<0.0001;
  }
  // сравниваем две проекции граней (f1,f2), координаты
  // точек по главной диагонали (p0,p2) должны совпадать
  static pAdjacent(f1, f2) {
    return Point.pEquals(f1[0],f2[0])
        && Point.pEquals(f1[2],f2[2]);
  }
};

Создаём объекты по шаблонам и рисуем их проекции на плоскости.

'use strict';
// матрицы-шаблоны для кубиков
const shape1 = [ // пространственный крест
  [[0,0,0,0,0], [0,0,0,0,0], [0,0,1,0,0], [0,0,0,0,0], [0,0,0,0,0]],
  [[0,0,0,0,0], [0,0,0,0,0], [0,0,1,0,0], [0,0,0,0,0], [0,0,0,0,0]],
  [[0,0,1,0,0], [0,0,1,0,0], [1,1,1,1,1], [0,0,1,0,0], [0,0,1,0,0]],
  [[0,0,0,0,0], [0,0,0,0,0], [0,0,1,0,0], [0,0,0,0,0], [0,0,0,0,0]],
  [[0,0,0,0,0], [0,0,0,0,0], [0,0,1,0,0], [0,0,0,0,0], [0,0,0,0,0]]];
const shape2 = [ // крест-куб
  [[0,0,1,0,0], [0,0,1,0,0], [1,1,1,1,1], [0,0,1,0,0], [0,0,1,0,0]],
  [[0,0,1,0,0], [0,0,0,0,0], [1,0,0,0,1], [0,0,0,0,0], [0,0,1,0,0]],
  [[1,1,1,1,1], [1,0,0,0,1], [1,0,0,0,1], [1,0,0,0,1], [1,1,1,1,1]],
  [[0,0,1,0,0], [0,0,0,0,0], [1,0,0,0,1], [0,0,0,0,0], [0,0,1,0,0]],
  [[0,0,1,0,0], [0,0,1,0,0], [1,1,1,1,1], [0,0,1,0,0], [0,0,1,0,0]]];
// размер кубика, количество кубиков в ряду, отступ
const size = 40, row = 5, gap = 50;
// массивы для кубиков
const cubes1 = [], cubes2 = [];
// обходим матрицы, заполняем массивы кубиками
for (let x=0; x<row; x++)
  for (let y=0; y<row; y++)
    for (let z=0; z<row; z++) {
      if (shape1[x][y][z]==1)
        cubes1.push(new Cube(x*size+gap,y*size+gap,z*size+gap,size));
      if (shape2[x][y][z]==1)
        cubes2.push(new Cube(x*size+gap,y*size+gap,z*size+gap,size));
    }
// центр фигуры, вокруг него будем выполнять поворот
const t0 = new Point(150,150,150);
// удалённость центра проекции
const d = 300;
// положение экрана наблюдателя
const tv = new Point(150,150,125);
// угол поворота в градусах
const deg = {x:1,y:1,z:1};
// рисовать будем по две картинки для каждой фигуры
const canvas1 = document.getElementById('canvas1');
const canvas2 = document.getElementById('canvas2');
const canvas3 = document.getElementById('canvas3');
const canvas4 = document.getElementById('canvas4');
// обновление изображения
function repaint() {
  // пространственный крест
  processFigure(cubes1,canvas1,canvas2);
  // крест-куб
  processFigure(cubes2,canvas3,canvas4);
}
// поворачиваем фигуру и получаем проекции
function processFigure(cubes,cnv1,cnv2) {
  // массивы проекций граней кубиков
  let parallel = [], perspective = [];
  // поворачиваем кубики и получаем проекции
  for (let cube of cubes) {
    cube.rotate(deg, t0);
    parallel = parallel.concat(cube.projection('parallel',tv,d));
    perspective = perspective.concat(cube.projection('perspective',tv,d));
  }
  // смежные стенки между соседними кубиками не рисуем
  noAdjacent(parallel);
  noAdjacent(perspective);
  // сортируем грани разных кубиков по удалённости и внутри одного кубика по наклону
  parallel.sort((a,b)=>Math.abs(b.dist-a.dist)>size ? b.dist-a.dist : b.clock-a.clock);
  // сортируем грани по удалённости от центра проекции
  perspective.sort((a,b)=>b.dist-a.dist);
  // рисуем параллельную проекцию
  drawFigure(cnv1, parallel);
  // рисуем перспективную проекцию
  drawFigure(cnv2, perspective);
}
// смежные стенки между соседними кубиками не рисуем
function noAdjacent(array) {
  // сортируем грани по удалённости
  array.sort((a,b) => b.dist-a.dist);
  // удаляем смежные стенки между кубиками
  for (let i=0, j=1; i<array.length-1; j=++i+1)
    while (j<array.length && Cube.pEquidistant(array[i],array[j]))
      if (Cube.pAdjacent(array[i],array[j])) {
        array.splice(j,1);
        array.splice(i,1);
        i--; j=array.length;
      } else j++;
}
// рисуем фигуру по точкам из массива
function drawFigure(canvas, proj, alpha=0.8) {
  const context = canvas.getContext('2d');
  // очищаем весь холст целиком
  context.clearRect(0, 0, canvas.width, canvas.height);
  // обходим массив граней куба
  for (let i = 0; i < proj.length; i++) {
    // обходим массив точек и соединяем их линиями
    context.beginPath();
    for (let j = 0; j < proj[i].length; j++) {
      if (j == 0) {
        context.moveTo(proj[i][j].x, proj[i][j].y);
      } else {
        context.lineTo(proj[i][j].x, proj[i][j].y);
      }
    }
    context.closePath();
    // рисуем грань куба вместе с рёбрами
    context.lineWidth = 1.9;
    context.lineJoin = 'round';
    context.fillStyle = 'rgba(200,230,201,'+alpha+')';
    context.strokeStyle = 'rgba(102,187,106,'+(0.2+alpha)+')';
    context.fill();
    context.stroke();
  }
}
// после загрузки страницы, задаём частоту обновления изображения 20 Гц
document.addEventListener('DOMContentLoaded',()=>setInterval(repaint,50));

© Головин Г.Г., Код с комментариями, 2023