Когда простого пунктира мало: как подружить Java AWT Stroke и 10 приказ Минэкономразвития РФ

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Мы тут в ИТМО занимаемся созданием всяких ГИС на заказ. И вот пришел к нам заказчик и попросил сделать демку с отображением генерального плана города и с некоторой аналитикой по ней.

Сам генеральный план - это куча геоданных в формате Shapefile, содержащих различные виды зон и объектов. Плюс поверх этого мы еще должны были рисовать всякие кадастровые участки, здания и много чего еще. И при этом отображать это как в браузере (про это я еще отдельную статью напишу, как нарисовать в браузере на карте 200+ тысяч объектов), так и рендерить отдельные схемы в пнгшки для дальнейшей печати.

И все бы ничего - взяли стандартные средства для работы с изображениями, отрендерили в текстуру по шаблону сгенерированный текст, пунктирчиком разметили границы участков, но тут всплыла такая штука как ЗОУИТ - Зоны с особыми условиями использования территории. Грубо говоря - зоны, в которых строить или нельзя вообще, или можно но с ограничениями и согласованиями. Это могут быть всякие зоны защиты инженерных сетей, водоохранные зоны и т.п.

И вот тут-то началась засада. У каждого ЗОУИТа есть свой стиль для отображения, описанный в 10 приказе Минэкономразвития (вообще порадовало, что оказывается есть цельный отдельный приказ, где прописано все в деталях, от RGB цветов линий до названий и значений полей с данными). И стили эти все достаточно непростые.

Если простую линию или пунктир нарисовать через стандартные механизмы AWT просто, для этого есть класс BasicStroke, то вот рисовать что-то такое этакое, с галочками, крестиками и прочими закорючками в разрывах или вдоль линии, из коробки в джаве не получится. При этом документация и примеры в сети довольно скудные - везде обычно пишут, как нарисовать пунктир тем же стандартным BasicStroke и на этом успокаиваются. А что делать с более сложными видами линий - нигде не сказано. Пришлось осваивать это самому.

Под катом - описание того, как сделать свой собственный Stroke, позволяющий рисовать произвольные фигуры вдоль пути.

Сперва вкратце пробежимся по тому, как в AWT выглядит рисование объектов:

  • Берем объект Graphics - его можно получить как у окна (да, десктоп на джаве давно мертв, я в курсе), так и у, например, изображения, чтобы рендерить в текстуру. Это будет наше полотно для рисования.

  • Создаем объект типа Shape - это могут быть всякие круги-линии-полигоны и прочие геометрические примитивы, заполняем его координатами. Это то, ЧТО мы будем рисовать.

  • У нашего Graphics выставляем поля типа font, color, stroke, paint - это то, КАК мы будем рисовать.

  • Зовем один из двух основных методов - fill закрасит наш шейп, draw нарисует его контур.

Этого достаточно чтобы нарисовать что-то простое. При этом вся магия стилизации нашего изображения находится в пункте 3. И конкретно за рисование контуров отвечает класс Stroke.

Про BasicStroke

Из коробки у нас есть единственный класс BasicStroke с несколькими конструкторами, умеющий рисовать сплошные и пунктирные линии одним цветом. Наиболее понятно (для меня) параметры конструктора и возможности рисования расписаны в этой статье. Например вот разные варианты пунктира для разного варианта dashArray:

Почему-то много где приводят эти цифры, но мало где их объясняют. На деле это просто последовательность закрашенных и пустых пикселей. Т.е. [21, 9, 3, 9] означает "нарисуй 21 пиксель, затем пропусти 9, нарисуй 3, пропусти 9, начни с начала".

Тут тоже скрыт небольшой подводный камень. Вот вы задаете массив {10, 10} и ожидаете, что пунктир и пропуски у вас будут одинаковой длины... однако на изображении видите, что пунктир явно длиннее чем пустое место. Такое поведение может быть вызвано неправильным значением параметра endCap, который задает, как именно должны заканчиваться штрихи. Вариантов доступных три:

В итоге если задать что-то отличное от CAP_BUTT, к штриху добавится еще несколько пикселей на его окончание.

С помощью BasicStroke и dashArray можно рисовать различные сложные последовательности пунктира. И большинство туториалов заканчиваются фразой в духе "ну вот этого вам точно хватит на 99% случаев рисования, поэтому углубляться дальше не будем".

Ну а вот мы попали в тот самый 1 процент. Нам вдоль пути надо рисовать не только пунктир, но еще и различные символы в разрывах. В том самом 10 приказе встречаются самые разные варианты: кружочки, крестики, полоски в одну сторону от линии, полоски в обе стороны от линии, какие-то повернутые галочки и чего еще только нет. В общем, нам нужен Stroke, который умеет рисовать не только линию. но и произвольный Shape раз в N пикселей. Повернутый на правильный угол.

Как устроены классы Stroke

Реализуем свой собственный класс, наследник Stroke. Этот интерфейс определяет единственный метод createStrokedShape. Суть в том, что в AWT по сути есть одна-единственная "настоящая" операция рисования - это заливка области, заданной Shape'ом. Рисование контуров тоже сделано через заливку.

Stroke берет геометрию и превращает ее в фигуру, площадь которой надо залить. Т.е. в простейшем случае линия превращается в многоугольник нужной толщины, построенный вокруг нее, который потом заливается сплошным цветом. И наш метод должен брать геометрический объект (в нашем случае - линию границы ЗОУИТ) и возвращать Shape, который можно будет залить нужным цветом и получить итоговый контур.

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

Несколько неплохих примеров есть в статье http://www.java2s.com/Code/Java/2D-Graphics-GUI/CustomStrokes.htm. Обратите внимание на занятный вид штриха, искажающего форму объекта и имитирующего "рукописные кривули":

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

  1. Нарисуем наш Shape стандартным BasicStroke с нужным типом пунктира

  2. Добавим к этому Shape наши выступающие кусочки и всякие загогулины

  3. Вернем комбинированный Shape

Под катом полный код метода createStrokedShape()

Spoiler
override fun createStrokedShape(shape: Shape): Shape {
        // По сути копия исходного шейпа, которую мы будем рисовать чтобы знать последнюю точку
        val newshape = GeneralPath() 
        // Тут мы будем хранить все вставленные нами дополнительные шейпы
        val innerShape = GeneralPath()

        val i: PathIterator = shape.getPathIterator(null)
        val coords = FloatArray(6)
        // Аккумулятор расстояния с момента последней вставки нашего дополнительного шейпа
        var pixels = 0.0
        // Пройдем по сегментам переданного нам Shape
        while (!i.isDone()) {
            val type: Int = i.currentSegment(coords)
            when (type) {
                // Пустой сегмент - ничего не делаем
                PathIterator.SEG_MOVETO -> {
                    newshape.moveTo(coords.get(0), coords.get(1))
                }
                PathIterator.SEG_LINETO -> {
                    // Вычислим длину сегмента
                    val startX = newshape.currentPoint.x
                    val startY = newshape.currentPoint.y
                    val endX = coords.get(0).toDouble()
                    val endY = coords.get(1).toDouble()

                    val line = Line2D.Double(startX, startY, endX, endY)
                    val segmentLength = Math.sqrt(Math.pow(endX - startX, 2.0) + Math.pow(endY - startY, 2.0))
                    // Если сегмент короче чем расстояние между вставками шейпа - ничего не делаем
                    if (pixels + segmentLength < step) {
                        pixels += segmentLength
                    } else {
                        // Накопилось достаточное расстояние с момента вставки предыдущего шейпа, вставми новую копию
                        var offset = Math.max(0.0, step - pixels)

                        while (offset < segmentLength) {
                            // Вычисляем точку на текущем сегменте, в котороую надо вставить шейп
                            val stepScale = offset / segmentLength

                            val stepTransform2 = AffineTransform.getTranslateInstance(startX, startY)
                            stepTransform2.scale(stepScale, stepScale)
                            stepTransform2.translate(-startX, -startY)
                            val stepLine = stepTransform2.createTransformedShape(line) as Path2D.Double

                            val lastPoint = getLastPoint(stepLine)
                            // Берем наш шейп, поворачиваем и перемещаем его в эту точку
                            val transformed = generateShapeForInsertion(line, lastPoint!!)
                            // Добавляем шейп к результирующему шейпу
                            innerShape.append(transformed, false)

                            offset += step
                        }
                        pixels = segmentLength - offset + step
                    }


                    newshape.lineTo(coords.get(0), coords.get(1))
                }
                PathIterator.SEG_QUADTO -> {
                    newshape.quadTo(coords.get(0), coords.get(1), coords.get(2), coords.get(3))
                }
                PathIterator.SEG_CUBICTO -> {
                    newshape.curveTo(coords.get(0), coords.get(1), coords.get(2), coords.get(3),
                        coords.get(4), coords.get(5))
                }
                PathIterator.SEG_CLOSE -> newshape.closePath()
            }
            i.next()
        }

        // Составим результат из newShape (сейчас содержащего по сути копию входного шейпа)
        // и добавленных нами шейпов вдоль него
        val rz = Area(mainStroke.createStrokedShape(newshape))
        rz.add(Area(sideStroke.createStrokedShape(innerShape)))
        return rz
}

Начинаем мы с двух пустых шейпов. В один будем складывать копию входного шейпа (наверное можно было бы обойтись и без нее и как-то вытаскивать координаты из итератора), во вторую будем складывать добавленные нами шейпы, которые будем рисовать вдоль линии.

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

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

Полный код класса можно увидеть в нашем репозитории в GitHub

В итоге можно рендерить всякие вот такие вот линии, которые очень нравятся чиновникам и архитекторам:

Источник: https://habr.com/ru/post/524588/


Интересные статьи

Интересные статьи

Всем привет! Меня зовут Слава Фомин, я ведущий разработчик в компании DomClick. За свою 16-ти летнюю практику я в первых рядах наблюдал за становлением и развитием JavaScript как ст...
Доброго времени суток! Как известно, одной из характерных черт JavaScript, наряду c мультипарадигменностью, слабой (динамической) типизацией, автоматическим управлением памятью и ...
Много лет назад, я пришел в один legacy-проект, который разрабатывал Владимир Филонов (pyhoster). Так я и познакомился с одним из организаторов MoscowPython, любителем копаться во внутренностях б...
Это короткая, но достаточно полезная статья для продолжающих разработчиков о итераторах в Javascript.
Хороший сервис для заказа такси должен быть безопасным, надёжным и быстрым. Пользователь не станет вдаваться в детали: ему важно, чтобы он нажал кнопку «Заказать» и как можно быстрее получил ...