- name
- blockwright-draw
- description
- Этапы 4 и 5 Blockwright — чертёж здания в build.py. Используй при написании или правке рецепта постройки: маски, Frame.region, эрозия и дилатация, Canvas, Facade, оконные проёмы, лестничное правило 1.41, словарь блоков, плиты и панели.
# Чертёж — этапы 4 и 5
Каждый объём пишется неравенствами в `(u, v)` и растеризуется `Frame.region`,
который спрашивает предикат про центр каждой мировой клетки. Фигура, записанная
как `0 <= u < 105 and 4 <= v < 13`, выходит маской под углом здания, и ступенька
падает туда, куда её кладёт геометрия.
**Угол обрабатывается ровно один раз — в растеризаторе.** Ни один примитив ничего
не поворачивает. Это касается и деталей: лестница или панель кладутся на ту же
машинерию масок, что и стена, и угол не выводится заново. Секция, которая считает
свои синус и косинус, — это секция, которая разойдётся с остальными на полградуса
после того, как карту перерисуют.
## Всё — это двумерная маска, поднятая через диапазон высот
| Приём | Операция |
|---|---|
| кольцо стены | пятно минус его эрозия |
| балкон за плитой | `erode(вынос)` |
| верхний объём с отступом | `erode(расстояние)` |
| выступающий карниз | `dilate(вынос).outline(...)` |
| двор | план минус крылья |
| терраса на участке | `dilate(d1) - dilate(d0)` |
## Две детали, каждая ценой в пересборку
**Смещения обязаны быть евклидовыми.** Отступ в 3 должен означать три метра в
любом направлении. Чебышёвское или chamfer-смещение промахивается на 40 % вдоль
фасада под 52°.
**Однослойное кольцо не герметично под углом.** Чтобы удержать воду вдоль стены
под углом θ, нужно `|cos θ| + |sin θ| ≈ 1.41` блока. Во внутреннем углу каждой
ступеньки ближайшая свободная клетка — диагональная, поэтому метрическая полоса в
один блок этот угол пропускает — около пятой части стены. `Mask.outline`
объединяет полосу с `rim()`, множеством клеток, у которых свободен любой из восьми
соседей, и тем закрывает их по построению.
## Лестничное правило
Те же 1.41 задают нижнюю границу толщины элемента. Ферма шириной в блок,
проведённая вдоль оси здания, растеризуется в пунктирную лестницу. **1.5 м —
самый узкий непрерывный элемент** при таком угле.
Число измеряется, а не вписывается: `derived["frame"]["staircase"]`. Перед тем как
чертить что-то узкое, сверься с ним и **останови сборку**, а не рисуй:
```python
if width < site.staircase:
raise SystemExit(
f"элемент шириной {width:.1f} м уже, чем ступенька рамы "
f"{site.staircase:.2f} м; он растеризуется в клетки, соединённые "
"только углами")
```
## Решётка: чем стена отличается от вектора
Рама сидит на наклоне `p:q`, у которого мотив — не длиннее трёх пробегов
(`derived["frame"]["slope"]`). Из этого следуют две вещи, и обе надо держать в
голове, когда чертишь.
**Лестница периодична.** Пробеги идут `2,1,1 | 2,1,1 | 2,1,1`, а не как попало.
Это и есть разница между «нарисованной линией» и «вокселизованным вектором», и
она бесплатна: цена снапа — доли градуса, измеренные и напечатанные.
**Повтор возможен.** `site.period` — метры от клетки мотива до той же клетки
следующего; `site.periods(метры)` — во сколько периодов уложился измеренный
питч. Шаг секции обязан быть целым числом периодов, иначе копия не копия.
Измеренные 20.6 м становятся 20.0 м = 4 периода по 5.0 — это решение, и печатать
его надо рядом с тем, что измерено.
Круг рисуется по мировой решётке, а не по раме (`Mask.circle`): у круга нет
угла, а есть симметрия, и рама её ломает. Грани, идущие не по осям рамы —
скошенный торец, разведённое крыло, — садятся каждая на свою дробь в
`Site.faceted`.
## Декларируй то, что положил
`sched.declare(name, mask, y0, y1)` берёт маску **того, куда легли блоки**, а не
входной контур. Объявишь перекрытие на весь контур — его удержит кольцо стены,
стоящее в тех же клетках, и в день, когда плиты перестанут класться, гейт этого
не заметит. Плита — `foot.erode(THICK)`, стена — `foot.outline(THICK)`, проёмы —
`build.openings(foot, face, period, width, THICK)`.
И назови материал в манифесте: `Item(..., blocks=GLASS)`. Без этого объявление
удовлетворяется чем угодно не-воздушным в своей маске — например, той самой
стеной, в которую прорезан проём.
## Полблока припуска — только для плана с карты
`site.footprint(name)` открывает часть на `site.pad` во все стороны, и это число
зависит от того, чем план измерен. Карта рисует стену линией, а `plan.decompose`
эту линию вычитает — значит, часть с карты кончается на полблока внутри
настоящей грани, и припуск возвращает её на место. Часть, снятая с модели или с
захвата, **уже** является внешней гранью, и повторный припуск строит здание на
полметра больше с каждой стороны.
В самой постройке этого не видно, зато слышно на гейте: абсолютный припуск
раздувает узкую ось в большей доле, чем длинную, поэтому регистрация возвращается
с разъехавшимися масштабами по u и v. Шаблон выбирает припуск сам, по
`derived["plan"]["by"]`; трогать его руками нужно только если знаешь, почему.
## Пролёты — по длине дуги, а не по u
Постоянный шаг по `u` покрывает разную длину стены везде, где фасад не прямой,
поэтому оконные пролёты уходят из ритма на повороте. `Facade` обходит границу
пятна и даёт каждой клетке стены её расстояние вдоль стены.
Обведённый контур сглаживается перед измерением: цифровая лестница завышает
собственную длину примерно на 5 %, а это восемь метров накопленной ошибки фазы на
здании такой длины.
## Полое, а не сплошное
Заполненный объём — это объёмный этюд: он проходит сечение, читается в каждом
рендере глыбой камня и в него нельзя войти. `build.walls` даёт кольцо стены,
герметичное по построению; `build.storeys` повторяет его с плитой на каждом
уровне.
Плиты перекрытий по умолчанию заводятся за стену. Если пустить их до края пятна,
на фасаде появится полоса материала перекрытия на каждом уровне, и при метре на
блок между полосами не остаётся сплошной стены — здание читается стопкой плит.
## Ровность: чем жертвовать и чем нет
Пара градусов расхождения с референсом ничего не стоит. Пила в блок стоит всей
кромки — это первое, что видит человек рядом с фотографией, и **ни одна другая
строка гейта её не видит**: зубчатая кромка меряется так же на каждой станции,
охватывает ту же площадь и даёт тот же силуэт, что прямая.
Пять правил. Каждое — про то, что здание должно быть ровнее эталона, а не
точнее его.
**Прямая линия — одна линия.** Стена, отклоняющаяся от прямой в раме меньше чем
на блок, выпрямляется. Карта рисована от руки, её кромка гуляет на клетку-две, и
это дрожь руки, а не форма.
Пять идиом, и последняя — та, которая нужна чаще всего:
| | что делает | когда |
|---|---|---|
| `site.footprint(name)` | маска как нарисована | форма плана несёт измерение: скос, изгиб, диагональный ряд |
| `site.box(name)` | прямоугольник по габариту | часть действительно прямоугольная, а кромка — шум |
| `site.squared(name)` | прямоугольник по робастным квантилям | то же, но кромку рвут выбросы, и габарит по ним врёт |
| `site.faceted(name)` | многоугольник из фитованных прямых | у части есть скошенная или разведённая грань: её направление — измерение, и брать его надо линией, а не парой крайних точек. Углы выходят пересечениями граней и прямые по построению; при меньше чем трёх гранях отказывает |
| `STRAIGHT = 1.0` на `Site` | углы сохраняются, дрожь убирается | **почти всегда**. Контур читается в раме, упрощается Дугласом–Пекером, растеризуется заново |
Скос в 8° переживает выпрямление при любом допуске: скос — это угол, а не
дрожь. `checks.jaggedness` печатает и долю дрожи, и **из скольких прямых
отрезков состоит контур** — прямоугольник, нарисованный ровно, приходит пятью.
Пятьдесят три — это пятьдесят три, и никакой допуск не сделает из этого
прямоугольник.
**Горизонталь — одна отметка**, пока обмер не докажет ступень. Плоская крыша,
нарезанная по станциям, превращает каждое качание фотограмметрии через границу
блока в ступень; `checks.level_runs` считает, сколько разных верхних отметок у
того, что обязано быть одной.
**Углы — 0 или 90 к раме, или дуга.** Промежуточный угол *внутри* одной части
почти всегда шум обмера. Угол здания живёт в раме и обрабатывается ровно один
раз — в растеризаторе.
**Симметричная часть строится из половины.** См. ниже.
**Повторяющийся элемент ставится один раз и копируется.** Тоже ниже.
Что гейт спрашивает: `<часть> is drawn straight` (бюджет `JAGGED`, по умолчанию
15% и его стоит опускать на прошедшую сборку), `<часть> is level` (`LEVEL`),
`<a> mirrors <b>` (`TWINS`).
## То, что повторяется, — рисуется один раз
Здание, у которого две одинаковые башни, восемь одинаковых вилл или семь
одинаковых этажей, **не должно выводить каждую копию из чисел заново**. Числа
шумят, и повторяющееся совпадает только пока совпадают числа.
Это не теория. У одного отеля две башни-близнеца мерились по отдельности,
захват моделирует одну хуже другой, и в постройку уехала разница: пять ступеней
кровли против девяти, расхождение до 5 м там, где у настоящих крыш один уровень.
Все проверки при этом зелёные — две башни одного пятна дают один силуэт и один
профиль, а сечение судит каждую по своей половине эталона.
Три примитива, каждый на своё:
**`site.flipped(axis_u)`** — тот же участок, но всё рисуется зеркально по u.
Напиши секцию один раз, вызови дважды. Копии совпадают по построению, а не по
согласию двух наборов чисел.
**`canvas.repeat(y0, y1, step, count, mask)`** — скопировать курсы вверх. Этаж
чертится один раз и штампуется; цикл, перерисовывающий каждый этаж из тех же
чисел, одинаков ровно до первого уровня, который округлится иначе.
Шаг у штампа один, и это обязано остаться так. Когда `count × step` не сходится с
измеренной высотой, у многоэтажки меняют `count`, а не режут отдельные этажи до
двух блоков или одного: этажом выше или ниже — никто не заметит, этаж в один блок
посреди фасада — первое, что видно на рендере, и ни одна строка гейта его не
ловит. Подробности и граница, ниже которой правило не работает, — в этапе 3.
**`site.stamp(canvas, motif, y0, y1, periods, count)`** — скопировать секцию
вдоль здания. Это горизонтальный аналог `repeat`, и работает он потому, что рама
сидит на решёточном наклоне: собственная ось здания там — целый вектор, поэтому
сдвиг на `periods × period` переносит блоки клетка-в-клетку. Копируется и воздух
внутри мотива — прорезанное в первой секции окно есть часть того, чего вторая
копия является копией, — поэтому `[y0, y1)` обязан быть тем объёмом, который
мотив реально занимает.
Шаг считается, а не печатается: `site.periods(20.6)` переводит измеренный питч в
целое число периодов, `site.pitch(20.6)` говорит, во что он при этом округлился,
`site.step(n)` даёт вектор. Проёмы — `site.openings(...)`, то же самое для
маски: одна фигура, отштампованная вдоль стены, вместо `Facade.bays`, который
разносит проёмы по длине дуги и сажает каждый в свою субпиксельную фазу.
`Facade.bays` остаётся правильным на кривой стене, где никакого переносящего
вектора не существует вовсе.
**`canvas.place(motif, y0, y1, corner, turn, mirror)`** — та же копия, но куда
угодно и с поворотом. Для здания, которого несколько: двор одинаковых коттеджей,
пара павильонов, сторожка в обоих концах подъезда. Повороты только кратные 90° и
зеркала только по осям мира — это вся группа, сохраняющая решётку. Состояние
блока поворачивается вместе с геометрией (`blocks.turned`), иначе у копии
лестницы смотрят так же, как у оригинала, и это дефект, которого не читает ни
одна строка: все блоки на месте, все нужного рода, все в нужных клетках.
**`measure.twins({имя: профиль})`** — сказать, насколько две копии разошлись в
обмере. Он **не решает**: усреднить чистое чтение с шумным — это третье чтение,
не совпадающее ни с одним, и сечение завалит прогоны станций уже на обеих
копиях. Расхождение означает, что одну копию эталон моделирует хуже, и это
предложение в отчёт, а не число для усреднения.
**Зеркалить под произвольным углом надо предикат, а не воксели.** Рама стоит под
углом к сетке мира; отражение вокселей — это ресемплинг, потому что центры
клеток не ложатся в центры клеток. Прямой сплэт даёт дырки (а дырка под блоком —
это висящий блок, бюджет `FLOATING_BLOCKS = 0`), обратная выборка
перерастеризует каждую кромку поверх уже запечённой лестницы, и ни то ни другое
не переживает `finalize`: состояния `north/east/south/west` привязаны к мировым
сторонам. Без потерь только три вещи: сдвиг на целый вектор, поворот кратный 90°
и зеркало по оси мира. Всё остальное — предикатом.
Гейт спрашивает про это тремя строками. `evenness` — таблицы `TWINS` и `LEVEL`
про зеркальные пары. `<часть> repeats` — про повтор сдвигом: заявляется
`Item(..., copies=N)` в манифесте и `sched.declare_repeat(...)` рядом со
штампом, и гейт сверяет блоки копий побайтово. Пустые обе — `ungraded`, а не
молчание, ровно как пустой `COUNTS`; закрываются `SINGULAR` и `UNREPEATED` с
причиной.
## Здание имеет право быть двумя разными зданиями
Самый дорогой класс дефекта на сегодня, и он невидим целиком. Здание построили с
лоджиями по всем трём крыльям — у правого их нет. Надстройку на кровле построили
одной глухой коробкой — там коробка и открытая рама с кондиционерами. Все строки
гейта были зелёные, и не по снисходительности:
| проверка | почему молчит |
|---|---|
| сечение | судит скайлайн, у фасада скайлайна нет |
| силуэт, `COUNTS` | три крыла одного пятна дают один силуэт |
| аудит манифеста | лоджии объявлены и стоят — в тех клетках, в том блоке |
| `level` | склеенная коробка даёт на отметку **меньше**, а не больше |
| `TWINS` | спрашивает обратное: «ты сказал, что одинаковы — правда?» |
**`Facade(foot)` обводит весь контур, и `openings` режет по всей его длине одним
шагом.** Ветвления по граням нет. Ограничить можно пересечением — `& site.slice_of(...)`
или `& site.rect(...)`, — но об этом надо додуматься, а обмер не подсказывал.
**Мерь ритм по пролётам, а не по четырём орто.** Четыре орто — это
север-юг-восток-запад **захвата**; крылья идут вдоль u, и ни одно из четырёх
чтений не соответствует одному крылу.
```python
found = measure.rhythm_by_span(image, scale, spans, *BAY_RANGE)
groups = measure.apart([r["value"] for r in found]) # индексы, а не значения
```
`apart` возвращает индексы, потому что сказать надо, *какое* крыло разошлось.
**Меньшинство, которое не согласно, — это находка.** `texture bay: 11.00 m, 2 of
4 elevations agree` при чтениях 10.99 / 8.48 / 14.50 / 11.01 — это не слабое
измерение одного ритма, а твёрдое измерение двух. Строка теперь называет
несогласных поимённо; прочитать её как «два согласны, берём 11.0» — это и есть
тот дефект.
**Строка гейта `facades`.** Пустая таблица — `ungraded`, как `COUNTS` и `TWINS`:
```python
FACADES = {"west wing": (1.7, 26.4), "east wing": (81.1, 110.5)}
FACADES_DIFFER = (("east wing", "west wing"),)
# либо
UNIFORM = True # эталон правда рисует один фасад по кругу — но сходи посмотри
```
Разница считается по **плоскости** стены вместе с воздухом. Именно вместе:
лоджия — это дыра, а доли одних оставшихся блоков у прорезанного крыла и у
глухого почти совпадают, потому что вырезанное ушло и из числителя, и из
знаменателя.
**На крышу ставится не только коробка.** Ряд кондиционеров стоит на стальной
этажерке: под ней видно, между аппаратами видно небо, силуэт — гребёнка, а не
крышка. `build.rack(canvas, foot, posts, base, top, post, deck=..., units=...)`;
стойки — `site.grid(foot, pitch)`, потому что сетка должна лежать в раме здания,
а не в мировой.
## Словарь блоков
При масштабе 1:1 полный куб — это метр, а очень много архитектуры мельче:
балюстрада, карниз, импост, ступень. Некубические блоки — способ это построить, и
правило, которое делает их безопасными, стоит проговорить:
**Некубичность — свойство записи, а не геометрии.** Маски остаются булевыми.
`Frame.region` остаётся единственным местом, где живёт угол. Морфология не
трогается. Меняется то, какую строку блока получает клетка, а это свойство
клетки, а не фигуры.
Отсюда три следствия:
1. **Позиционная запись.** `Canvas.fill` берёт одну строку блока на всю маску. То,
у чего состояние меняется от клетки к клетке — ступени лицом наружу, панели,
соединяющиеся с соседями, — требует `Canvas.set` и функции, считающей строку
на клетку.
2. **Направление, по которому меняться.** Ступени нужно знать, где наружу. Три
источника этого в библиотеке уже есть, и все три ответ выбрасывают: `Mask.rim`
находит свободного соседа и теряет, какого именно; `Facade` обводит контур и
оставляет от него только длину дуги; `Mask.distance_field` держит нормаль в
своём градиенте.
3. **Состояния соединения.** WorldEdit вставляет схематику без обновлений блоков,
поэтому панель, забор или стена, записанные состоянием по умолчанию, приезжают
столбиком, а не полотном. Это вообще не выражается поклеточно — зависит от
соседей — и требует одного прохода по готовому холсту перед записью.
`canvas.finalize()` в `finish` делает его первым и ровно один раз.
Два мелких правила, которые уже стоили денег:
- **Листве нужен `persistent=true`.** Без него Minecraft разрушает всякий лист
дальше шести блоков от бревна, то есть большую часть кроны пальмы, и посадки
тихо исчезают через пару минут после вставки.
- **Половинные высоты живут в `type=top|bottom`.** Квантование измеренной плиты на
1.25 м к y = 1 выбрасывает полметра, которые состояние могло бы нести. Ось y
остаётся целой: плита на 1.25 м — это нижняя плита в клетке 1, а не дробный y.
## Палитра
Каждая строка должна быть в `blocks.COLORS` или производной от неё, иначе рендер
выйдет пурпурным — и это проверяется, а не предполагается: `checks.palette`
одна из строк гейта.
**Сначала совпадает светлота, потом тон.** Блок на сотню уровней темнее читается
другим материалом, даже когда цвет номинально верен, и фото-ревью это назовёт;
блок правильной светлоты с потерянным оттенком читается тем же материалом при
другом освещении, и этого никто не замечает.
## Горизонталь шумит, вертикаль чистая
Единственный приём в этой библиотеке, **замеренный** на готовом городе, а не
кем-то посоветованный. На чужой карте Лос-Сантоса три пятых всей текстуры плоских
кровель — одна пара блоков в двадцати единицах RGB друг от друга, положенная
честной монеткой 50/50: не шахматка, не полосы, не пятна. Фасады при этом не
шумят вовсе — 16.6 % против 50 %.
Та же пара статистик по нашим шести постройкам: фасад 0.10–0.22 (в такте),
горизонталь **0.015–0.067** — в десять-тридцать раз площе. Каждая палуба,
терраса и мощение у нас — один печатный прямоугольник одного блока.
```python
DECK = "minecraft:light_gray_concrete"
DECK_MIX = "minecraft:polished_andesite" # на 20.5 RGB в стороне
build.dither(canvas, foot, top - 3, DECK, DECK_MIX, seed=3)
```
Четыре вещи, каждая нарочно:
**Дизер пишет один курс.** Не диапазон. Поэтому им нельзя задизерить стену — то
же самое, чем `slab` отказывается класть куб на полувысоту. Правило, которое
держит форма сигнатуры, не протухает вместе с дисциплиной.
**Пару называешь ты.** `blocks.why_pair(DECK)` печатает кандидатов с числами и
на этом останавливается — как `nearest`/`why_not` для цвета. Цвет — всё, что
таблица видит: `brown_concrete` и `jungle_log` в двенадцати единицах друг от
друга, и ни одна крыша не сделана из обоих. Бери тот, который **того же
материала**, и объяви обоих в манифесте: `blocks=(DECK, DECK_MIX)`.
**Двадцать единиц — это параметр, а не допуск.** Пара в 6 единиц
(`diorite`+`polished_diorite`) — другой приём, невидимый; пара в 185
(`quartz_block`+`stone`) — третий, крапина. Полоса 8–32 в
`blocks.DITHER_NEAR/FAR` — края того приёма, который замерен.
**Ячейки выбираются монеткой, а не модулем.** `mask.speckle(share, seed)`.
`(x * 7 + z * 3) % 11 < 4` выглядит как рассев и является решёткой: повторяется
по диагонали в 82 % случаев, то есть кладёт диагональные полосы. Так было
написано в пяти местах двух зданий, и ни одна строка гейта этого не видит —
полоса даёт тот же силуэт, ту же площадь и то же сечение, что монетка.
Гейт печатает `texture` на каждом прогоне и **ничего по нему не решает**.
Правильного значения нет: фотографии этого здания старше любого числа с чужой
карты. Колонка `ref` — контекст.
И обратной силы у этих чисел почти нет. Число про монетку вправе отклонить
находку ревью **про монетку** («шум неровный» — 0.50 по шахматке). Находка
«крыша не читается» — про читаемость, и над ней оно не начальник.
## Как устроен рецепт
Одна функция на часть, каждая берёт `(canvas, site, sched)` и объявляет то, что
поставила. Держать их раздельно — это то, что позволяет ответить на находку ревью
переписыванием одной из них.
`Site` собирается один раз и передаётся в каждую секцию, поэтому есть одно место,
где измерение превращается в координату, и ни одна секция не может тихо разойтись
с другой в том, где стоит стена. Уровни округляются до целых блоков там и больше
нигде: уровень, дотащенный как 5.75 до заливки, превратится в двух секциях в два
разных целых, и перекрытие уедет на полметра от стены, которая его несёт.
View on GitHub