Files
houseplan-card/docs/specs/089-isometric-view-stage1.md
T
2026-08-13 13:53:54 +03:00

60 KiB
Raw Blame History

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

  • Issue: https://github.com/Matysh/houseplan-card/issues/89
  • Приоритет: P1, feature
  • Статус ТЗ: на независимом ревью (ревизия 3, решения владельца 2026-08-13). Ревизия 2 предшествует действующему процессу и новым визуальным референсам; эта редакция обязана получить независимое ревью до S5-ready.
  • Исследование и продуктовое обоснование: 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) и <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() не знает про поднятые верхние грани и начнёт их обрезать §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 сам хит-тестит содержимое трансформированного <g>, 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:

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)

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 сам хит-тестит содержимое трансформированного <g>, а 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-процессу.