15 KiB
Issue #316 — миграция v9 сама разрешает конфликт «проём ↔ нулевая стена»
Статус: ревизия 4 (r1: M1/M2/Low; r2: M3/M4; r3: M5). Родитель: #306 (стены нулевой толщины), контекст: #319 (бэкенд-гард — отдельная задача). Решение владельца (чат, 2026-08-26): конфликт проёма при миграции разрешается автоматически, рисование нигде не блокируется; выпуск — v1.68.0-beta.4 вместе с #319.
1. Сценарий и пользовательский результат
На v1.68.0-beta.3 два клика инструментом «Стены» на пустом плане заканчиваются
тостом «Пространство не преобразовано: проём нельзя однозначно привязать к
стене. Данные не изменены». Причина воспроизведена исполнением: структурный
писатель проходит атомарный барьер commitWallSegmentModel на весь конфиг;
hostRoomOpenings требует для каждого контурного проёма ровно одного носителя
с cm > 0; проём, который легаси-open_span перевёл бы на нулевую стену,
кидает WallSegmentModelError('opening-host') — и конфликт в любом одном
пространстве блокирует структурные записи во всех, включая пустые.
После фикса: первая структурная запись (и «Оптимизировать планы») мигрирует документ всегда; конфликтные проёмы разрешаются детерминированно по правилам §3; ни одно пространство не блокирует другое. Пользовательские данные проёмов не удаляются.
2. Границы
- Правила §3 действуют только при initial migration (
model_version < 9на входе вcommitWallSegmentModel). Пост-v9 структурные записи сохраняют прежний fail-closed контракт: невозможность разместить проём — ошибка данных, запись отклоняется целиком (гарантии #278/#282 не ослабляются). - §8.2 ТЗ #306 в части «Legacy-конфликт open_span/open_to с проёмом блокирует миграцию всего пространства» заменяется настоящим документом. Остальное («новый проём на нулевой стене запрещён», «перевод участка с проёмом в 0 отклоняется») действует без изменений — это пост-миграционные операции.
- Бэкенд-гард stale-client не входит в скоуп (#319).
3. Нормативные правила авто-разрешения (в порядке применения)
3.1 Проём удерживает свою стену
Атом, несущий существующий проём (интервал проёма [t·L − length/2, t·L + length/2] пересекает интервал атома на общем носителе, с допуском
GRID_STEP_N * 0.02 по расстоянию и тем же угловым критерием, что в
resolveRoomOpeningHost), не переводится в cm: 0 легаси-катом
(open_spans/open_to): он сохраняет толщину, которую имел бы без ката.
Пункт 8 контракта #306 сужается: «open_spans переводят покрытые атомы в cm:0,
кроме атомов, несущих проём». Физический смысл: если внутри «границы» стоит
дверь — в этом месте стена была и остаётся; разрыв (нулевые атомы) продолжается
по обе стороны от проёма.
3.2 Неоднозначный носитель
Если после 3.1 у проёма больше одного подходящего кандидата (eligible из
resolveRoomOpeningHost), выбирается детерминированно: текущий host, если он
среди кандидатов; иначе ближайший по расстоянию от центра проёма до оси; при
равенстве — больший cm; при равенстве — лексикографически меньший id.
3.3 Проём без носителя
Если кандидатов с cm > 0 нет вовсе: берётся ближайший по расстоянию сегмент
пространства, удовлетворяющий угловому критерию и вместимости, без ограничения
допуска расстояния. Если и таких нет (в пространстве нет ни одного пригодного
сегмента) — проём сохраняется в данных без host и становится непривязанным (unhosted):
не создаёт тоннель/вырез/тело, не участвует в физике, рендерится по своим
x/y как прежде. hostRoomOpenings при initial migration пропускает такой
проём вместо исключения; пост-v9 запись, не меняющая этот проём, обязана его
сохранять, а picker/placement позволяет переставить его на стену штатно.
3.4 Никаких исключений из миграции по проёмам
При initial migration hostRoomOpenings не кидает opening-host ни при каких
данных. Прочие причины (invalid-room, duplicate-id, zero-length) остаются
блокирующими — это настоящие поломки данных, а не легаси-конфликт.
4. AC
- AC1 (репродукция #316). Конфиг: пустое пространство A + пространство B с
комнатой,
open_spanвдоль стены и дверью на той же стене. Два клика инструментом «Стены» в A создают черновик; тоста «Пространство не преобразовано» нет; запись уходит. Доказательство: смок, повторяющий выполненную репродукцию (до фикса красный). - AC2 (3.1). После миграции конфига B дверь остаётся на атоме с исходной
толщиной; атомы span'а по обе стороны проёма —
cm: 0. Доказательство: юнит-тест наcommitWallSegmentModel. - AC3 (3.2). Достижимая внутри
resolveRoomOpeningHostнеоднозначность: центр проёма стоит на стыке двух контурных атомов одной стены на границе смены толщины (оба атома проходятeligible— проём короткий и помещается в каждом). Host выбирается по правилу 3.2 и стабилен при повторном прогоне (идемпотентность: второй прогон байт-идентичен). Доказательство: юнит-тест. - AC4 (3.3). Проём вдали от всех стен мигрирует в состояние «без host»,
конфиг проходит
CONFIG_SCHEMAбэкенда, повторная запись его сохраняет. Доказательство: юнит-тест фронта + бэкенд-тест схемы. - AC5 (граница §2). Пост-v9 запись с проёмом, потерявшим носителя,
по-прежнему отклоняется
opening-host(fail-closed не ослаблен). Доказательство: существующий/новый юнит-тест. - AC6. Прогон миграции идемпотентен:
commit(commit(x)) == commit(x)байтово на фикстурах AC1–AC4. Доказательство: юнит-тест.
5. Затронутые поверхности и артефакты
5.1 Файлы
src/wall-segment-model.ts—buildAtoms(правило 3.1: легаси-кат не зануляет атом, несущий проём),hostRoomOpenings/resolveRoomOpeningHost(3.2/3.3 приinitialMigration), пропуск непривязанных проёмов.src/houseplan-card.ts— без новых веток: barrier остаётся; проверить, что непривязанный проём не ломает существующие писатели.src/space-render.ts— подтверждено ревью r1: рендер поx/yуже работает для проёмов без host; правок не ожидается (если потребуются — в скоупе).scripts/config-field-registry.mjs, записьspaces[].openings[].host=wall:defaultиmigrationбольше не могут звучать как «required for contour openings» / «materialize only when exactly one carrier is proven» — формулировки обновляются на «materialized by migration §3 of #316; a contour opening may persist unhosted after degraded migration», в том же коммите, что и код (правило 11).docs/CONFIG-COMPATIBILITY.md— новый подраздел о непривязанном контурном проёме как валидном v9-состоянии после деградированной миграции (§3.3): не участвует в телах/вырезах, рендерится поx/y, переставляется picker'ом. Устаревает абзац раздела «Canonical zero-thickness walls — model v9 (#306)» («…verifies that no opening would acquire a zero host… A conflict rejects the complete candidate») — именно его fail-closed описание заменяют правила §3.1–3.4; он переписывается в том же коммите. Раздел «Independent-wall opening host (#132)» (партиционные проёмы) не затрагивается.tests_backend— кейс схемы: v9-документ с контурным проёмом без host принимается на чтение и запись (AC4).demo/smoke_*— новый смок репродукции AC1.
5.2 i18n
Без изменений: ключи toast.zero_wall_migration_blocked и
wall_model.reason.opening-host остаются для пост-v9 fail-closed пути (§2);
новых строк не появляется.
5.3 Release-артефакты
- CHANGELOG RU+EN — в реализационном коммите (
User-Visible: yes). - Golden: правило 3.1 меняет видимое зонирование
cm:0(атом под проёмом остаётся телесным) — добавляется сцена «span поверх стены с дверью» в golden-матрицу. Комбинация span+проём в существующих сценах ЕСТЬ:coincident-partition-virtual-darkстроитopen_spansповерх позиции проёма с удалённым host. Ожидание «без изменений» держится не на природе кладки (после очистки host проём обрабатывался бы миграцией как контурный — тем же путём, что правило 3.1), а на границе §2: сцена рендерится вmode: 'view'без структурной записи,commitWallSegmentModelиз чистого рендера не вызывается — правила §3 в ней не выполняются вовсе. Арбитр —golden:verify: неизменность подтверждается прогоном, а любое расхождение проходит канонический путь приёмки с визуальным ревью, не молчаливую переснятие. - USER-GUIDE — без правок: пользовательского UI-контракта не меняется, поведение «рисование не блокируется» — восстановление обещанного.
5.4 Производительность и touch
Perf: правило 3.1 добавляет к атомизации один проход по проёмам пространства (O(atoms × openings) на миграцию, единожды на документ) — вне горячего пути рендера; performance-smoke не затрагивается. Touch-контракта правки не касаются (миграция — не жест).
6. Принято предположительно (поменять свободно)
- Порядок tie-break в 3.2: текущий host → расстояние → больший
cm→ меньшийid. - Семантика деградации 3.3: ближайший пригодный сегмент без лимита допуска, и лишь затем непривязанное состояние; альтернатива «сразу непривязанный без поиска» тоже согласуется с решением владельца.
- Имя состояния 3.3 в документации: «непривязанный (unhosted)».
7. Риски
- Изменение зонирования cm:0 (3.1) сдвигает границы атомов относительно beta.3 — на документах, уже мигрировавших на beta.3, правило 3.1 не сработает (span'ы уже удалены, атомы уже cm:0): такие проёмы попадут в 3.2/3.3. Принято: beta.3 прожила меньше суток, владелец предупреждён.
- Непривязанный проём (3.3) — новое состояние данных; риск ограничен рендером по x/y и пропуском в физике; picker уже умеет переставлять проёмы.
8. Откат
Реверт ветки; миграция снова кидает opening-host (поведение beta.3). Данные,
уже мигрировавшие с авто-разрешением, остаются валидными v9-документами.