# ТЗ #89, этап 1 — объёмный вид за флагом Labs - Issue: https://github.com/Matysh/houseplan-card/issues/89 - Приоритет: **P1**, feature - Статус ТЗ: **принято к реализации** (ревизия 3, решения владельца и зелёное независимое ревью ТЗ 2026-08-13). Ревизия 2 предшествует действующему процессу и новым визуальным референсам. - Исследование и продуктовое обоснование: [`089-isometric-view.md`](089-isometric-view.md) (контекст; нормативен настоящий файл) - Историческое ревью ревизии 1 не является доступным артефактом репозитория и не заменяет новое ревью по `PROCESS.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) и `` с `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()` не знает про поднятые верхние грани и начнёт их обрезать | §4.4 — plan/scene/client, pivot-константа, `projectedFrame` | | B2 | Нет алгоритма переноса viewport между проекциями. `_view` и `_viewModeSnap` хранят прямоугольник и `cx/cy` в координатах *текущей* сцены; требование «не менять фокус» было недоказуемо | §4.5 — алгоритм из шести шагов | | B3 | Несовместимость с `docs/WARM-REMOUNT.md`: `warmBoot` переносит `_view`/`_viewModeSnap`/mode, а один и тот же `ViewRect` теперь имеет два разных смысла | §7.1 — projection в warm-состоянии, усыновление только при совпадении | | B4 | Правило «все попадания через unproject» технически неверно: SVG сам хит-тестит содержимое трансформированного ``, HTML-маркер получает клик как обычный DOM-узел; плюс смок про перетаскивание маркера противоречил «редакторы всегда плоские» | §7.1 — нативный hit-test, смок про drag убран | | B5 | Кнопка «в киоске рядом с шапкой» невозможна: `.hdr.kioskhide { display: none }` в `src/styles.ts` | §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 | ### 0.3 Решения владельца 2026-08-13 | # | Решение | Следствие для этапа 1 | |---|---|---| | O1 | Детерминированный 2.5D допускается как узкое исключение из прежнего абсолютного запрета в `docs/SCOPE.md` | Это альтернативное представление J1/J2/J3, не отдельная модель, interior editor или свободная 3D-камера | | O2 | Новые изображения задают обязательное направление Stage 2 | Stage 1 сразу использует почти верхнюю ортографическую камеру без диагонального yaw, но не обещает материал/фотореализм референса | | O3 | Labs — общий переиспользуемый механизм | Одноразовый флаг #89 запрещён; пользовательской настройки Labs нет | | O4 | #89 заканчивается полностью реализованным Stage 1 | Stage 2/public rollout получает отдельную feature issue только после зелёного Stage 1 | | O5 | Вертикальные створки и светлые оконные элементы отложены | Двери, окна и ворота сохраняют текущие символы на плоскости пола; стеновые разрывы полноценны | | O6 | Все текущие live-эффекты пола сохраняются | Glow/spill, солнце, room fills/hover, decor/backdrop и vacuum не заменяются новым оконным светом | --- ## 1. Цель и границы этапа Получить работающий объёмный вид на реальных планах, не показывая его никому, кроме тех, кто явно включил флаг. **Job и персона.** Этап служит J1 «показать весь дом и происходящее сейчас»; на целевой поверхности View домашний администратор оценивает скрытый прототип, а household member/guest в обычной установке продолжает видеть неизменный плоский план. Touch View и kiosk остаются обязательными поверхностями даже для скрытого режима: включивший Labs администратор не должен получить сломанный ежедневный план на планшете. **До → после, без терминов реализации.** До этапа явно включённый экспериментальный режим отсутствует; после этапа тестировщик может одним переключателем увидеть тот же живой дом с низкими объёмными стенами и вернуться к прежнему плоскому виду без потери состояния, фокуса и действий. Входит: механизм Labs (§2); системы координат и проекция (§4); объём стен, перегородок, колонн и разрывы в проёмах (§5); поведение слоёв (§6); взаимодействие, непрерывность и киоск (§7); кэш и производительность (§8); fallback (§9); данные (§10); тесты (§11). Не входит: §14. ## 2. Labs — механизм скрытых фич ### 2.1 Реестр Единственный источник истины — `src/labs.ts`: ```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()` и реактивно слушает `hashchange` внутри 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 Камера Один зафиксированный почти верхний ортографический ракурс, следующий новым референсам владельца: исходные горизонтали и вертикали плана остаются параллельны экранным осям, диагонального yaw/поворота плана нет, перспектива и схождение линий отсутствуют, высота стен заметна, но не закрывает комнаты. Нормативная семья проекции для spike: `rotDeg = 0`; наклон от вида строго сверху находится в диапазоне **18–22°**; логическая высота и `zScale` должны давать низкую видимую боковую грань. Точное значение внутри диапазона выбирает ADR по golden/performance, после чего оно меняется только вместе с пересъёмкой новых iso-эталонов. Свободное вращение, пользовательский tilt и пресеты камеры в Stage 1 не входят. ### 4.3 Одна проекция на всё SVG-геометрия и HTML-оверлеи (маркеры, подписи комнат, карточки комнат) проецируются одной и той же функцией. Отдельная «примерно такая же» формула в CSS для оверлеев запрещена: расхождение маркера и плана — главный риск исследования. ### 4.4 Системы координат, pivot и projected frame (B1) ```ts 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`). Не центр viewport и не центр содержимого: иначе появление дальнего объекта или переключение `_showFar` сдвинет уже построенные стены без изменения их геометрии. 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()` с этим центром. 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 исследования, она нормативна. Дополнительно как обязательный контракт Stage 1: - floor plane сохраняет **все** нынешние live-слои и их порядок: room fills/hover, Glow/spill, солнце, backdrop/decor, furniture и vacuum path/outline; состояния HA продолжают обновлять их без перестройки граней; - дверь, окно и ворота сохраняют нынешний floor-plane symbol и live state; вертикальные створки, светлые оконные вставки и новый оконный свет не появляются; - room/device actions, badges, live text и screen-facing HTML остаются теми же пользовательскими поверхностями; объёмный renderer не создаёт новых service paths и не ослабляет lock invariant из `docs/SCOPE.md`; - слои пола проецируются одной матрицей с нижними точками стен; HTML overlay использует те же projected anchors, но сам остаётся screen-facing; - `show_borders: false` не рисует top/side faces, но не выключает room fill, Glow/spill, sunlight или их барьерную семантику; Запреты: - существующее дерево карточки **не оборачивается** в 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 и не создаёт записей в сторе. - **Touch editor: не exposed.** Объёмной геометрии внутри Plan/Devices/Backdrop нет; редакторы используют прежний desktop-first flat contract. - **Touch View и kiosk: полностью поддерживаются.** Pan/pinch, space switching, room/device dialogs, безопасные действия, orientation/background/remount и kiosk gestures проходят тот же safety floor, что flat View. Переключение проекции атомарно и само не считается жестом #82. ### 7.1 Hit-test, непрерывность и киоск (B3, B4, B5) **Попадания.** SVG сам хит-тестит содержимое трансформированного ``, а HTML-маркер получает клик как обычный DOM-узел. Ручной unproject для них — двойное преобразование. - интерактивные потомки SVG и HTML используют нативный hit-test; - цепочка `client → scene → unprojectFloorPoint` применяется только там, где событие сцены действительно должно дать координату плана; - на этапе 1 объёмный вид ничего не создаёт, не редактирует и не перетаскивает, поэтому `_svgPoint()` остаётся плоским; - тесты кликают реальные 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 диалог, отдельным решением. **Touch/Companion.** На coarse pointer toggle в обычном View остаётся минимум 44×44 CSS px; после переключения pinch midpoint и tap/long-press попадают в видимый объект, а не в его flat-позицию. Orientation change, background → foreground и Companion warm-remount используют §4.5/§7.1 и не показывают полуплоский смешанный кадр. Эти случаи release-blocking по `docs/TOUCH-SUPPORT.md`. ## 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). - Camera contract: `rotDeg = 0`, диапазон tilt 18–22°, отсутствие perspective term и сохранение параллельности plan axes; поднятая высота не меняет floor round-trip. - Layer contract: state-only HA updates не меняют geometry fingerprint/cache; `show_borders: false` убирает только faces, а не floor layers; door/window/gate Stage 1 geometry не содержит вертикальных leaf/window panels. ### 11.2 Смоки Контракт «ничего не изменилось» (T1) разбит на проверяемые части внутри **одной** сборки: - без параметра и с неизвестным параметром: в DOM нет узлов объёмного вида, нет дополнительных ws/http-запросов и записей в config/layout; - чтение собственного ключа Labs не считается изменением, но при отсутствии операций в URL storage не перезаписывается; - нулевой diff существующих плоских golden-сцен между ревизиями гарантирует остальное. Дальше: кнопка появляется только под флагом и только в обычном View; переключает и возвращает вид; вход в редактор показывает плоский вид, выход восстанавливает; флаг переживает смену вида дашборда, `off` его снимает; iso → remount → тот же кадр; iso → снять флаг → remount → плоский кадр без вуали; загрузка киоска с сохранённым `iso` и возврат в плоский через URL. Отдельный smoke live-layer parity последовательно меняет room fill/hover, несколько Glow/spill sources, солнце, backdrop/decor, furniture и vacuum state; между flat и iso совпадают resolved source count/state, service-action outcome и логические floor anchors, а geometry build counter не растёт от HA-only update. Проверяются текущие opening symbols/states без vertical leaf/window DOM. Touch/Companion smoke: toggle имеет ≥44×44 CSS px; pinch и space switching в iso; tap/long-press по реальному DOM room/device/opening; orientation resize; background/foreground и warm-remount; kiosk с сохранённым iso и аварийным `off`. В каждом случае нет phantom click, unintended edit/service call, смешанного flat/iso frame или permanently stuck View. ### 11.3 Golden (M7) Минимальная матрица этапа: desktop dark (смешанные толщины, проёмы, Glow и солнце, устройства); desktop light (тема, затенение граней, паритет фильтров); узкий мобильный или киоск (fit, совпадение маркеров и подписей, отсутствие обрезки); `show_borders: false` (стены не нарисованы, заливка и Glow на месте). Каждая iso-сцена показывает `rotDeg = 0`: стены, параллельные plan X/Y, не получают диагонального поворота. Референсы владельца не являются baseline Stage 1 и не принимаются как обещание фотореалистичных материалов. Ветка активации флага добавляется в `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` делает её мгновенной). ### 11.6 Матрица доказательств AC | AC | Доказательство до code review | Обязательная проверка ревьюера | |---|---|---| | AC1 | unit spy + flat smoke + существующие golden + performance profile | убедиться мутантом, что включение iso без флага краснит smoke | | AC2–AC3 | unit Labs grammar/version + URL smoke | запустить команды и проверить fail-closed malformed/expired | | AC4 | unit camera + desktop/touch smoke + iso golden | проверить чтением отсутствие perspective/free camera и исполнением fixed view | | AC5–AC6 | unit face topology + iso golden | применить один geometry mutant из §11.4 | | AC7 | unit fingerprint/layer contract + live-layer smoke + golden | выполнить HA-only update и проверить cache/source/action parity | | AC8 | projection unit + DOM interaction/touch smoke | внести контролируемый anchor shift и получить красный smoke | | AC9 | editor/warm/touch smoke | проверить View → editor → View и Companion remount исполнением | | AC10 | fallback unit + smoke | инъекция renderer exception, затем повторный HA update | | AC11 | reviewed performance profile на exact SHA | проверить report provenance/environment fail-closed | | AC12 | schema/config source-contract + smoke второй карточки | чтение diff и исполнение flat `houseplan-space-card` | | AC13 | source-contract + opening/live-layer smoke | проверить отсутствие vertical leaf/window и нового window-light layer | | AC14 | touch/Companion smoke + mobile/kiosk golden | исполнить coarse-pointer safety matrix | | AC15 | review документации и provenance | проверить issue ↔ spec ↔ SCOPE и отсутствие user changelog | ## 12. Acceptance criteria 1. **AC1 (`unit` + `smoke` + `golden` + `performance`):** при выключенном флаге карточка сохраняет прежние DOM, запросы, сторы, flat golden и бюджет; geometry/cache объёмного вида не создаются. 2. **AC2 (`unit` + `smoke`):** флаг включается query/hash по §2.2.1, реактивно переживает навигацию и снимается через `-iso`/`off` без переписывания URL. 3. **AC3 (`unit`):** malformed registry/version и достигнутый `expires` fail-closed; ни URL, ни storage не включают мёртвый флаг. 4. **AC4 (`unit` + `smoke` + `golden`):** Labs View использует один почти верхний ортографический ракурс: `rotDeg = 0`, tilt 18–22°, без перспективы; план не повёрнут диагонально, комнаты не перекрыты стенами, flat default. 5. **AC5 (`unit` + `golden`):** physical walls, partitions и columns имеют непрерывные top/visible side faces O(E); virtual boundaries остаются flat. 6. **AC6 (`unit` + `golden`):** door/window/gate opening — full-height gap без top/side strip внутри; jamb faces присутствуют, независимые extra bodies не вырезаются. 7. **AC7 (`unit` + `smoke` + `golden`):** room fills/hover, Glow/spill, солнце, backdrop/decor, furniture и vacuum сохраняют state, source count, порядок, actions и barrier semantics между flat/iso; HA-only update не rebuild-ит geometry; второго light layer нет. 8. **AC8 (`unit` + `smoke`):** SVG floor, markers, room labels/cards и native hit targets используют один projection snapshot; anchor error ≤1 CSS px; toggle сохраняет scalar zoom и logical floor center. 9. **AC9 (`smoke`):** все редакторы flat; View → editor → View, space change и warm-remount не смешивают projections, не меняют config/history/view intent. 10. **AC10 (`unit` + `smoke`):** renderer/projection exception даёт latched flat fallback на fingerprint, отражается кнопкой, не стирает preference и не повторяется на каждом HA update. 11. **AC11 (`performance`):** профиль §8.2 зелёный на exact SHA; pan/zoom и HA update не recompute geometry, flat path остаётся в noise budget. 12. **AC12 (`unit` + `smoke` + code review):** plan schema/backend/import-export не меняются; `houseplan-space-card` остаётся flat; Labs не создаёт network calls и не гейтит migrations. 13. **AC13 (`unit` + `smoke` + code review):** Stage 1 сохраняет нынешние floor-plane opening symbols и states; vertical leaf/window panels, floor edges, new window light и reference-grade materials отсутствуют. 14. **AC14 (`smoke` + `golden`):** desktop View, touch View и kiosk полностью работоспособны; toggle ≥44×44, pinch/tap/long-press/space/orientation/ background/remount безопасны; touch editors остаются flat/not exposed. 15. **AC15 (code review):** `SCOPE`, issue, spec, internal Labs/ISO docs и `STATUS` согласованы; en+ru i18n ключи toggle перечислены; README, User Guide и оба changelog не обещают скрытую функцию. ## 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/SCOPE.md` — узкое owner-approved исключение для детерминированного 2.5D без расширения в photorealistic/free-camera/interior editor; - `docs/ISOMETRIC.md` — внутреннее описание координат, проекции, топологии граней и ограничений; - запись про Labs в `docs/DEVELOPMENT.md` и `AGENTS.md`: как включать, как заводить флаг, когда он умирает; - `docs/STATUS.md` — строка о скрытом этапе; - issue #89: ссылка на это ТЗ как на нормативное, синхронизация scope и acceptance criteria, Project остаётся в `Todo` до фактического старта. Issue не закрывается после ADR — только после всех AC этапа (T5). - После зелёного Stage 1 — отдельная feature issue `S1-new` для Stage 2/public rollout; в ней хранятся новые референсы владельца и решения о vertical doors/windows, materials, shadows/floor edges и публичном UX. Не трогаются: `README.md`, `README.ru.md`, `docs/USER-GUIDE.ru.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. ### 13.3 Предполагаемые файлы реализации Точный список подтверждается ADR, но code review ожидает как минимум: - новый pure geometry/projection слой: `src/iso-projection.ts` и `src/iso-walls.ts` либо эквивалентные модули без Lit/HA; - reusable Labs resolver `src/labs.ts`; - интеграцию только в `src/houseplan-card.ts`, `src/styles.ts`, `src/i18n/en.json`, `src/i18n/ru.json`; `src/space-card.ts` и `src/space-render.ts` разрешены только для negative/source-contract tests, не для iso-rendering; - unit: новые `test/iso-*.test.mjs`, `test/labs.test.mjs` и узкие source-contract/regression additions; - browser: целевые `demo/smoke_isometric_*.mjs`, touch/warm/live-layer paths; - golden matrix/harness и **новые** iso baselines, принимаемые отдельно только по reviewed Linux artifact; существующие flat baselines не обновляются; - performance profile/budget `large-house-isometric-v1` и workflow wiring; - `docs/ISOMETRIC.md`, `docs/DEVELOPMENT.md`, `AGENTS.md`, `docs/STATUS.md`. Backend Python, manifest/HACS metadata, config schema, import/export и package dependencies не меняются. Новая runtime dependency требует возврата в S3 и отдельного архитектурного решения. ### 13.4 i18n, compatibility и security - Новые ключи только для toggle: `view.volumetric` и `view.flat` либо эквивалентные согласованные имена, одновременно EN/RU. Текст берётся из действующей терминологии: «Объёмный вид» / «Плоский вид» и “Volumetric view” / “Flat view”. - Config/schema migration отсутствует; новые localStorage keys namespaced и fail-soft, удаление keys полностью откатывает пользовательское состояние. - Labs parser принимает только известные registry ids, не исполняет и не логирует произвольный input; diagnostics не содержат entity ids, URLs, config или персональные данные. - Новый renderer не создаёт service calls. Все safe actions и единственная sanctioned lock surface остаются прежними. ### 13.5 Откат Пользовательский аварийный откат без новой сборки: `?hp-labs=-iso` или `?hp-labs=off`; flat View немедленно становится effective, сохранённый iso не воскрешается warm-state. Полный code rollback — revert одного принятого behavior commit/серии #89 и удаление `iso` из Labs registry; plan/config/backend данные не требуют обратной миграции. Если Stage 1 ломает flat path, Labs flag не считается достаточным смягчением: issue возвращается в S6 и beta блокируется. ## 14. Вне этапа 1 - Кнопка и любые пользовательские упоминания вне флага. - Пресеты камеры, свободное вращение и tilt. - Полировка дверей, окон и ворот, мягкие тени, floor edges (этап 2). - Геометрическая окклюзия маркеров стенами. - Объёмный вид в `houseplan-space-card`. - Редактирование в перспективе, пользовательская высота, 3D-мебель, WebGL. - YAML-опция карточки для Labs. ## 15. Принятые предположения — можно изменить без пересмотра продукта 1. Имена pure modules и i18n keys из §13.3–13.4 предварительные; review может выбрать более согласованные имена без изменения поведения. 2. Точная wall height, `zScale`, palette и side-face contrast выбираются ADR внутри ограничений §4.2 и требуют новых iso golden, но не нового owner decision. 3. LocalStorage keys версионируются суффиксом `_v1`; внутренняя форма может меняться до публичного Stage 2, если чтение malformed/old данных fail-soft. 4. Stage 2 issue создаётся после зелёного code review/интеграции Stage 1, не обязательно после beta; закрытие самой #89 всё равно следует beta-процессу.