Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру 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. Обратите внимание на занятный вид штриха, искажающего форму объекта и имитирующего "рукописные кривули":
Поскольку глупо было бы изобретать велосипед и делать рисование пунктиров самостоятельно, если это уже есть в стандартной библиотеке, сделаем так:
Нарисуем наш Shape стандартным BasicStroke с нужным типом пунктира
Добавим к этому Shape наши выступающие кусочки и всякие загогулины
Вернем комбинированный 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
В итоге можно рендерить всякие вот такие вот линии, которые очень нравятся чиновникам и архитекторам: