Vue.JS + D3.JS = Генератор бесшовных плиток Вороного.


Disklaimer (aka Отмазки)

  • Главным приоритетом при разработке утилиты было время. Качеству кода много внимания не уделялось.
  • Согласно правилу "80/20" реализовано 80% функционала за 20% времени. Можно улучшать юзабилити и фиксить минорные баги, но ресурсов на это сейчас нет.
  • Как разработчику, автору проще скопировать SVG из окна Dev Tools в Chrome, чем реализовывать экспорт в файл. Уж простите...

TL;DR (для нетерпеливых)

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

Код размещен в репозитории на Bitbucket.

Введение

Итак, что же за задачу решал автор при разработке утилиты?

Есть идея реализации игрового мира a-la Minecraft, но не с регулярными вокселями (кубами), а с призмами на основе Диаграммы Вороного.
Прототип из реального мира - побережья с ландшафтом из базальтовых колонн в Исландии. Загуглите "basalt column iceland"...

Так вот, для реализации идеи был придуман такой пайплайн:

  1. На любом языке сгенерировать Диаграмму Вороного так, чтобы противолежащие границы и углы стыковылись без разрывов. Иными словами, чтобы огибающая правой границы совпадала с огибающей левой, верхняя граница совпадала с нижней, левый нижний угол совпадал с правым верхним и т.д.
  2. Экспортировать сгенерированную диаграмму в SVG. Открыть в InkScape и скопировать.
  3. Импортировать в 3D редактор (Sketchup, Blender).
  4. В 3D сделать экструзию и подготовить 3D модель для Unity 3D.
  5. В Unity 3D запрограммировать компоненты, изменяющие высоту колонны и реализовать изменяемый ландшафт на основе Диаграммы Вороного.

В данной статье описывается реализация 1 и 2 шагов - генератор бесшовной плитки на основе Диаграммы Вороного.

Настройка проекта Vue.JS


источник

Начнем с разворачивания простого проекта NPM на основе фреймворка Vue.JS.

Вы легко можете нагуглить самые свежие туториалы на тему "Vue.JS Get Started".

Вот, например, туториал с рукопашным запиливанием зачатка проекта на Vue.JS

Автор предпочитает работать в консоли с использованием Vue CLI.

Поэтому вот еще один туториал: Vue CLI Full Docs.

Эта утилита помогает через консоль нагенерировать стандартный код (скаффолдинг, scaffolding) и де-факто приучивает разработчика следовать некоторым конвенциям.

Замечание: Сейчас утилита Vue CLI стала более продвинутой, нежели она была во время работы над кодом описываемой утилиты "Voronoi Tile JS". Тогда продвинутого скаффолдинга не было и использовался по сути только начальный генератор проекта по шаблону "webpack-simple".

Для начала следует установить Vue CLI.

> npm install -g @vue/cli

Теперь можно сгенерировать проект.

vue create my-project

Вот полное описание для команды "vue create":

Usage: vue <command> [options]

Commands:

  create [options] <app-name>      create a new project powered by vue-cli-service
  invoke <plugin> [pluginOptions]  invoke the generator of a plugin in an already created project
  inspect [options] [paths...]     inspect the webpack config in a project with vue-cli-service
  serve [options] [entry]          serve a .js or .vue file in development mode with zero config
  build [options] [entry]          build a .js or .vue file in production mode with zero config
  init <template> <app-name>       generate a project from a remote template (legacy API, requires @vue/cli-init)
Usage: create [options] <app-name>

create a new project powered by vue-cli-service

Options:

  -p, --preset <presetName>       Skip prompts and use saved preset
  -d, --default                   Skip prompts and use default preset
  -i, --inlinePreset <json>       Skip prompts and use inline JSON string as preset
  -m, --packageManager <command>  Use specified npm client when installing dependencies
  -r, --registry <url>            Use specified npm registry when installing dependencies (only for npm)
  -f, --force                     Overwrite target directory if it exists
  -h, --help                      output usage information

Замечание: эта утилита быстро развивается и старый синтаксис ,использованный во время разработки уже давно устарел. В мире NPM и VueJS 1-2 месяца - это уже приличный срок. Поэтому у вас сгенерированный результат ,возможно, будет незначительно отличаться от коммита на скриншоте ниже. ¯\_(ツ)_/¯

Так или иначе, после вызова "vue create" вы должны получить минимальный набор сорцев, которые можно открыть в VS Code.

Если код сгенерировался без ошибок, то вы должны получить возможность запустить свой "Hello World". В новом Vue CLI есть команда "serve":

vue serve MyComponent.vue

Автор же по-старинке использовал npm run dev.

Преимущество новой команды vue serve в том, что можно указать корневой компонент, а HTML файл для него Vue CLI сгенерирует самостоятельно. Очень удобно! (индивидуальное ИМХО автора поста ;)

Внедрение MobX


источник

Теперь настала очередь добавления логики. Вам может показаться, что для очень маленьких приложений, вроде описываемой утилитки, можно обойтись одним лишь Vue.JS. И это, в принципе, правда. Но автор рекомендует все-таки сразу привыкать к использованию какой-либо реализации паттерна Flux даже в таких мелких аппах. На примере простых приложений Flux State Manager'ы вполне себе доступны для изучения.

Всего есть несколько реализаций Flux. Самые известные на момент публикации: Redux, MobX и Vuex.

Redux - реализация Flux для ReactJS. Отличается многословностью и повышенным порогом вхождения.

MobX - кросс-платформенная реализация. Есть адаптеры как под ReactJS, так и под VueJS. Отличается эта реализация предельной простотой в освоении и использовании. Поэтому изначально логику приложения и было решено делать с MobX.

VueX - реализация Flux для VueJS. Лучше интегрирована в приложение VueJS. По сути такая же простая в освоении и использовании как MobX.

Что нужно для внедрения MobX? Смотрим туториал на гитхабе.

npm install vue-mobx --save

Дальше в main.js:

// main.js
import Vue from 'vue';
import {observable, isObservable, toJS} from 'mobx';
import VueMobx from 'vue-mobx';

Vue.use(VueMobx, {
    toJS: toJS, // must
    isObservable: isObservable, // must
    observable: observable,  // optional
});

Создаем модель состояния аппа:

// voronoi.mobx.js
import {observable, action} from 'mobx';

class VoronoiMobx {
    @observable
    toolbarCaption = 'Tools:';

    @action.bound
    changeCount(){
        ++this.count;
    }
}

const voronoi = new VoronoiMobx();
export default voronoi;

Замечания: тут автор проявил себя как рукожоп человек-снежинка. По распространенной конвенции, оказывается, файлы следует именовать в "kebab -- case", т.е. вместо точки разделять слова следует дефисом.

Когда именуешь файлы с точкой вместо kebab-case,

Остается привинтить MobX к компоненту

<template>

    <h3>{{ toolbarCaption }}</h3>

</template>

import vor from '../mobx/voronoi.mobx';

export default {
  name: 'tools',
  data () {
    return {
    }
  },
  fromMobx: { // < MobX goes here
      vor     // < See "vor" imported above from voronoi.mobx.js
  }
}
</script>

Внедрение D3.JS

Кто не знает, D3.JS это супер-пупер-мега-крутая библиотека визуализации данных. В частности, для нее реализована утилита генерации тесселяции Вороного (aka триангуляция Делоне). В этой утилите используется эффективный Fortune's Algorithm

Для внедрения D3.JS можно либо подкличить скрипты из CDN, либо установить пакет через NPM.

npm install d3

Перед началом использования D3.JS, нужно было создать компонент Artboard.vue.

Привязать размеры канваса в артборде к хранилищу состояния MobX.

Ну и затем для драфта наивная копипаста с демо страницы d3-voronoi.

Порция измененного кода компонента Artboard.vue

  },
  fromMobx: {
      vor
  },
  mounted(){
        console.log('D3 lib: ', d3);

    var canvas = d3.select("canvas#mainCanvas").on("touchmove mousemove", () => { moved(canvas, sites, voronoi, context, width, height); }).node(),
        context = canvas.getContext("2d"),
        width = canvas.width,
        height = canvas.height;

    var sites = d3.range(100)
        .map(function(d) { return [Math.random() * width, Math.random() * height]; });

    var voronoi = d3.voronoi()
        .extent([[-1, -1], [width + 1, height + 1]]);

    redraw(sites, voronoi, context, width, height);

  }
}

function moved(node, sites, voronoi, context, width, height) {
  sites[0] = d3.mouse(node);
  redraw(sites, voronoi, context, width, height);
}

function redraw(sites, voronoi, context, width, height) {
  var diagram = voronoi(sites),
      links = diagram.links(),
      polygons = diagram.polygons();

  context.clearRect(0, 0, width, height);
  context.beginPath();
  drawCell(polygons[0], context);
  context.fillStyle = "#f00";
  context.fill();

  context.beginPath();
  for (var i = 0, n = polygons.length; i < n; ++i) drawCell(polygons[i], context);
  context.strokeStyle 
  context.stroke();

  context.beginPath();
  for (var i  n  i < n; ++i) drawLink(links[i], context);
  context.strokeStyle 
  context.stroke();

  context.beginPath();
  drawSite(sites[0], context);
  context.fillStyle 
  context.fill();

  context.beginPath();
  for (var i  n  i < n; ++i) drawSite(sites[i], context);
  context.fillStyle 
  context.fill();
  context.strokeStyle 
  context.stroke();
}

function drawSite(site, context) {
  context.moveTo(site[0] + 2.5, site[1]);
  context.arc(site[0], site[1], 2.5, 0, 2 * Math.PI, false);
}

function drawLink(link, context) {
  context.moveTo(link.source[0], link.source[1]);
  context.lineTo(link.target[0], link.target[1]);
}

function drawCell(cell, context) {
  if (!cell) return false;
  context.moveTo(cell[0][0], cell[0][1]);
  for (var j  m  j < m; ++j) {
    context.lineTo(cell[j][0], cell[j][1]);
  }
  context.closePath();
  return true;
}

</script>

Замечания: из "нехорошестостей" в глаза тут бросаются function в глобальном контексте. Позже этот код был переделан согласно гайдам на компоненты Vue. Как-то так...

Засорять глобальный контекст в JS считается плохой практикой.

Далее был установлен NPM-пакет "vue-numeric". Это компонент для полей ввода числовых значений.

Миграция на VueX


источник

С MobX было хорошо и лениво было тратить время на изучение VueX. Но засорение кода Vue всяческими декораторами навроде "@observable" и прочей шелухой от MobX уже начало пахнуть неприятностями. Было решено перейти на VueX.

Как обычно с пакетами NPM:

npm install vuex

Потом создаем store:

// vor.store.js
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex.Store({
    strict: true,
    state: {
        toolbarCaption: 'Tools:',
        canvasWidth: 640,
        canvasHeight: 640,
    },
    mutations: {
        updateCanvas: function(state, size) {
            if (size.hasOwnProperty('width')) {
                state.canvasWidth = size.width;
            }

            if (size.hasOwnProperty('height')) {
                state.canvasHeight = size.height;
            }
        }
    }
});

export default store;

Заменяем кишочечки store в main.js

Переделываем компонент инструментов

Ну и в артборде переделываем биндинги на vuex

Вот финальная версия компонента Artboard.vue:

<template>

      <svg id="mainCanvas"  ></svg>

</template>

import * as d3 from "d3";
import vor from '../state/vor.store';
import { forceCenter } from 'd3-force';

export default {
  name: 'artboard',
  store: vor,
  data () {
    return {
      width: this.$store.state.canvasWidth,
      height: this.$store.state.canvasHeight,
      sites: [],
      canvas: null,
      polygon: null,
      link: null,
      site: null,
    }
  },
  mounted(){
    this.initCanvas();

    this.$watch('canvasSize', (size) => {
      this.initCanvas();
    });
  },
  computed: {
    canvasSize() {
      return { width: this.$store.state.canvasWidth, height: this.$store.state.canvasHeight };
    },
    diagramSites() {
      return {
        sites: this.$store.state.sites,
        drawSites: this.$store.state.drawSites,
        drawLink: this.$store.state.drawLinks
      };
    }
  },
  watch: {
    diagramSites: function(newSites, oldSites) {
      this.initCanvas();
    }
  },
  methods:{
    initCanvas(){
      const canvas = this.$data.canvas = d3.select("svg#mainCanvas");
      canvas.selectAll("*").remove();

      const width = this.$store.state.canvasWidth,
        height = this.$store.state.canvasHeight,
        halfWidth = width * 0.5,
        halfHeight = height * 0.5;

      const n = this.$store.state.sites.length;

      if (n === 0){
        console.warn('Artboard >> Sites is not yet generated. Aborting Diagram Initialization...');
        return;
      }

      const storeSites = this.$store.state.sites;
      this.$data.sites = [];

      for (let i = 0; i < n; i++){
        for (let j  j < n; j++) {
          const s 
          this.$data.sites.push([halfWidth + s.x, halfHeight + s.y]);
        }
      }

      const voronoi  = d3.voronoi().extent([[-1, -1], [width + 1, height + 1]]);
      const sites 

      this.$data.polygon 
        .attr("class", "polygons")
        .selectAll("path")
        .data(voronoi.polygons(sites))
        .enter().append("path");
      this.$data.polygon.call(this.drawCell);

      if(this.$store.state.drawLinks) {
        this.$data.link 
          .attr("class", "links")
          .selectAll("line")
          .data(voronoi.links(sites))
          .enter().append("line");
        this.$data.link.call(this.drawLink);
      }

      if(this.$store.state.drawSites){
        this.$data.site 
          .attr("class", "sites")
          .selectAll("circle")
          .data(sites)
          .enter().append("circle")
          .attr("r", 2.5);
        this.$data.site.call(this.drawSite);
      }

      this.redraw();

    },

    redraw() {
      const voronoi ;
      const width 
      const height 
      const sites 

      var diagram 
      this.$data.polygon 

      if(this.$store.state.drawLinks) {
        this.$data.link  this.$data.link.exit().remove();
        this.$data.link 
      }

      if(this.$store.state.drawSites){
        this.$data.site 
      }
    },

    drawSite(site) {
      this.$data.site
        .attr("cx", function(d) { return d[0]; })
        .attr("cy", function(d) { return d[1]; });
    },

    drawCell(cell) {
      this.$data.polygon
        .attr("d", function(d) { return d ? "M" + d.join("L") + "Z" : null; });
    },

    drawLink(link) {
      this.$data.link
        .attr("x1", function(d) { return d.source[0]; })
        .attr("y1", function(d) { return d.source[1]; })
        .attr("x2", function(d) { return d.target[0]; })
        .attr("y2", function(d) { return d.target[1]; });
    }
  }
}

</script>

div#artboard {
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
}

div#artboard svg#mainCanvas {
    margin: auto;
    background-color:#eee;
}
.links {
  stroke:#000;
  stroke-opacity: 0.2;
}

.polygons {
  fill: none;
  stroke:#000;
}

.sites {
  fill: rgba(8,8,8,0.2);
  stroke:#111;
}

Шаг 4. Дорисовываем Сову.

источник

После перехода на Vuex были еще сделаны некоторые улучшения по юзабилити и дизайну. За этим лучше сходить в историю коммитов. Тут же хотелось поподробнее описать последние значимые для решения задачи штрихи.

На демо странице d3-voronoi генерировалась полностью случайная диаграмма. Ее нельзя было состыковать саму с собой ни в каком направлении. Автору потребовалось придумать подход как на границе диаграммы сформировать слой из двух рядов сайтов ячеек такой, чтобы ломаные между этими рядами совпадали для противолежащих границ.

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

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

Материал подготовлен автором @homoludens


Comments 1