Files
houseplan-card/docs/specs/089-isometric-view-stage1.md
T

44 KiB
Raw Blame History

ТЗ #89, этап 1 — объёмный вид за флагом Labs

  • Issue: https://github.com/Matysh/houseplan-card/issues/89
  • Приоритет: P1, feature
  • Статус ТЗ: готово к реализации (ревизия 2, ревью 2026-08-11). До ADR (§13.1) задача ведётся как spike/implementation-prep: renderer не считается выбранным, пока решения ADR не записаны.
  • Исследование и продуктовое обоснование: 089-isometric-view.md (контекст; нормативен настоящий файл)
  • Ревью ревизии 1: SPEC-REVIEW-089-isometric-view-stage1.md
  • Связано: #82 (анимация zoom/fit), #73 (визуальная непрерывность), #85 (тесты должны уметь падать), #50 (перенос конфигурации)

0. Решения

0.1 Зафиксировано изначально

# Решение Почему
D1 Этап 1 не показывает пользователю ничего. Ни кнопки, ни строки в настройках, ни записи в changelog — только под явно включённым флагом Labs Ракурс, читаемость подписей и поведение на больших планах проверяются на реальных данных; до этого любая кнопка — обещание, которое нечем закрыть
D2 Механизм флагов вводится один раз и переиспользуется. Следующие кандидаты — #82, #83, #52 Иначе каждая сырая фича заводит свой одноразовый способ включения
D3 Renderer — SVG-проекция координат. CSS perspective/preserve-3d и WebGL запрещены Карточка уже использует filter: brightness() на .zoomwrap (day/night), mix-blend-mode: screen (Glow) и <filter> с feGaussianBlur: 3D-контекст либо ломается, либо уплощается
D4 Предпочтение вида не хранится в конфигурации плана — только в localStorage Запись в стор создаёт revision, конфликты совместного редактирования и попадает в экспорт #50
D5 Кэш геометрии — по контентному отпечатку, не по _cfgEpoch Эпоха отстаёт от правок in-place; на этом уже ломался кэш барьеров света
D6 Spike входит в этап 1 и заканчивается ADR до имплементации renderer Отдельная поставка ради двух прототипов не нужна, но выбор должен быть письменным
D7 Документация этапа 1 — внутренняя Пользовательской функциональности не появилось

0.2 Что изменено ревью 2026-08-11

# Находка Куда внесено
B1 Проекция точек описана, а три реальные системы координат — нет. IsoCamera без pivot и zScale: проекция вокруг (0,0) сдвинет план, а _baseVb() (houseplan-card.ts:3995) не знает про поднятые верхние грани и начнёт их обрезать §4.4 — plan/scene/client, pivot-константа, projectedFrame
B2 Нет алгоритма переноса viewport между проекциями. _view и _viewModeSnap (:982) хранят прямоугольник и cx/cy в координатах текущей сцены; требование «не менять фокус» было недоказуемо §4.5 — алгоритм из шести шагов
B3 Несовместимость с docs/WARM-REMOUNT.md: warmBoot (:287) переносит _view/_viewModeSnap/mode, а один и тот же ViewRect теперь имеет два разных смысла §7.1 — projection в warm-состоянии, усыновление только при совпадении
B4 Правило «все попадания через unproject» технически неверно: SVG сам хит-тестит содержимое трансформированного <g>, HTML-маркер получает клик как обычный DOM-узел; плюс смок про перетаскивание маркера противоречил «редакторы всегда плоские» §7.1 — нативный hit-test, смок про drag убран
B5 Кнопка «в киоске рядом с шапкой» невозможна: .hdr.kioskhide { display: none } (styles.ts:987) §3, §7.1 — кнопка только в обычном View, аварийный выход из киоска через URL
B6 Грамматика Labs противоречива: неясен приоритет query и хэша, смысл off,iso, повторного параметра, и §2.2 против §2.3 про очистку URL §2.2.1 — точный контракт операций
B7 Не определена топология граней. wallBodiesGeometry().geom — MultiPolygon с внешними и внутренними rings после union и вырезов; «граничных рёбер» недостаточно. Формулировка «без торцов в проёме» запрещала бы корректные jamb faces §5.1, §12 AC 6
B8 Fallback «вернуться в flat на этом кадре» не отвечает, что будет на следующем рендере и что показывает кнопка §9 — state machine с latch
M1 ADR требовался, но список обязательных решений не задан §13.1
M2 Отпечаток перечислял не все входы геометрии §8.1
M3 since/expires без версионной семантики, а зависимости semver в проекте нет §2.4
M4 Не указан владелец механизма в рантайме и форма window.__hpLabs §2.5
M5 Не сказано про вторую карточку — src/space-card.ts §10.3
M6 Перф-контракт не совпадает с существующей инфраструктурой: demo/performance/ использует профильные бюджеты (budgets-*.json) и fail-closed сверку окружения §8.2
M7 Двух golden-картинок мало для новой системы координат §11.3
M8 Контракт доступности кнопки неполон §11.5
T1 «Сравнить innerHTML до и после ветки» невоспроизводимо обычным тестом §11.1
T2 Мутанты сформулированы так, что часть ловится только текстовым поиском §11.4
T3 Формулировка AC про проём конфликтовала с B7 §12 AC 6
T4 Не определено поведение при первом запуске и при истёкшем флаге с сохранённым iso §10.2
T5 Issue и файл ТЗ расходились в статусе §13.2

1. Цель и границы этапа

Получить работающий объёмный вид на реальных планах, не показывая его никому, кроме тех, кто явно включил флаг.

Входит: механизм Labs (§2); системы координат и проекция (§4); объём стен, перегородок, колонн и разрывы в проёмах (§5); поведение слоёв (§6); взаимодействие, непрерывность и киоск (§7); кэш и производительность (§8); fallback (§9); данные (§10); тесты (§11).

Не входит: §14.

2. Labs — механизм скрытых фич

2.1 Реестр

Единственный источник истины — src/labs.ts:

export interface LabsFlag {
  id: string;            // 'iso'
  issue: number;         // 89
  since: string;         // версия, в которой флаг появился
  expires: string;       // версия, начиная с которой флаг МЁРТВ
  summary: string;       // одна строка для консоли, только EN, не i18n
}
export const LABS_FLAGS: readonly LabsFlag[] = [ /* … */ ];

Запись без issue, since, expires или с since >= expires — ошибка сборки (тест §11.1). summary намеренно не проходит через i18n: это диагностика.

2.2 Источники активации

Читаются и query-строка, и хэш. Хэш — потому что карточка уже им владеет (_hashSpace(), houseplan-card.ts:1290, слушатель hashchange на :1422) и он реактивен внутри Lovelace; query — потому что его удобно давать тестировщику.

?hp-labs=iso        #hp-labs=iso        включить
?hp-labs=iso,foo                        несколько значений
?hp-labs=-iso                           выключить один
?hp-labs=off                            очистить набор

2.2.1 Приоритет и семантика операций (B6)

  1. База — валидный набор из storage.
  2. К базе применяются операции query слева направо, затем hash слева направо. Хэш сильнее: он реактивен и переживает переходы Lovelace.
  3. id добавляет, -id удаляет, off очищает набор в этой позиции; следующие токены снова могут добавлять. Поэтому off,iso даёт {iso}, а iso,-iso — пустой набор.
  4. Повторяющиеся параметры hp-labs обрабатываются в порядке появления.
  5. Неизвестный идентификатор игнорируется молча и сам по себе не переписывает storage. Storage обновляется, только если в URL была хотя бы одна известная операция или off.
  6. Механизм никогда не переписывает URL. off очищает эффективный набор и storage, но остаётся видимым в адресной строке: пользователь должен видеть, почему карточка выглядит так.
  7. Хэш разбирается общим helper вместе с space: #space=x&hp-labs=iso и обратный порядок работают одинаково, percent-encoding поддержан. Второй regex-парсер в _hashSpace() при этом удаляется — источник разбора один.
  8. hashchange применяется без перезагрузки; popstate перечитывает query и хэш, если URL действительно изменился без reload.

2.3 Персистентность

localStorage['houseplan_card_labs_v1'] (соседи: houseplan_card_layout_v1, houseplan_card_cfg_v1). Недоступное или сломанное хранилище (приватный режим, квота) — не ошибка: набор живёт до конца жизни страницы.

2.4 Версии и срок жизни (M3)

Зависимости semver в проекте нет, строковое сравнение недопустимо. Нормативно: собственный parser major.minor.patch[-prerelease].

  • Сравнивается числовое ядро major.minor.patch, prerelease-суффикс игнорируется. Следствие: 1.65.0-beta.1 уже достигает expires: 1.65.0, и мёртвый флаг не уезжает в новый релизный цикл.
  • Флаг с достигнутым expires не включается ничем: ни URL, ни storage.
  • Некорректная запись реестра или неразбираемая версия карточки — fail closed: флаг считается выключенным.
  • Тесты: 1.64.9, 1.65.0-beta.1, 1.65.0, malformed, since >= expires.

2.5 Владелец в рантайме и диагностика (M4)

  • Доступность флагов — состояние загруженного JS-модуля, одно на страницу: один resolver, одна подписка на hashchange/popstate, а не по слушателю на каждый рендер карточки.
  • Эффективный вид (flat|iso) — состояние конкретной карточки и пространства.
  • window.__hpLabs — замороженный отсортированный массив (Object.freeze(['iso'])). При изменении URL свойство заменяется новым замороженным массивом; внутренний Set наружу не отдаётся. За это свойство цепляются смоки.
  • При непустом наборе — одна строка в консоль при первом рендере: HOUSEPLAN LABS: iso (#89, expires 1.65.0). При пустом — тишина.
  • Ничего в UI: ни бейджа, ни тоста, ни строки в «Общих настройках».

2.6 Границы механизма

Флаг Labs — только presentation. Запрещено гейтить им миграции данных, схему конфигурации, записи в сторы и любые сетевые вызовы. Бэкенд о флагах не знает.

2.7 Чего механизм не делает на этапе 1

YAML-опция карточки (labs: [iso]) не вводится: конфигурация уезжает в скриншоты, в поддержку и в экспорт #50. Добавляется позже одной строкой в том же парсере.

3. Что видно под флагом iso

  • Кнопка mdi:cube-outline с accessible name «Объёмный вид» / «Volumetric view» только в обычном режиме просмотра. В киоске её нет и быть не может: .hdr.kioskhide { display: none } (B5).
  • Строки i18n добавляются в оба словаря сразу, но существуют только в этой ветке разметки.
  • Больше ничего: ни в «Общих настройках», ни в настройках пространства, ни в диалогах устройства.

4. Координаты и проекция

4.1 Чистый модуль

src/iso-projection.ts — без Lit, без DOM, без hass.

4.2 Камера

Один зафиксированный ракурс; константы выбираются на spike и после этого меняются только вместе с пересъёмкой golden. Свободное вращение и tilt в этап 1 не входят.

4.3 Одна проекция на всё

SVG-геометрия и HTML-оверлеи (маркеры, подписи комнат, карточки комнат) проецируются одной и той же функцией. Отдельная «примерно такая же» формула в CSS для оверлеев запрещена: расхождение маркера и плана — главный риск исследования.

4.4 Системы координат, pivot и projected frame (B1)

type PlanPoint  = readonly [number, number];   // модель: room, wall, marker
type ScenePoint = readonly [number, number];   // координаты SVG viewBox (_view)

interface IsoCamera {
  rotDeg: number;
  tiltDeg: number;
  xyScale: number;
  zScale: number;
  origin: PlanPoint;      // pivot
}

projectPlanPoint(p: PlanPoint, zUnits: number, cam: IsoCamera): ScenePoint;
unprojectFloorPoint(p: ScenePoint, cam: IsoCamera): PlanPoint;   // только z = 0
clientToScenePoint(client: readonly [number, number],
                   stageRect: DOMRectReadOnly, view: ViewRect): ScenePoint;
projectedFrame(input: IsoFrameInput, cam: IsoCamera): ViewRect;

Нормативно:

  1. projectPlanPoint возвращает scene, а не client/screen координаты. Client получается существующим путём через _view и .stage.
  2. unprojectFloorPoint инвертирует только плоскость z = 0: точке на вертикальной грани не соответствует единственная точка плана.
  3. Pivot — фиксированная константа плана, рекомендуется [NORM_W / 2, NORM_W / 2] (NORM_W = 1000, space-geometry.ts:11). Не центр viewport и не центр содержимого: иначе появление дальнего объекта или переключение _showFar (houseplan-card.ts:806) сдвинет уже построенные стены без изменения их геометрии.
  4. Высота стены задаётся одной константой, переводится в plan units и только потом умножается на zScale. Значения фиксируются ADR.
  5. fit, ограничение pan, «стрелка домой», подсказка о дальних объектах и начальный вид используют projectedFrame, который включает и пол, и поднятые верхние грани. _baseVb() в объёмном виде не применяется.
  6. projectedFrame не зависит от текущего zoom/pan и входит в кэш геометрии (§8.1).
  7. Round-trip unprojectFloorPoint(projectPlanPoint(p, 0)) ≈ p с точностью 1e-9 на всём диапазоне холста (±5000, docs/CANVAS.md).

4.5 Переключение viewport между проекциями (B2)

_view и _viewModeSnap хранят прямоугольник в координатах текущей сцены; переносить их между проекциями напрямую нельзя. Алгоритм смены:

  1. Получить логический центр пола: в flat центр _view уже является точкой плана; в iso — пропустить центр _view через unprojectFloorPoint.
  2. Построить целевой projectedFrame и целевой fit.
  3. Сохранить тот же скалярный zoom.
  4. Спроецировать логический центр в целевую сцену и вызвать _applyView() (:4101) с этим центром.
  5. Сырые x/y/w/h между видами не переиспользуются никогда.
  6. Вход в редактор выполняет тот же переход iso → flat, выход — обратный к предыдущему виду. Смена пространства внутри редактора сбрасывает старый снимок по существующему правилу.

Предпочтение вида и viewport — разные сущности: в localStorage пишется только предпочтение (§10.2) и существующий скалярный zoom, сырой viewport остаётся runtime/warm-состоянием.

5. Объём

  • Источник — канонические тела стен (wallBodiesGeometry), те же, что питают модель света. Второй геометрии не заводится.
  • Число боковых граней O(рёбер); копирование контура слоями (как в демо) запрещено.
  • Перегородки и колонны экструдируются из своих текущих геометрий; высота у всех одна, в сантиметрах пользователю не показывается.
  • Виртуальные границы остаются линиями на полу и высоты не получают.
  • show_borders: false не меняет семантику: невидимые стены остаются невидимыми, но продолжают участвовать в площади и в модели света; кэш при этом не инвалидируется (§8.1).

5.1 Топология граней (B7)

  • Грани строятся из rings канонического MultiPolygon после union, стыков и вырезов проёмов. Исходные рёбра комнат для экструзии не используются.
  • Winding нормализуется один раз; внешняя нормаль учитывает, внешнее это кольцо или дырка.
  • Видимость грани определяется знаком скалярного произведения нормали и фиксированного направления взгляда.
  • Для фиксированной камеры задаётся детерминированный порядок отрисовки.
  • Проём — разрыв на всю высоту. Две вертикальные грани откосов по краям разрыва (jamb faces) — ожидаемая часть объёма, а не дефект.
  • Запрещены: полоса верха или бока, пересекающая сам разрыв, и «крышка» на полу тоннеля проёма.
  • Дверь, окно и ворота на этапе 1 — осознанно разрывы на всю высоту: модель не хранит высоту подоконника.
  • Проём никогда не вырезает совпадающую перегородку или колонну — сохраняется текущий порядок объединения дополнительных тел после вырезов.
  • Верхняя поверхность рисуется целиком с fill-rule: evenodd.
  • Юнит-фикстуры: внешнее кольцо, дырка, multipolygon, T- и X-стык, проём у угла, совпадающее независимое тело.

6. Слои

Поведение слоёв — таблица §6 исследования, она нормативна. Дополнительно как запреты:

  • существующее дерево карточки не оборачивается в CSS 3D-контекст; perspective, rotateX/Z, preserve-3d не появляются ни на .zoomwrap, ни на её предках;
  • day/night filter: brightness() и Glow mix-blend-mode: screen не трогаются;
  • слой света остаётся одним регионом на источник с одним размытием на весь слой (docs/LIGHT.md); второго слоя света объёмный вид не добавляет — это прямо запрещено ассертом test/golden-matrix.test.mjs.

7. Взаимодействие

  • Zoom/pan работают как сейчас и не пересчитывают модель (§8); якорь зума считается в scene-координатах.
  • Редакторы всегда плоские. Вход показывает плоский вид, выход восстанавливает предыдущий; переключение не пишет в command stack и не создаёт записей в сторе.

7.1 Hit-test, непрерывность и киоск (B3, B4, B5)

Попадания. SVG сам хит-тестит содержимое трансформированного <g>, а HTML-маркер получает клик как обычный DOM-узел. Ручной unproject для них — двойное преобразование.

  • интерактивные потомки SVG и HTML используют нативный hit-test;
  • цепочка client → scene → unprojectFloorPoint применяется только там, где событие сцены действительно должно дать координату плана;
  • на этапе 1 объёмный вид ничего не создаёт, не редактирует и не перетаскивает, поэтому _svgPoint() (:5018) остаётся плоским;
  • тесты кликают реальные DOM-узлы комнат, устройств и проёмов и проверяют действие; отдельный юнит проверяет цепочку client → scene → floor.

Warm-remount (#73). Один и тот же ViewRect теперь имеет два смысла, поэтому:

  • warm-состояние хранит projection: 'flat' | 'iso' и логический центр;
  • сырой _view усыновляется только при совпадении пространства, проекции и активного контракта Labs;
  • при несовпадении восстанавливаются скалярный zoom и логический центр по алгоритму §4.5, а не чужой прямоугольник;
  • отпечаток кадра включает эффективную проекцию и отпечаток геометрии объёма;
  • снятие или истечение флага никогда не воскрешает iso-DOM из memo;
  • смоки: iso → remount → тот же кадр; iso → снять флаг → remount → корректный плоский кадр без вуали и вспышки.

Киоск. Кнопки в киоске нет (§3). Киоск читает последнее предпочтение этого браузера для пространства. Аварийный путь — ?hp-labs=-iso или off: киоск немедленно становится плоским и не может восстановить iso из warm-памяти. Новых панелей и диалогов этап 1 в киоске не добавляет. Если переключатель в киоске понадобится, его место — существующий long-press диалог, отдельным решением.

8. Производительность и кэш

8.1 Отпечаток (M2)

Геометрия объёма пересчитывается только при изменении контентного отпечатка, который включает: комнаты и их контуры; стены и толщины; room_drafts с толщинами сегментов; нормализованные openCuts и виртуальные интервалы; канонические вырезы проёмов; перегородки; колонны с формой, углом и диаметром; cell_cm, шаг сетки и масштаб координат; константы камеры и высоты стены; версию алгоритма проекции и построения граней.

Массивы сериализуются детерминированно, числа нормализуются как в существующих отпечатках геометрии. Ключ вида ${space.id}|${fingerprint}; _cfgEpoch в ключе запрещён (D5).

Состояние отображения (hover, состояния HA, show_borders) кэш не инвалидирует.

Pan/zoom меняют только transform/viewBox и ничего не пересчитывают.

8.2 Перф-контракт (M6)

Существующая инфраструктура (demo/performance/) использует профильные бюджеты (budgets-*.json), допуск на шум и fail-closed сверку окружения. Поэтому:

  • заводится отдельный профиль large-house-isometric-v1: тот же harness запускает candidate-бандл с hp-labs=iso и переключением вида, а базовый бандл игнорирует неизвестный флаг и остаётся плоским;
  • идентификатор профиля в обоих отчётах одинаков, runtime/browser/fingerprint проверяются существующим контрактом;
  • отдельный reviewed-бюджет задаёт 20 % относительного допуска плюс абсолютный допуск на шум;
  • обязательные метрики: первый устойчивый кадр iso, переключение вида, pan/zoom, обновление состояния HA, смена пространства, количество/сумма/максимум long-task, рост heap, размер и рост кэша геометрии, число отрисованных устройств;
  • завершение этапа подтверждается полным performance-workflow на точном SHA, а не локальным сравнением с другой машиной.

Требование к плоскому виду формулируется проверяемо: при выключенном флаге геометрия, кэш и DOM объёмного вида отсутствуют, дополнительного прохода по коллекциям комнат и устройств нет, а тайминги укладываются в допуск на шум.

9. Fallback (B8)

Состояние:

  • desiredView — сохранённое предпочтение;
  • effectiveView — то, что реально нарисовано.

Правила:

  • исключение в чистой геометрии или в шаблоне сцены ловится на границе renderIsoScene(); для пары (space, geometryFingerprint) ставится защёлка на сессию;
  • при защёлке effectiveView = 'flat', и геометрия объёма больше не вызывается на каждом обновлении состояния HA;
  • конфигурация, layout и сохранённое предпочтение не меняются автоматически;
  • кнопка отражает effectiveView (aria-pressed="false"); явное повторное нажатие или новый отпечаток геометрии снимает защёлку и даёт одну повторную попытку;
  • в консоль — одна строка на защёлку: issue, пространство, отпечаток и короткая причина, без конфигурации и entity id;
  • ошибка проекции HTML-оверлеев входит в ту же границу, иначе получится «стены плоские, маркеры объёмные».

10. Данные и совместимость

10.1 Формат

Формат конфигурации не меняется: ни одного нового поля в spaces, markers, settings. Записей в config/layout сторы объёмный вид не делает. Экспорт и импорт #50 не затрагиваются.

10.2 Предпочтение вида (T4)

  • Ключ houseplan_card_view_v1, значение — по пространству, flat | iso.
  • Без записи для пространства эффективный вид всегда плоский, даже при активном флаге.
  • Переключение в обычном View пишет предпочтение для текущего пространства.
  • При неактивном или истёкшем флаге сохранённое iso игнорируется: DOM не меняется и в warm-память не попадает.
  • Вход и выход из редактора предпочтение не перезаписывают.
  • Fallback (§9) предпочтение не перезаписывает.

10.3 Вторая карточка (M5)

houseplan-space-card (src/space-card.ts, src/space-render.ts) остаётся плоской: флаг iso на неё не влияет. Её поддержка — отдельный будущий scope; иначе этап 1 сразу требует второй композиции сцены.

11. Тесты

11.1 Юниты

  • iso-projection: round-trip §4.4.7, детерминированность, вырожденная камера, projectedFrame включает поднятые грани и не зависит от zoom/pan.
  • Грани: число = числу видимых граничных рёбер; разрыв проёма даёт две грани откосов; полоса через разрыв и крышка на полу тоннеля отсутствуют; фикстуры §5.1.
  • Labs: грамматика §2.2.1 целиком (off,iso, iso,-iso, повторный параметр, приоритет хэша над query, неизвестное значение не переписывает storage, недоступный localStorage), общий разбор #space=…&hp-labs=… в обоих порядках, версии §2.4, валидность реестра.
  • Отсутствие вызова построителя геометрии объёма при выключенном флаге — проверяется шпионом (T1).

11.2 Смоки

Контракт «ничего не изменилось» (T1) разбит на проверяемые части внутри одной сборки:

  • без параметра и с неизвестным параметром: в DOM нет узлов объёмного вида, нет дополнительных ws/http-запросов и записей в config/layout;
  • чтение собственного ключа Labs не считается изменением, но при отсутствии операций в URL storage не перезаписывается;
  • нулевой diff существующих плоских golden-сцен между ревизиями гарантирует остальное.

Дальше: кнопка появляется только под флагом и только в обычном View; переключает и возвращает вид; вход в редактор показывает плоский вид, выход восстанавливает; флаг переживает смену вида дашборда, off его снимает; iso → remount → тот же кадр; iso → снять флаг → remount → плоский кадр без вуали; загрузка киоска с сохранённым iso и возврат в плоский через URL.

11.3 Golden (M7)

Минимальная матрица этапа: desktop dark (смешанные толщины, проёмы, Glow и солнце, устройства); desktop light (тема, затенение граней, паритет фильтров); узкий мобильный или киоск (fit, совпадение маркеров и подписей, отсутствие обрезки); show_borders: false (стены не нарисованы, заливка и Glow на месте). Ветка активации флага добавляется в demo/golden/harness.mjs, GOLDEN_MATRIX_VERSION поднимается. Существующие плоские сцены не принимаются заново: ненулевой diff — регресс. Новые эталоны принимаются только из полного Linux-артефакта CI по действующему контракту HP-QA-01.

11.4 Мутанты (T2, контракт #85)

Каждый мутант исполним, и для каждого сохраняются patch/команда и имя краснеющего теста:

  1. объёмный вид включается без флага → падает смок §11.2;
  2. в отображение HTML-оверлеев вносится контролируемый сдвиг → смок видит расхождение якоря больше 1 CSS px;
  3. кэш геометрии ключуется по _cfgEpoch: тест правит геометрию in-place, грани обязаны обновиться;
  4. expires игнорируется → падает юнит версий;
  5. боковые грани строятся слоями → падает проверка верхней границы числа граней как O(E).

11.5 Доступность (M8)

aria-pressed по effectiveView; стабильное имя «Объёмный вид» / «Volumetric view»; фокус остаётся на той же кнопке после переключения; активное состояние не кодируется только цветом; порядок обхода устройств и действий комнаты совпадает с плоским видом; анимации переключения этап 1 не добавляет — свап атомарный (если анимация появится через #82, prefers-reduced-motion делает её мгновенной).

12. Acceptance criteria

  1. При выключенном флаге карточка идентична текущей: DOM, запросы, сторы, производительность в пределах допуска на шум, все существующие golden.
  2. Флаг включается из query и из хэша по грамматике §2.2.1, переживает переход между видами и снимается через off.
  3. Флаг с достигнутым expires не включается никаким способом.
  4. Под флагом в обычном View доступен объёмный вид с фиксированным ракурсом; плоский вид остаётся значением по умолчанию, в том числе при первом запуске.
  5. Физические стены, перегородки и колонны имеют непрерывные верх и боковые грани; виртуальные границы плоские.
  6. Проём является разрывом на всю высоту: внутри разрыва нет полосы верха или бока, а две вертикальные грани откосов на границах разрыва — ожидаемая часть объёма.
  7. Glow, spill, солнце, заливки и hover сохраняют текущую семантику; второго слоя света не появилось.
  8. Маркеры, подписи и попадания курсора используют одну проекцию и не расходятся с планом; переключение вида не меняет зум и не сдвигает логический центр.
  9. Редакторы всегда плоские; переключение не пишет в сторы и не создаёт записей в истории; warm-remount не подменяет проекцию.
  10. Исключение в renderer даёт плоский fallback с защёлкой, кнопка отражает фактический вид, предпочтение не перезаписывается.
  11. Перф-профиль §8.2 выполнен на полном workflow по точному SHA.
  12. Формат конфигурации не изменился; houseplan-space-card остался плоским.

13. Артефакты этапа

13.1 ADR (M1)

До основной реализации ADR закрывает: формулу проекции и pivot; константы камеры, единицы высоты стены и zScale; заливку, обводку и затенение верха и боков в светлой и тёмной теме; нормализацию колец, видимость граней и порядок отрисовки; z-порядок «пол → Glow/солнце/декор → грани и верх → экранные HTML-оверлеи»; осознанное правило этапа 1 «маркеры и карточки комнат всегда выше граней и не получают геометрической окклюзии»; projectedFrame и конверсию viewport; результат проверки SVG filter/clip/mix-blend в Chromium, Firefox и WebKit; причины отказа от проигравшего прототипа.

13.2 Документы

  • docs/ISOMETRIC.md — внутреннее описание координат, проекции, топологии граней и ограничений;
  • запись про Labs в docs/DEVELOPMENT.md и AGENTS.md: как включать, как заводить флаг, когда он умирает;
  • docs/STATUS.md — строка о скрытом этапе;
  • issue #89: ссылка на это ТЗ как на нормативное, синхронизация scope и acceptance criteria, Project остаётся в Todo до фактического старта. Issue не закрывается после ADR — только после всех AC этапа (T5).

Не трогаются: README.md, README.ru.md, docs/USER-GUIDE.ru.md, docs/CHANGELOG.md, docs/CHANGELOG.ru.md.

14. Вне этапа 1

  • Кнопка и любые пользовательские упоминания вне флага.
  • Пресеты камеры, свободное вращение и tilt.
  • Полировка дверей, окон и ворот, мягкие тени, floor edges (этап 2).
  • Геометрическая окклюзия маркеров стенами.
  • Объёмный вид в houseplan-space-card.
  • Редактирование в перспективе, пользовательская высота, 3D-мебель, WebGL.
  • YAML-опция карточки для Labs.