- name
- blockwright-measure
- description
- Этап 2 Blockwright — обмер плана: по векторному плану (vectorplan.read), по карте layout.png, по 3D-модели или захвату (model.read), либо по объявленной таблице (declared.layout). Используй, когда нужно получить раму здания (угол, начало, протяжённость), разложить план на части, найти участок внутри дорожного кольца, или когда что-то не так с Frame.fit_mask, plan.decompose, model.read, declared.layout, flatmap.parcel, flatmap.guides.
# Этап 2 — обмер плана
На вопрос плана отвечает сильнейший из положенных входов, и от этого зависит,
кто **рисует** план — какую из четырёх читалок вызвать. Нарисованные части
остаются; эталон добавляет те, которых рисунок не держал. После сборки — те же
именованные части в тех же `(u, v)`, тот же `build.py`, тот же гейт.
| кто ответил | чем читается |
|---|---|
| векторный план | `vectorplan.read` — полигон уже контур |
| карта `layout.png` | `plan.decompose` — по нарисованным линиям |
| 3D-модель или захват | `model.read` — по ступеням крыши |
| описание, эскиз, чертёж | `declared.layout` — таблица с указанием источника |
Кто именно отвечает при ничьей — не «кого объявили позже», а явный список
`sources.PREFER`, по строке на вопрос: на план вектор бьёт модель, модель бьёт
карту, карта бьёт чертёж; на высоты модель бьёт чертёж, чертёж бьёт захват.
`probes/derive.py` выбирает, кто рисует, через `sources.survey`, собирает план
в `Survey.assemble` и печатает и ветку, и таблицу покрытия. Скрипт сборки берёт
готовое через `derive.plan_of()` и не знает, откуда оно взялось, — иначе два
файла однажды прочтут план по-разному.
## Ветка вектора
`vectorplan.read` растеризует полигоны GeoJSON или пути SVG на метровую сетку по
правилу «центр ячейки внутри» — то же правило, что у `Frame.region`, поэтому
вектор и фигура, начерченная в (u, v), ложатся в одни клетки.
Долгота и широта распознаются и проецируются локально; проецированный экспорт
уже в метрах; у SVG единиц нет, и `VECTOR_SCALE` заявляется как `MODEL_SCALE` у
OBJ. Кривые не аппроксимируются — путь с безье экспортируют с флаттеном, иначе
углы здания придумает читалка.
Части приезжают именованными: одна фигура на часть, `name` из properties — имя
части. Единственная ветка, которой не нужен `STRIPS`.
## Ветка карты
**Сначала палитра.** Какие оттенки серого на этой карте означают здание, линию и
дорогу, заявляет здание в `MAP_PALETTE`; по умолчанию стоит палитра одной
конкретной карты. Кроп из другой игры, экспорт OSM, скан чёрным по белому — своя.
```sh
python -m tools.map_probe buildings/<имя>/input/layout.png
```
печатает гистограмму серых и предлагает пороги. Если палитра не подходит,
`footprint` останавливается и показывает ту же гистограмму — он не возвращает
пустую маску и не находит «что-нибудь».
Карта измеряется **один раз** — в раму и набор частей — и после этого
откладывается. Геометрия чертится потом, чистыми фигурами по измеренным числам.
Причина: карта — рисунок, а не съёмка. Её рёбра гуляют на метр-другой, внутренние
линии проведены от руки. Трассировка по клеткам наследует каждую дрожь, и после
трассировки уже никакой шаг не отличит намеренную ступеньку в плане от нетвёрдой
руки.
## Рама
`Frame.fit_mask` вписывает **минимальный по площади** ориентированный
прямоугольник в пятно карты и возвращает раму: начало, угол и локальные
координаты `(u, v)` — вдоль здания и поперёк.
**Минимальный прямоугольник, а не PCA.** PCA взвешивает по массе, поэтому длинное
крыло с тяжёлым отростком на конце утаскивает ось. На реальном примере PCA дал
57.2° против истинных 52.48° — пять градусов, а это на здании длиной 165 м
четырнадцать метров ошибки на дальнем конце. Минимальный прямоугольник получается
вращающимися штангенциркулями по выпуклой оболочке и точен.
**Дальше рама садится на решётку.** `Survey.on_a_lattice` поворачивает её на
ближайший наклон `p:q`, у которого мотив лестницы не длиннее трёх пробегов, — на
одной точке для всех четырёх веток, потому что `build.py` и `gate.py` тоже
зовут `plan_of`, и рама, повернувшаяся в обмере и не повернувшаяся в сборке, —
это два здания. Части перемеряются в новой раме; **маски, которые нарисовала
карта, не двигаются** — поворачивается чертёжная доска, а не чертёж.
Цель — кратчайший период, ограничение — цена в метрах на торцах. Здание
объявляет `LATTICE` («auto» или пару), `LATTICE_BUDGET`, `LATTICE_PITCH`, а
`UNLATTICED` — причину, по которой решётки нет вовсе. Цена печатается на каждом
прогоне и лежит в `derived["frame"]["slope"]`.
Рама эталона поворачивается на **ту же дельту** (`survey.with_the_plan`): обе
стороны регистрации обязаны мериться вдоль одних направлений, иначе разница
вылезает как расхождение масштабов по осям и прогон останавливается сообщением
про подгонку, которая ни при чём.
## Части
`flatmap.footprint` сливает нарисованные линии с массой и возвращает одну кляксу.
Это правильный вход для вопроса «где стоит здание и куда смотрит» и неправильный
для всего дальнейшего: у кляксы нет комнат, поэтому постройка выйдет одним
выдавленным контуром, а части, которые картограф нарисовал, потеряются раньше,
чем будет положен первый блок.
`plan.decompose` вместо этого **вычитает слой линий из массы**. Остаются
замкнутые области, каждая со своей маской, своей протяжённостью в `(u, v)` и своей
классификацией.
**Классификация — вписанной окружностью, а не изопериметрическим отношением
`4πA/P²`.** Обход контура засчитывает один шаг за диагональ и потому недомеряет
периметр фигуры, нарисованной под 52°, из-за чего отношение объявляет
прямоугольник довольно круглым. Подгонка окружности не зависит от масштаба и сама
сообщает свою невязку — вот это число и стоит доверия.
Именно это позволяет скрипту постройки говорить «круглая» и «длинная» вместо
координат, которые человек списал с картинки.
## Ветка модели и захвата
`model.read` берёт всё, что стоит выше двух метров над землёй, роняет в сетку метр
на метр, берёт высшую точку в каждой ячейке и заливает внутренние дыры — модель
это поверхность, внутри контура у неё вершин нет. Двор, открытый небу, тоже
зальётся, и так и надо: план здания с двором включает двор, а что внутри него
происходит — решение сборки, а не потеря на обмере.
**Части берутся со ступеней крыши.** Высоты кластеризуются по разрывам, не по
корзинам: крылья на 11.8 и 12.1 — одна площадка при любой сетке корзин, а на 11.9
и 15.9 — две, хотя четырёхметровая корзина сложила бы их вместе. Каждая площадка
бьётся на связные области, мелкие остатки раздаются ближайшей части, чтобы в плане
не осталось дыр.
**Берутся все куски, а не самый большой.** Здание с отдельным крылом через двор —
это два куска одного здания. Опора — на то, что файл клипнут по этому зданию;
сосед, оставшийся в клипе, приедет ещё одним куском, и его поймает проверка
регистрации в гейте.
**Масштаб заявляется, а не угадывается.** OBJ не несёт единиц, и `model.read`
останавливается на абсурдном габарите вместо того, чтобы домножить на подходящее
число. Один угаданный коэффициент проходит через каждый размер и ничем потом не
ловится: постройка будет идеально согласована сама с собой, только не того
размера. Ось «вверх» — там же, `MODEL_UP`: половина экспортёров пишет Z-up, и в
файле об этом ничего не сказано.
Модель авторитетна для формы, захват — нет. В геометрии эта разница не видна, её
помнит `sources.py`. Тянуть кривую из захвата и называть её ротондой — ровно та
ошибка, ради предотвращения которой построен весь пайплайн.
## Ветка объявлений
`declared.layout` растеризует таблицу `DECLARED_PLAN` — прямоугольники и круги,
которые человек напечатал, прочитав бриф или лист. У каждой фигуры обязателен
`source`: без него функция останавливается, а не предупреждает. Объявленное число
без источника нельзя ни перечитать, ни оспорить, и оно неотличимо от того, которое
кто-то неверно вспомнил.
Угол по умолчанию нулевой. Объявленному плану не в чем стоять: уличной сетки нет,
а рисование по осям убирает растеризационную лестницу из постройки, у которой
размеры и так самое мягкое место. Угол задаётся, только если его что-то
фиксирует.
## Сборка с эталона
Нарисованный источник оставляет каждую часть, которую нарисовал. Эталон
добавляет те, которых там нет. Сопоставление — IoU на пороге `MATCH_FLOOR`.
Умолчание 0.30 нарочно свободное: контур карты и силуэт захвата одного здания
обычно сидят на 0.5–0.7, и всё, что делит меньше трети площади со всем
нарисованным, — другое здание.
Отпечаток эталона пересэмплируется **назад** на сетку плана через ту же
регистрацию, которой потом читают скайлайн: для каждой клетки плана — что
эталон держит там. Вперёд «кляксой» на фикстуре дырявил седьмую часть пятна
узором, который никто не прочтёт иначе как шум. `PART_LEAST_AREA` считает
клетки этой сетки, после пересэмплирования. Одна клетка — один метр.
Сборка отказывается, а не угадывает. Часть, от которой на холсте меньше
половины клеток, не принимают: постройка перерисовала бы обрезок как целое
здание. Если профиль ширины не отличается от своего переворота и рамы стоят
дальше двух градусов, прогон останавливается: иначе добавленная часть сядет
не на тот конец, а все строки ниже согласятся. Мелочь участка отсекается
`PART_LEAST_AREA` / `PART_LEAST_HEIGHT`, и число отброшенного печатается.
Первый прогон называет добавленное `capture-1`; `CAPTURE_PARTS` заполняют
после, как `STRIPS`.
## Участок
`flatmap.parcel` возвращает землю внутри дорожного кольца. Работает только с
картой: у модели и у описания нарисованной границы нет. Это и есть настоящая
единица работы: здание — один объект, стоящий на ней, а не вся задача.
**Порога серого, отделяющего дорогу от здания, не существует.** Нарисованная
дорога тёмно-серая с широкой сглаженной каймой, идущей от дорожных 82 до земляных
217 через все промежуточные значения — включая 176, то есть собственную заливку
здания. При любом пороге кайма сливает дороги и здания в одну кляксу, а заливка,
посеянная внутри, уходит за пределы картинки.
Однозначно только **ядро дороги**: ахроматичное и темнее всего остального на
карте. Оно тоньше, чем дорога выглядит, и это ничего не стоит — барьеру нужно быть
замкнутым, а не широким.
**Заливка идёт наружу от здания**, а не внутрь от границы, поэтому ответ — «земля,
на которой стоит это здание», а не «какая область оказалась больше».
Два действия, которые легко пропустить:
- **Сообщать о побеге.** Если заливка дошла до края кропа, кроп мал и участок
молча обрезан. `parcel` бросает исключение. Вернуть половину карты было бы хуже,
чем остановиться.
- **Не вписывать в него раму.** Участок ограничен кривыми дорогами. Ориентированный
прямоугольник его не описывает, и это не нужно: это маска в тех же мировых
координатах, что и всё остальное.
Дальше цветовой оттенок делит участок на воду, траву и голую землю. Это разбиение
**всего** участка, включая пятно здания, — то, что под зданием, это земля.
Вызывающему, которому нужна только открытая земля, следует вычесть план самому, а
не поручать это угадывать.
## Направляющие
`flatmap.guides` возвращает нарисованные внутренние линии. `decompose` их уже
использовал — части это то, что остаётся после их вычитания, — поэтому в
`derived.json` они записываются как подтверждение, а не как новость, и ради одного
случая: картограф провёл деление, которое разложение потом не смогло замкнуть в
часть.
## Что дальше
С рамой и частями на руках план меряется в `(u, v)`: где пятно уступает, какой
ширины каждый отрезок, где идут нарисованные направляющие. Это горсть чисел — и
строится по ним, а не по растру.
View on GitHub