Files
houseplan-card/docs/specs/316-opening-host-auto-resolution.md
T
2026-08-26 21:54:19 +03:00

9.4 KiB
Raw Blame History

Issue #316 — миграция v9 сама разрешает конфликт «проём ↔ нулевая стена»

Статус: ревизия 1. Родитель: #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 и становится инертным: не создаёт тоннель/вырез/тело, не участвует в физике, рендерится по своим 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). Два коллинеарных совпадающих кандидата (контурный + независимый, кейс #313) — 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. Риски

  • Изменение зонирования cm:0 (3.1) сдвигает границы атомов относительно beta.3 — на документах, уже мигрировавших на beta.3, правило 3.1 не сработает (span'ы уже удалены, атомы уже cm:0): такие проёмы попадут в 3.2/3.3. Принято: beta.3 прожила меньше суток, владелец предупреждён.
  • «Инертный» проём (3.3) — новое состояние данных; риск ограничен рендером по x/y и пропуском в физике; picker уже умеет переставлять проёмы.

6. Откат

Реверт ветки; миграция снова кидает opening-host (поведение beta.3). Данные, уже мигрировавшие с авто-разрешением, остаются валидными v9-документами.