- name
- blockwright-schedule
- description
- Этап 3 Blockwright — обмер разреза (по модели, захвату или объявлениям) и манифест частей. Используй при работе с probes/derive.py, derived.json, measure.storey_height / skyline / silhouette / presence, DECLARED_HEIGHTS, DECLARED_STOREY, blockwright.schedule, Item, декларациями и объявлениями частей.
# Этап 3 — обмер разреза и запись того, что существует
Карта не даёт высот. Высоты приходят из 3D-модели, из захвата, из масштабного
чертежа — или объявляются словами.
`blockwright.measure` — библиотека, которая их читает, и правило в том, что она
запускается **при каждой сборке**, а не однажды и руками в прошлом.
`probes/derive.py` здания её вызывает, пишет результаты в `out/derived.json`, а
`build.py` отказывается стартовать без этого файла.
**Когда мерить нечем.** Ни модели, ни захвата — тогда высоты берутся из
`DECLARED_HEIGHTS`, по строке на часть, у каждой указан источник. Это законный
режим: кто-то описал здание абзацем, и здание построено. Незаконно отчитаться о
нём так же, как об измеренном, и гейт этого не сделает — он напишет `ungraded`.
**Когда ритма нет.** Автокорреляция на чистой модели с гладким фасадом не находит
ничего, и это не отказ поиска: коррелировать нечего. Тогда `DECLARED_STOREY` —
шаг этажа и источник. Порядок именно такой: сначала мерим, объявление —
запасной вариант, и `derived.json` пишет обе версии, чтобы было видно, какая
пошла в дело. Объявление, перебивающее настоящий замер, — это способ получить
высоты, которых кто-то ожидал, вместо тех, что есть.
## Ровный шаг важнее точного счёта этажей
Сосчитать этажи по фотографии можно. Из этого **не следует**, что ровно столько
этажей обязано влезть в измеренную высоту. При метре на блок шаг этажа целый, и
`H / N` целым получается редко.
Остаток раскидывают двумя способами, и один из них — брак:
- **Разнобой.** Часть этажей на 3 блока, часть на 2, один на 1. Здание перестаёт
быть многоэтажкой и становится стопкой полок разной толщины. Суммарная высота
та же, силуэт тот же, сечение по станциям то же, `storey height agree`
сравнивает с объявленным шагом одно число, а не разброс. Ловит это одна строка
— `<часть> stands at that storey`: она мерит период **построенной** стены
автокорреляцией и сверяет его с замеренным этажом. Полагаться на «плохую
корреляцию» тут нельзя: стена с шагом 3-2-3-3-2 коррелирует **хорошо**
(r≈0.7) — просто на периоде 5 или 8, а не 3. Отвечает именно период, счёт —
порог, ниже которого строка честно уходит в `ungraded`.
- **Смена счёта.** Шаг остаётся один на всё здание, `N = round(H / step)`, и
здание выходит на этаж выше или ниже задуманного. Остаток метра-двух уходит в
парапет, техэтаж или толщину кровли — туда, где он и есть в настоящем здании.
Правило: **у многоэтажки шаг этажа один, счёт — то, чем жертвуют.** Этажом больше
или меньше на четырнадцатиэтажке никто не заметит; этаж в один блок посреди
фасада заметят все.
Оговорка ровно одна, и она про масштаб здания. Работает это от шести-семи этажей
и выше, где один этаж — несколько процентов высоты. У двух- или трёхэтажного дома
этаж стоит трети силуэта: там счёт фиксирован, сходится за счёт шага, и если шаг
не сходится — значит, врёт измеренная высота, а не счёт.
Шаг применяется штампом — `canvas.repeat(y0, y1, step, count, mask)`, — и это не
просто удобство: цикл, который сам распределяет остаток по уровням, и есть тот
способ получить разнобой.
## Почему именно так
Раньше на здание держали двадцать девять пробников. Каждый печатал гистограмму,
человек считывал число и вписывал его в `build.py` с комментарием, называющим
пробник. Это измерение с обратным клапаном: когда карту перерисовали или меш
переобрезали, пробник сказал бы другое, а константа продолжала говорить старое.
Три дефекта из июльского ревью 2026 дошли до заказчика ровно через этот зазор.
## Политика каталога `probes/`
- Пробник, читающий **вход** (меш, карту), — это **провенанс**. Ему место в
`probes/derive.py`.
- Пробник, читающий **`out/`**, — это **проверка**. Ей место в `gate.py` или в
корзине.
Третьего не дано, и каталог `probes/` не должен превращаться в кладбище.
## Что меряется
| Что | Чем | Подвох |
|---|---|---|
| высота этажа | `measure.storey_height` | автокорреляция гистограммы высот вершин по фасадам: плиты балконов и перемычки окон дают куда больше вершин на уровне перекрытия, чем на стене между ними, поэтому шаг пиков и есть этаж. Сообщает собственную корреляцию — не найденный ритм так и говорит, вместо правдоподобного числа |
| высоты крыш | `measure.skyline` | максимум высоты на каждой станции v, **по маске каждой части**, а не по её габаритной коробке: коробка Г-образного крыла накрывает двор, который оно обнимает. Медиана — высота только если станции складываются в одну палубу, поэтому `skyline_of` пишет ещё и **долю станций у медианы** (`PLATEAU`), а гейт спрашивает `<часть> is one height`: сосед, свисающий над частью, даёт тот же разброс `low..high`, что парапет с венткамерой, и различает их только доля |
| разрез | `measure.silhouette` | раскладывает каждую вершину по (положение поперёк, высота), **сохраняя всё**, а не только верх |
| сколько там на самом деле есть | `measure.presence` | доля объёма коробки, которую меш заполняет |
**Одно окно на всю длину годится только там, где здание на всю длину одно и то
же.** Там, где крыло кончается или башня отступает, окно надо делить: одна медиана
на всю длину делит разницу пополам и ошибается на обоих концах. На реальном
примере одно окно на всё заднее крыло вывело его хвост на 2.1 м выше меша, а
деление там, где кончается переднее крыло, это починило.
**Профиль по максимуму высоты показывает только силуэт и прячет всё под ним** —
так двор и превращается в сплошной блок высотой двадцать пять метров. Пустота
читается `measure.presence` в плане, а не в объёме: фотограмметрия возвращает
поверхность, и заполненная коробка объявила бы каждое настоящее перекрытие и
каждую настоящую крышу почти пустыми.
**Измерение обязано различать «у меша здесь ничего нет» и «у меша здесь есть
что-то низкое».** Рисовать оба как ноль — это способ изобрести двор.
## Меш — это оболочка
Фотограмметрия восстанавливает только внешние поверхности. Пустая ячейка внутри
контура здания означает **комнату**, а не открытый воздух. Пробник, трактующий
отсутствие как пустоту, доложит о дворах, которых нет.
## Регистрация
Нужна только тогда, когда план и разрез пришли из **разных** источников. Если
план вычитан из того же OBJ, что и высоты, подгонка одна и регистрация — тождество;
`derive` так и пишет: `the plan and the section come from the same file`.
У клипа меша своя вписанная рама, не делящая с картой ни начала, ни размаха.
Прежде чем станция начнёт что-то значить, обе надо привести в одни координаты.
`Registration` сопоставляет размахи того, что стоит выше отметки `REGISTER_FLOOR`
— на уровне земли меш держит дорогу, посадки, припаркованные машины и соседей, —
и потом сверяет две оси между собой. Отметка в **метрах**, а не долей от верха
эталона: план описывает всё здание, включая одноэтажное крыло, и доля выбросила
бы всё, что ниже половины самой высокой части.
Если подгонка вышла 1.15× по u и 0.98× по v, она зацепилась не за здание, и
дальше меряется не то. `derive` в этом случае **ничего не пишет** и говорит,
почему. Отметку поднимают, когда в клипе стоит пояс деревьев или сосед, которого
фотограмметрия не отличает от здания, — понимая, что всё ниже новой отметки
перестанет регистрироваться.
## Декларации
Не выводится то, чего положенные входы не могут сказать сами: **сколько частей** и
**высота того, что фотограмметрия скругляет**. Это приходит из фотографий и
записывается как декларация **с названной фотографией**, рядом с измеренными
числами, а не спрятанным среди них.
Три таблицы наверху `derive.py` — это все объявления, какие бывают, и у каждой
строки обязателен источник:
| таблица | что объявляет | когда нужна |
|---|---|---|
| `DECLARED_PLAN` | части плана: прямоугольники и круги | нет ни карты, ни модели, ни захвата |
| `DECLARED_HEIGHTS` | высота части | нечем измерить, или эталон над ней ничего не держит |
| `DECLARED_STOREY` | шаг этажа | ритм не нашёлся |
| `DECLARED` | счёт частей и прочее с фотографий | всегда, когда фотографии что-то решают |
Счёт частей — это счёт того, какие части существуют, а это ровно то, для чего
фотографии авторитетны. Карта рисует зарубки, `derive` сверяет их шаг со
счётом — но шаг зарубок это подтверждение, а не счёт, и меш стыки заглаживает
вовсе.
## Манифест
Фотографии читаются на этом этапе, и результат их чтения — `blockwright.schedule`:
список частей, которые у здания есть, написанный руками наверху `build.py`,
**до** кода, который хоть одну из них ставит.
Прочитать фотографию нельзя вычислением. Записать, что ты на ней увидел, — можно,
и это честный артефакт. Поэтому каждая строка несёт `source`, называющий, где
автор это увидел.
У расписания три слоя, и каждый зазор между двумя из них — свой класс бага:
| Слой | Что это |
|---|---|
| **манифест** | что у здания есть, по фотографиям |
| **декларация** | что постройка утверждает, что поставила: имя, маска, диапазон y, написанные рядом с кодом, который кладёт блоки |
| **схематика** | что стоит на самом деле |
Две вещи превращают это из формальности в проверку, и обе легко потерять.
**Декларируй маску того, что положил, а не входную.** Перекрытие, объявленное на
весь контур, держится кольцом стены в тех же клетках, и в день, когда плиты
перестанут класться, никто не узнает. Правильно — `foot.erode(THICK)` для плиты,
`foot.outline(THICK)` для стены, `build.openings(...)` для проёмов.
**Называй материал.** `Item(..., blocks=GLASS)`. Без этого декларация
удовлетворяется любым не-воздухом в своей маске, и оконный проём, объявленный на
стене, в которую прорезан, удовлетворяется стеной: «остекление не построено»
проходит зелёным. С материалом строка говорит `nothing of
minecraft:light_blue_stained_glass stands there`.
- строка манифеста **без декларации** — часть, которую **никто не построил**;
- декларация **без блоков** под ней — часть, **построенная в ничто**: маска
сточилась, диапазон цикла вышел пустым, геометрию вынесло за холст;
- декларация, у которой **сосед вне досягаемости**, — часть, **построенная не
там**: мостик, не дошедший до конуса трёх метров.
**Строка без реализации валит гейт в красное.** В этом весь смысл: это
спецификация, а спецификация, которая молчит, когда не выполнена, — список
пожеланий. Пиши манифест первым, смотри, как он падает, потом строй, пока не
перестанет.
Смежность (`near`) — проверка сборки, а не пожелание. Дорожка, не дошедшая до
конуса, проходит любую проверку собственной формы. Досягаемость по умолчанию
1.5 м, потому что две вещи, соприкасающиеся вдоль стены под 52°, отстоят центрами
на `|cos θ| + |sin θ| = 1.41` клетки, и что-либо строже объявило бы настоящий стык
разрывом. Разрыв сообщается **числом** — «5.0 м врозь», — а не булевым значением.
Без этого часть, которую никогда не строили, неотличима от части, которой не
существует, и пайплайн рапортует об успехе. Четыре дефекта из шести на реальном
примере были именно такими.
**Считать копии — тоже дело фотографии.** `Item(..., copies=8)` — это счёт, и
счёт ровно то, что кадр умеет, по тому же аргументу, на котором стоит `placed`.
Вектор, которым копии шагают, — измерение, живёт в `derived.json` и берётся
через `site.step(...)`; печатать его в манифест нельзя.
Рядом со штампом ставится `sched.declare_repeat(имя, мотив, y0, y1, шаг, счёт)`,
и гейт сверяет блоки каждой копии с первой **побайтово**. Это единственная
строка, которая отличает восемь одинаковых секций от восьми, нарисованных восемь
раз: у них один силуэт, одно сечение, один план, одни доли материалов и один
аудит.
Концы ряда — не копии. Фронтон, лестничное ядро, угловой возврат — свои `Item`,
а не «копия с правкой»: отредактированный штамп копией не является.
`near` у повторяющейся части считается по каждой копии отдельно и называет
худшую. Слитая маска отвечает своей ближайшей клеткой, поэтому семь вилл на
палубе отвечали бы за восьмую, висящую в пяти метрах от её края.
## Самопроверка
У расписания нет картинки, на которую можно посмотреть, поэтому
`tools/schedule_selftest.py` гоняет его на синтетическом объекте 24×24×12, несущем
по одной намеренной неисправности на каждый режим отказа, плюс три правильные
части и пять авторских ошибок. Это единственная проверка, чьё собственное
молчание неотличимо от успеха: если она завтра перестанет замечать пропавшие
части, все гейты ниже позеленеют, а отчёт будет читаться ровно так же, как сейчас.
View on GitHub