Постановка задачи



Вы умеете собирать кубик Рубика? Я практически нет. В детстве я мог собрать только пирамидку, а кубик просто раздирал на запчасти и собирал заново, после чего он стремительно разбалтывался. В прошлом году я съездил на сигграф и там на одном из выставочных стендов раздавали (ужасные по качеству, но зато с рекламой) кубики, после чего конференция для меня была практически потеряна, зато двенадцать часов в самолёте прошли незаметно.

Собирать кубик, подглядывая в уже готовые алгоритмы, мне было неинтересно, поэтому я его собрал пока что только один раз, это у меня заняло примерно полгода и приличную стопку исписанной бумаги. Между делом мне на глаза попался проект MindCub3r. Его автор даёт чертежи и ПО для роботов-сборщиков кубика из LEGO, причём эти чертежи есть для всех возможных комплектов, начиная от первого NXT и заканчивая EV3.




Эти роботы крайне хороши, я восхищён тем, их автор David Gilday использует для непосредственно сборки кубика только два сервомотора и ещё один для сканера. Надо учитывать, что стандартные наборы LEGO довольно скудны, и то, что такого робота можно собрать из одной коробки — это немалое достижение. Работает оно просто, сначала кубик сканируется: его датчик ставится напротив каждой этикетки кубика, затем софт решает кубик, выдавая цепочку необходимых вращений, которая потом и исполняется механически.

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

Итак, собрал я хардварную часть робота, сборочные чертежи великолепны. Скачал софт, загрузил, с трепетом поставил кубик на платформу и жестоко обломался. Оказывается, этому роботу необходим официальный кубик Рубика, а я купил тот, что используется для спидкубинга. У него другие цвета граней (например, нет белого), и робот надорвался, не сумел его сосканировать. Хорошо, сказал я, поскрёб по сусекам, нашёл официальный кубик, правда, возрастом под два десятка лет. С его затёртыми и залапанными наклейками робот тоже работать не захотел.



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

Итак, постановка задачи: написать весь софт под существующий хард MindCub3r EV3, для начала софт должен просто собирать кубик. Минимизировать количество ходов в сборке меня не интересует, мне нужен как можно более простой код. Эта статья говорит только про то, как решать кубик, в следующей я опишу сканирование и управление сервами. В среднем решение получается длиной слегка за сто ходов.

Пишем код


Использованный метод



В мире огромное количество алгоритмов, наверное, самым правильным было бы запрограммировать вот этот. Но это надо думать, изобретать эвристики для A* с итеративным заглублением, а я ленивый. Кроме того, это надо думать, как его использовать, чтобы получить заданную конфигурацию, а я ленивый. Поэтому я буду пользоваться самым тупым и прямым методом: при помощи Cube Explorer записываем все последовательности поворотов, которые позволяют собрать
  • нижнее переднее ребро
  • нижний правый передний угол
  • переднее правое ребро
  • верхний правый передний угол
  • верхнее переднее ребро


Затем код будет выглядеть следующим образом:
(исполнить четыре раза) {
    собрать нижнее переднее ребро;
    повернуть весь куб на 90° вокруг вертикальной оси;
}

(исполнить четыре раза) {
    собрать нижний правый передний угол;
    повернуть весь куб на 90° вокруг вертикальной оси;
}

(исполнить четыре раза) {
    собрать переднее правое ребро;
    повернуть весь куб на 90° вокруг вертикальной оси;
}

(исполнить четыре раза) {
    собрать верхний правый передний угол;
    повернуть весь куб на 90° вокруг вертикальной оси;
}

(исполнить четыре раза) {
    собрать верхнее переднее ребро;
    повернуть весь куб на 90° вокруг вертикальной оси;
}


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

Структура данных



Грани кубика назовём стандартно: F — передняя, R — правая, B — задняя, L — левая, U — верхняя, D — нижняя. Вот стандартные вращения:


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



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

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



Собранный куб должен быть представлен парой массивов {0, 1, 2, 3, 8, 9, 10, 11, 20, 21, 22, 23} и {0, 1, 2, 3, 20, 21, 22, 23}, это рёбра и углы, соответственно. Например, ребро номер 4 в массиве должно иметь ориентацию 8.

Вот так выглядит представление кубика:
Скрытый текст
unsigned char edges[12], corners[8]; // actual configuration of the cube

// When we rotate one face, there will be 4 rotating edges with 2 incident facets and 4 rotating corners with 3 incident facets
// these arrays define the transformations of the cube indices for all 6 possible rotations
const unsigned char how_edges_move[6][8] = {4,8,16,15, 0,12,20,11, 5,9,17,12, 1,13,21,8, 6,10,18,13, 2,14,22,9, 7,11,19,14, 3,15,23,10, 0,3,2,1, 4,7,6,5, 20,21,22,23, 16,17,18,19};
const unsigned char how_corners_move[6][12] = {4,12,19,11, 0,16,23,7, 8,20,15,3, 8,5,13,16, 4,1,17,20, 0,9,21,12, 9,6,14,17, 5,2,18,21, 1,10,22,13, 7,15,18,10, 3,19,22,6, 11,23,14,2, 0,3,2,1, 8,11,10,9, 4,7,6,5, 20,21,22,23, 16,17,18,19, 12,13,14,15};

// given a face index and a number of turns, rotate the face
void atomic_rotation(unsigned char face_idx, unsigned char count) {
    for (int i=0; i<12; i++) { // # edges
        for (int j=0; j<8; j++) { // 4*2 edge facets are to be moved
            if (how_edges_move[face_idx][j] == edges[i]) {
                edges[i] = how_edges_move[face_idx][(j+count)%4+(j/4)*4];
                break;
            }
        }
    }
    for (int i=0; i<8; i++) { // # corners
        for (int j=0; j<12; j++) { // 4*3 corner facets are to be moved
            if (how_corners_move[face_idx][j] == corners[i]) {
                corners[i] = how_corners_move[face_idx][(j+count)%4+(j/4)*4];
                break;
            }
        }
    }
}



edges и corners — это массивы, где хранятся данные, how_edges_move и how_corners_move описывают преобразование индексов массивов edges и corners для вращения вокруг каждой из шести граней, а функция atomic_rotation() собственно и выполняет вращение одной данной грани.

Жёстко зашитые алгоритмы



Сложная часть закончилась, теперь начинатся скучная часть, открываем Cube Explorer и руками записываем некоторое количество алгоритмов для сборки.

Скрытый текст
// hard-coded algorithms to assemble corresponding edges and corners
const char alg_DF_edge[]   [10] = {"F2 ", "U1 ", "U2 ", "U3 ", "U3 ", "R3 F1 R1 ", "U3 ", "L1 F3 L3 ", "D1 R3 D3 ", "D2 B3 D2 ", "D3 L3 D1 ", "F3 ", "F1 ", "D1 R1 D3 ", "D2 B1 D2 ", "D3 L1 D1 ", "F3 ", "R1 ", "B1 ", "L3 ", "", "R2 ", "B2 ", "D1 "};

const char alg_FR_edge[]   [34] = {"U2 ", "U3 ", "F3 U3 F1 U1 R1 U1 R3 ", "U1 ", "U1 ", "U2 ", "U3 ", "R1 U1 R3 U3 F3 U3 F1 ", "", "D1 B1 U2 B3 D3 R1 U3 R3 ", "D1 B2 D3 U1 R2 ", "D3 L3 U2 L1 D1 F3 U1 F1 ", "F3 U1 F1 U3 R1 U2 R3 U2 R1 U3 R3 ", "R2 U2 R2 U2 R2 ", "R1 F3 B1 U3 B2 U1 F1 B3 R3 B2 ", "F2 U2 F2 U2 F2 "};

const char alg_UF_edge[]   [40] = {"", "R2 U1 F1 B3 R2 B1 F3 U1 R2 ", "R2 U3 F1 B3 R2 B1 F3 U3 R2 ", "F2 U3 L1 R3 F2 R1 L3 U3 F2 ", "R3 F1 R1 F1 R2 B3 D3 F3 D1 F1 B1 R2 F2 ", "R3 F3 R2 D2 L2 B3 L2 D2 R3 ", "R1 U1 R3 U3 R3 L1 F1 R1 F3 L3 ", "L1 F1 L2 D2 R2 B1 R2 D2 L1 "};
const char alg_DRF_corner[][10] = {"U3 ", "F3 U2 F1 ", "F3 U3 F1 ", "R1 U2 R3 ", "F3 U3 F1 ", "U1 ", "R1 U2 R3 ", "R1 U3 R3 ", "R1 U1 R3 ", "F3 U1 F1 ", "F3 U2 F1 ", "U3 ", "F3 U2 F1 ", "R3 U3 R1 ", "B3 U3 B1 ", "L3 U3 L1 ", "R1 U2 R3 ", "B1 U1 B3 ", "L1 U1 L3 ", "F1 U1 F3 ", "", "B1 U1 B3 ", "L1 U2 L3 ", "L3 U3 L1 "};

const char alg_URF_corner[][40] = {"", "U3 F2 L2 F3 R3 F1 L2 F3 R1 F3 ", "B1 L3 B1 R2 B3 L1 B1 R2 B2 ", "U3 ", "F1 R2 F1 U3 L2 U1 F3 R2 F1 U3 L2 U1 F2 ", "L1 U1 L3 B2 D3 F1 R3 F3 D1 B2 U1 ", "U1 F1 U3 B3 U1 F3 U3 B1 ", "F1 R1 U1 R3 U3 F3 ", "B1 U2 B2 R1 D3 R1 D1 R2 B2 U2 B3 U2 ", "B1 U3 F1 U1 F3 B3 U2 F1 U1 F3 U2 ", "R1 B2 L3 D1 L3 D3 L2 B2 R3 U3 ", "L1 U1 F1 U3 F3 L3 U2 "};

// given a sequence string, split it into atomic rotations and print the moves
// the moves are to be corrected with the orientation of the cube
void apply_sequence(const char *sequence, unsigned char orientation) {
    while (*sequence) {
        unsigned char fi  = char2idx[*sequence-'A'];
        unsigned char cnt = *(++sequence) - '0';
        atomic_rotation(fi, cnt);

        if (fi<4) fi = (fi+orientation)%4; // U and D facets are not affected by the entire cube rotation
        std::cout << idx2char[fi] << *sequence << " ";
        sequence += 2;
    }
}



Помните, что первым делом мы будем собирать нижнее переднее ребро? В полностью перемешанном кубике оно может находиться на любом из 24 возможных мест, поэтому массив alg_DF_edge даёт 24 разных последовательности, которые поставят ребро на место. Названия остальных массивов говорят сами за себя. Функция apply_sequence() разбивает строку на атомарные операции и вызывает atomic_rotation().

Непосредственно сборщик



Непосредственно сборщик выглядит прямым переводом псевдокода, данного в начале, на язык C++:
Скрытый текст
        for (int e=8; e<12; e++) { // D edges have numbers 8..11
            while (edges[e] != 20) { // solved FD edge has the position 20
                apply_sequence(alg_DF_edge[edges[e]], e-8);
            }
            rotate_entire_cube();
        }

        for (int c=4; c<8; c++) { // D corners have numbers 4..7
            while (corners[c] != 20) { // solved DRF corner has the position 20
                apply_sequence(alg_DRF_corner[corners[c]], c-4);
            }
            rotate_entire_cube();
        }

        for (int e=4; e<8; e++) { // middle edges have numbers 4..7
            while (edges[e] != 8) { // solved edge number 4 has the position 8
                apply_sequence(alg_FR_edge[edges[e]], e-4);
            }
            rotate_entire_cube();
        }

        for (int c=0; c<4; c++) { // U corners have numbers 0..3
            apply_sequence(alg_URF_corner[corners[c]], c);
            rotate_entire_cube();
        }

        for (int e=0; e<4; e++) { // U edges have numbers 0..3
            apply_sequence(alg_UF_edge[edges[e]], e);
            rotate_entire_cube();
        }



Обратите внимание, что rotate_entire_cube() каждый раз вызывается четыре раза, таким образом, между циклами наш куб всегда ориентирован как надо, но внутри цикла мы должны знать, как именно он ориентирован, чтобы скорректировать вывод решения на экран.

Запуск



Берём пример разобранного кубика здесь и запускаем программу:
~/MindCub3r(master)$ make && ./main RF BL UF DR UL FD DL LF RU BU BR BD FRU URB ULF LDF LUB RFD LBD RDB
g++ -Wall -Wall -Wextra -Weffc++ -pedantic -std=c++98 -c  solver.cpp -o solver.o
g++ -Wall -O3 -o ./main solver.o -lm
R1 F1 U2 R2 B1 D3 B3 D1 U1 R3 U3 R1 U3 B3 U2 B1 L1 U1 L3 L3 U2 L1 F1 U2 F3 D3 L3 U2 L1 D1 F3 U1 F1 U2 L1 U1 L3 U3 B3 U3 B1 U3 L3 U3 L1 U1 F1 U1 F3 F1 R2 F1 U3 L2 U1 F3 R2 F1 U3 L2 U1 F2 B1 L2 F3 D1 F3 D3 F2 L2 B3 U3 R2 U1 F1 B3 R2 B1 F3 U1 R2 B1 U1 B3 U3 B3 F1 R1 B1 R3 F3 


Копируем вывод в веб-валидатор и получаем:
Result is a solved cube.
Move count is 91.
Good solution!


Итак, у нас есть солвер, в следующий раз будем управлять непосредственно роботом.

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


  1. OsipovRoman
    28.06.2015 20:14

    Вероятно вам будет полезно:


    1. haqreu Автор
      28.06.2015 22:57

      Да, спасибо, интересное видео.
      У меня было в планах использовать камеру, но, наверное, на калиброванном автомате — если всё равно нужен физический сборщик, то и камеру можно стационарно закрепить. Соответственно, процесс распознавания существенно упрощается.

      Алгоритм Косиембы, к слову, недурно показан, если мне нужно будет уменьшать количество ходов, то я буду его программировать. Сейчас не стал, т.к. в программировании он чуток сложнее, а у меня времени не сильно много.


  1. muodov
    30.06.2015 22:43

    haqreu прикольно, спасибо за пост. Однако 91 ход это уж слишком, даже для автоматизированной сборки. Я тут как раз работаю над подобным проектом: wiswin.nl/FAC%20system%20Rubik%20cube%20solver.htm. У нас посложнее в плане механики, и управляется это все на данный момент с Raspberry Pi. Сначала задумывали ардуино, но как раз из-за сложности алгоритма пересели на Распберри.

    Я использую для решения вышеупомянутый алгоритм Косиембы, который работает просто волшебно: в среднем решение 17-18 ходов. Правда, я не смог найти нормальной реализации, кроме оригинальной на Java, так что пришлось делать самому. По ссылке две версии, одна на чистом питоне, вторая на чистом Си. Питоновская версия довольно медленная (на десктопе считает мгновенно, но на распберри под PyPy работает до минуты), а сишная — летает.

    Забавно получилось, что я работаю над подобным проектом, и тут такой пост всплывает — родственная душа :) Надо будет как закончим тоже накатать статейку)