docs: file reviews where reviews live, retire the #89 draft, honest markers

Three review documents sat in the repository root, committed before the pipeline
existed and before docs/reviews/ did. The directory exists now and the pipeline
writes into it, so they move there and the root stops being a second place to
look.

The #89 spec had a twin: the research draft next to the normative stage1
document, two files for one issue. The draft goes to legacy — it fed the
decisions and is worth keeping, but nothing should read it as current.

ROADMAP.md carried a live link to the Project v2 board that was dropped
yesterday; missed then because the sweep grepped for status-canon wording, not
for every link. And docs/README.ru.md said "verified against v1.60.0" as if
that were fresh — the line is now an explicit warning naming what to trust
instead: USER-GUIDE.ru.md and the changelogs.

Issue: #142
User-Visible: no
This commit is contained in:
Matysh
2026-08-14 11:40:26 +03:00
parent 6c48c6d5c6
commit 8eb4bab7c6
6 changed files with 1073 additions and 4 deletions
+5 -1
View File
@@ -1,6 +1,10 @@
# Документация House Plan
Этот каталог — новая точка входа в русскоязычную документацию House Plan. Материалы ниже сверены с исходным кодом и интерфейсом версии **v1.60.0**.
Этот каталог — точка входа в русскоязычную документацию House Plan.
> **Сверено с версией v1.60.0.** Продукт с тех пор ушёл вперёд (линия 1.64);
> при расхождении этого каталога с `docs/USER-GUIDE.ru.md` и changelog верить им.
> Пересверка каталога — отдельная документационная задача.
| Документ | Для кого | Что внутри |
|---|---|---|
+2 -3
View File
@@ -5,9 +5,8 @@ not a hardcoded feature. Universal (any house, any language, any plan format),
flexible (options instead of assumptions), and measured against the official
**Integration Quality Scale** even though custom integrations are not formally graded.
Current tasks and priorities live only in
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and the linked
[Project v2](https://github.com/users/Matysh/projects/1); this file keeps
historical engineering direction. The original market rationale is archived at
[GitHub Issues](https://github.com/Matysh/houseplan-card/issues) and their labels
(`PROCESS.md` §9); this file keeps historical engineering direction. The original market rationale is archived at
`legacy/docs/PRODUCT-2026-07-05.md`.
Phases 0–6 of the original plan (server-side config, room markup editor, device
@@ -0,0 +1,82 @@
# Code review — issue #68 (contextual help)
Date: 2026-08-12
Branch: `dev`
Scope: `hp-help`, Houseplan help factory, localization contract, dialog/overlay lifecycle,
keyboard and touch interaction, responsive placement, consumers and regression coverage.
## Outcome
The overlay, focus, Escape, outside-click, scroll, Popover/fallback and visual-viewport
paths are internally consistent. The shared dialog overlay registry is used correctly,
the trigger remains reachable in disabled fieldsets through `legend`, and all seven
current call sites have non-empty RU/EN body and ARIA strings.
Four hardening findings were accepted and fixed locally. No model, saved configuration
or user data contract changed.
## Findings and resolutions
### CR68-01 — dead trigger is rendered without help content (P1)
`hp-help` blocked `_openHelp()` when `text` was empty, but `render()` still returned a
focusable button. The result was the exact reported defect: a visible help glyph that
could not open any explanation.
Resolution: `hp-help` renders nothing unless both trimmed `text` and `ariaLabel` exist.
The same predicate guards opening and closes an already-open surface if either value is
removed dynamically.
### CR68-02 — missing translation could be displayed as a key (P1)
The card factory called `t()` directly. Its intended generic fallback returns the key
name when neither dictionary contains a value, so a future incomplete help pair could
produce a real trigger with implementation text such as `marker.foo.help`.
Resolution: the factory now checks the localized value and its derived `.aria` value
through `hasTranslation()` before it creates `hp-help`. English fallback remains valid;
a genuinely absent or whitespace-only pair produces no host and no layout gap.
### CR68-03 — accessible name had a hard-coded English fallback (P1)
Direct use without `ariaLabel` produced `aria-label="Help"`. This violated the issue
contract requiring a complete localized accessible name and made an incomplete component
look valid to keyboard and screen-reader users.
Resolution: the fallback was removed. Missing ARIA copy suppresses the affordance just
like missing visible copy.
### CR68-04 — icon did not follow the product icon system (P2)
The trigger used a font `?`, whose shape and optical alignment depended on the platform
font and did not visually mean “question in a circle”.
Resolution: the glyph is now the shared MDI `help-circle-outline` vector inside the same
32/40 px target. It remains decorative because the button already has a full ARIA label.
### CR68-05 — regression coverage missed incomplete content (P2)
The smoke covered all open/close and overlay paths but never instantiated an empty or
half-configured component, so CR68-01/03 could pass the release gate.
Resolution: the #68 smoke now asserts that empty body and empty ARIA copy create no
trigger, and that restoring a complete pair creates the circled-question SVG.
## Reviewed without changes
- Mouse hover timing, keyboard focus, touch click and the second-Escape dialog path.
- `aria-describedby` only while open; the visible bubble stays hidden from the
accessibility tree to avoid duplicate announcements.
- Exclusive transient-surface ownership with the colour/opacity picker and toast.
- Popover API path and dialog-owned fallback portal.
- Cached dialog scroll-listener cleanup and disconnect cleanup.
- Visual viewport placement, flipping and edge clamping.
- Existing call sites and RU/EN localization parity.
## Verification policy
Per project policy, no tests were run during this local edit. Static type checking,
syntax checking and whitespace validation are recorded in the handoff; the updated
targeted smoke is intended for the next prerelease gate.
@@ -0,0 +1,180 @@
# Код-ревью issue #94 — универсальное «Переключить состояние»
- **Дата:** 2026-08-12
- **Issue:** https://github.com/Matysh/houseplan-card/issues/94
- **Проверенная версия:** локальный `dev` после `v1.62.0-beta.3`, включая
незакоммиченные исправления #95–#97
- **Итог ревью до правок:** changes requested — 2 high, 4 medium, 1 minor
- **Итог после локальных правок:** замечания устранены; проверки отложены до
ближайшего pre-release по принятому правилу владельца
## 1. Охват
Проверены:
1. нормативный алгоритм и acceptance criteria в
`docs/specs/094-universal-state-toggle.md`;
2. pure resolver `src/device-toggle.ts`;
3. target selection через exact binding, device role и `controls`;
4. capability/security/service guards;
5. dialog projection, hint, lossless Save и preview;
6. обычный click, confirmation re-resolve и обработка ошибок;
7. общий cover target для действия и presentation;
8. backend schema и import/export round-trip;
9. unit/smoke-матрица и архитектурная документация;
10. совместимость с визуальной непрерывностью #73 и локальными правками
#95–#97.
## 2. Найденные и исправленные замечания
### CR94-01 — High: domain service ошибочно считался capability конкретной entity
**Было:** `POWER_DOMAINS` разрешал `climate`, `water_heater`, `siren` и
`camera`, если нужный service существовал на уровне domain. Но HA публикует
services для всего domain; неподдерживающая их конкретная entity всё равно
оставалась «исполняемой» в hint, а вызов затем отклонялся Home Assistant.
Это прямо противоречило §9.1 и mutation gate 6 ТЗ. Home Assistant Core
подтверждает entity-level guards:
- Climate `TURN_OFF=128`, `TURN_ON=256`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/climate/const.py
- Water heater `ON_OFF=8`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/water_heater/__init__.py
- Siren `TURN_ON=1`, `TURN_OFF=2`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/siren/const.py
- Camera `ON_OFF=1`:
https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py
**Исправлено:** введён декларативный `POWER_ADAPTERS` с state semantics,
unknown policy и точными feature masks. Feature-gated entity теперь получает
команду только при наличии требуемых bits; service catalog остаётся вторым
guard. Media player и legacy vacuum включены в тот же реестр.
**Покрытие:** параметрические unit-матрицы для всех базовых power adapters и
для climate, media player, siren, water heater, camera, legacy vacuum — как
разрешённые, так и запрещённые/missing-feature варианты; отдельная матрица
`unknown` проверяет полный/неполный capability mask.
### CR94-02 — High: click мог использовать target из сохранённого визуального frame
**Было:** #73 намеренно может некоторое время показывать последний цельный
`_renderDevices` snapshot, но `_clickDevice(ev, d)` разрешал action прямо по
переданному `d`. Если binding/controls изменились до атомарной смены frame,
нажатие без confirmation могло вызвать прежнюю цель. Confirmation уже делал
повторное разрешение, обычный click — нет.
**Исправлено:** в View действие сначала находит текущий `DevItem` в
`this._devices` по стабильному marker id. Action, binding, controls и command
разрешаются только из него; исчезнувший marker даёт no-op. Локальная
House Plan info-card по-прежнему может использовать видимый snapshot — это
безопасная read-only поверхность и намеренный контракт исправления #96.
**Покрытие:** smoke сохраняет старый `DevItem`, меняет controls, перестраивает
live devices и проверяет, что click вызывает только новую группу.
### CR94-03 — Medium: неизвестный persisted action расходил UI и runtime
**Было:** неизвестный token на light проецировался как default `toggle`, тогда
как `toggleOriginOf()` правильно не признавал его toggle-origin. Селектор мог
показать «Переключить состояние», hint оставался пустым, а click был no-op.
**Исправлено:** light default применяется только к действительно отсутствующему
token (`null`, `undefined`, пустая legacy-строка). Неизвестное значение fail-
closed проецируется в локальную карточку; backend по-прежнему отклоняет его при
записи.
### CR94-04 — Medium: legacy cover терял identity после disable в HA
**Было:** legacy `tap_action: cover` искал cover только в active
`device.entities`, если рядом оставался хотя бы один активный sibling. После
disable cover в HA старое явное намерение превращалось в анонимный no-target и
presentation переставал знать прежнюю cover entity.
**Исправлено:** legacy-cover origin сначала сохраняет приоритет активной cover,
а при её отсутствии ищет историческую цель в `allEntities`. Общий resolver
возвращает `ha-disabled` и сохраняет тот же cover identity для hint/presentation,
но более ранняя disabled registry row не может заслонить рабочую cover. Новый
device-role toggle по-прежнему исключает disabled rows.
### CR94-05 — Medium: пустой service catalog считался поддержкой всех services
**Было:** отсутствие/пустой `hass.services` давало optimistic `true` для любого
service. Это нарушало runtime guard из ТЗ и позволяло построить команду без
доказательства её существования.
**Исправлено:** отсутствующий catalog/domain/service теперь означает
`unsupported`. После появления актуального HA snapshot resolver автоматически
пересчитывает hint и command. Синтетический HA в `demo/srv/demo.html` теперь
публикует явный service catalog, поэтому smoke-среда проверяет тот же fail-closed
контракт и не создаёт ложные no-op.
### CR94-06 — Medium: device binding не выбирал первую действительно поддерживаемую entity роли
**Было:** resolver выбирал первую entity «подходящего domain», а затем мог
остановиться на `unsupported`, хотя следующая равноправная entity той же
functional role имела требуемую capability. Это не соответствовало формулировке
§8.1 «первая поддерживаемая entity».
**Исправлено:** проверка идёт по уже выбранной shared functional role.
Capability-unsupported peer можно пропустить только внутри неё; missing,
unavailable и secure identity сохраняются без retarget. Config/diagnostic
switch более слабой роли по-прежнему никогда не подставляется.
### CR94-07 — Minor: статус ТЗ оставался «готово к реализации»
**Исправлено:** ТЗ, specs index, архитектура, STATUS, TESTING и RU/EN changelog
актуализированы под опубликованную beta.3 и этот локальный hardening pass.
## 3. Проверенные инварианты без изменений
- exact `entity:` binding не ищет sibling при unsupported/missing/unavailable;
- raw external controls владеют tap только у explicit toggle и не дают fallback
на собственную entity контроллера;
- passive forced-light marker сохраняет единственное документированное driver-
исключение и дедупликацию;
- partial group вызывает только отображённое доступное подмножество;
- any-on/all-off group semantics соответствует ТЗ;
- lock, alarm и cover classes `garage`/`door`/`gate` остаются secure no-op;
- cover/valve open/close/stop используют одновременно feature bit и service;
- confirmation сравнивает target set, а направление намеренно пересчитывается
по текущему state;
- legacy `cover` и отсутствующий default-light action сохраняются lossless до
явного изменения select;
- backend принимает текущие actions и legacy `cover`, неизвестные tokens
отклоняет; import/export сохраняет action-поля без преобразования;
- отдельного `cover` в текущем UI нет;
- right-click, long press, touch/pinch и confirmation UX этим проходом не
менялись.
## 4. Изменённые файлы
- `src/device-toggle.ts`
- `src/houseplan-card.ts`
- `test/device-toggle.test.mjs`
- `demo/smoke_controls.mjs`
- `demo/srv/demo.html`
- `docs/specs/094-universal-state-toggle.md`
- `docs/specs/README.md`
- `docs/ARCHITECTURE.md`
- `docs/STATUS.md`
- `docs/TESTING.md`
- `docs/CHANGELOG.md`
- `docs/CHANGELOG.ru.md`
## 5. Проверка
Локально выполнены только read-only/static проверки ревью:
- `git diff --check`;
- `npm run typecheck`;
- `node --check test/device-toggle.test.mjs`;
- `node --check demo/smoke_controls.mjs`;
- поиск всех consumers `resolveToggleIntent`, `projectedTapAction`,
`toggleCoverEntity`, `sameToggleCommandTargets`;
- сверка backend schema/import-export;
- сверка capability flags с официальным Home Assistant Core.
Unit, browser smoke, backend tests, build и generated bundles **не запускались**
по правилу проекта: локальные правки делаются без тестов, минимальный целевой
прогон выполняется при следующем pre-release.
@@ -0,0 +1,503 @@
# Ревью ТЗ `docs/specs/089-isometric-view-stage1.md`
Дата ревью: 2026-08-11
Issue: [#89](https://github.com/Matysh/houseplan-card/issues/89)
Проверенная версия ТЗ: локальный `dev`, после `v1.62.0-beta.1`, SHA `2cf5c27`
## Итог
Направление выбрано правильно: фиксированная SVG-проекция, отсутствие новой
модели данных, каноническая геометрия стен, плоские редакторы, скрытая поставка
через Labs и обязательные golden/performance gates хорошо соответствуют
текущей архитектуре House Plan.
Однако статус **«готово к реализации» пока преждевременен**. В ТЗ остаются
восемь блокирующих неоднозначностей. Главная из них — документ описывает
проекцию точек, но не определяет переход между тремя реально существующими
системами координат и не задаёт новый контракт viewport/frame. Если начать
реализацию буквально по текущему тексту, наиболее вероятный результат —
прыжок масштаба при переключении, рассинхронизация HTML-маркеров и SVG, неверный
warm-remount либо двойное обратное преобразование pointer events.
Рекомендация: внести блокеры B1–B8 и существенные замечания M1–M8 в ТЗ, после
чего документ можно переводить в `approved`. Переписывать продуктовую часть
или менять выбранный renderer не требуется.
## Что уже зафиксировано хорошо
1. Labs не меняет backend, schema/config/layout и не попадает в backup.
2. Плоский вид остаётся default и fallback; редакторы остаются плоскими.
3. CSS 3D, WebGL и многократное клонирование SVG явно запрещены.
4. Источник wall geometry — `wallBodiesGeometry()`, а не новый параллельный
контур.
5. Проёмы должны быть настоящими разрывами masonry geometry.
6. Glow сохраняет один регион на источник и один blur на слой по `LIGHT.md`.
7. Кэш не должен зависеть только от `_cfgEpoch`.
8. У stage 1 нет публичного обещания и пользовательской миграции.
---
## Блокирующие замечания
### B1 — Не определены системы координат, pivot и projected frame
**Где:** §4, §7, §8, AC 8.
**Критичность:** blocker.
`projectPoint(p, z, cam)` и `unprojectPoint(screen, cam)` недостаточны для
существующего renderer. Сейчас House Plan различает как минимум:
- plan/model units (`room`, `wall`, `marker`);
- координаты SVG scene/viewBox (`_view`);
- client pixels внутри `.stage` (`_screenToVb()`).
В объёмном виде plan units и scene units перестают совпадать. Кроме того,
`IsoCamera` не содержит pivot/origin и масштаба оси Z. Проекция вокруг `(0, 0)`
сместит план при смене пространства, а старый `_baseVb()` не включает поднятые
верхние грани и начнёт обрезать стены.
**Что добавить в ТЗ:**
```ts
type PlanPoint = readonly [number, number];
type ScenePoint = readonly [number, number];
interface IsoCamera {
rotDeg: number;
tiltDeg: number;
xyScale: number;
zScale: number;
origin: PlanPoint;
}
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. `projectPoint` возвращает **scene**, а не screen/client coordinates.
2. `unprojectFloorPoint` инвертирует только плоскость `z=0`; высотная грань не
имеет единственной plan-точки.
3. Pivot — одна фиксированная plan-space константа (рекомендуемо
`[NORM_W / 2, NORM_W / 2]`), а не центр viewport/content frame и не
положение курсора. Иначе появление far marker или переключение `_showFar`
сдвинет уже построенные стены без изменения их геометрии.
4. Wall height сначала переводится из общей константы в plan units, затем
применяется `zScale`; выбранные значения фиксируются ADR.
5. `fit`, pan clamp, home arrow, far-object hint и initial view используют
`projectedFrame`, включающий floor content и поднятые wall faces.
6. Projected frame не зависит от текущего zoom/pan и входит в geometry cache.
Без этого нельзя проверить «не меняет фокус плана скачком» и «не обрезает
объекты».
### B2 — Не задано преобразование viewport при flat ↔ iso и при входе в редактор
**Где:** §7, §10, AC 7–9.
**Критичность:** blocker.
Текущий `_view` хранит прямоугольник именно в координатах текущего SVG. Его
нельзя без преобразования перенести из flat scene в iso scene. Текущий
`_viewModeSnap` также хранит `cx/cy` в flat units. Требование «не менять zoom и
фокус» сейчас не имеет алгоритма.
**Добавить нормативный алгоритм:**
1. Перед сменой проекции получить логический центр пола:
- flat: центр `_view` уже является plan point;
- iso: центр `_view` пропустить через `unprojectFloorPoint`.
2. Построить target frame и target fit.
3. Сохранить тот же scalar zoom.
4. Спроецировать логический центр в target scene и вызвать `_applyView()` с
этим scene center.
5. Не переиспользовать raw `x/y/w/h` между видами.
6. Вход в editor выполняет тот же iso → flat переход; выход — flat → прежний
view kind. Смена пространства внутри editor сбрасывает старый snapshot по
существующему правилу.
Предпочтение вида (`flat|iso`) и viewport — разные сущности. В localStorage
пишется только предпочтение и существующий scalar zoom; raw viewport остаётся
runtime/warm state.
### B3 — ТЗ не совместимо с `docs/WARM-REMOUNT.md`
**Где:** §7 «Непрерывность», §10.
**Критичность:** blocker.
#73 переносит через `warmBoot` точный `_view`, `_viewModeSnap`, mode и
fingerprint кадра. После введения iso один и тот же `ViewRect` имеет два разных
смысла. Если новый экземпляр восстановит iso rectangle в flat mode (например,
флаг снят/истёк) либо наоборот, получится именно тот скачок/пустой кадр, который
#73 устраняет.
**Добавить:**
- warm viewport хранит `projection: 'flat'|'iso'` и `logicalCenter`;
- raw `_view` усыновляется только при совпадении space, projection и активного
Labs contract;
- при несовпадении восстанавливаются scalar zoom + logical center через
алгоритм B2, а не чужой rectangle;
- frame fingerprint включает effective projection и iso geometry fingerprint;
- выключение/expiry Labs никогда не может воскресить iso DOM из memo;
- отдельный smoke: iso → remount → тот же iso frame; iso → снять flag →
remount → корректный flat frame без veil/flash.
### B4 — Правило «все попадания через unproject» технически неверно
**Где:** §7 Pointer, §11 smoke Pointer.
**Критичность:** blocker.
SVG сам hit-тестирует элементы внутри трансформированного `<g>`. Room hover и
SVG opening symbols не нужно вручную unproject-ить: это даст двойное
преобразование. HTML marker также получает click как обычный DOM-элемент.
Кроме того, marker drag выполняется в Device editor, а по этому же ТЗ все
редакторы плоские; smoke «перетаскивание маркера в объёмном виде» противоречит
scope.
**Заменить правило на:**
- SVG/HTML interactive children используют нативный DOM/SVG hit-test;
- pan и zoom anchor работают в scene coordinates;
- `client → scene → unprojectFloor` применяется только там, где stage event
действительно должен получить plan coordinate;
- в stage 1 iso mode не создаёт/редактирует geometry и не перетаскивает
markers, поэтому editor `_svgPoint()` остаётся flat;
- тесты кликают реальные room/device/opening DOM targets и проверяют action;
отдельный unit проверяет `client → scene → floor` для будущего использования.
### B5 — Kiosk UX противоречит фактическому DOM
**Где:** §3, §7, AC 2/4.
**Критичность:** blocker.
ТЗ обещает кнопку «в режиме просмотра (и в киоске) рядом с шапкой». В текущем
kiosk вся `.hdr.kioskhide` имеет `display:none`; такой кнопки физически не
будет. Одновременно §7 говорит, что скрытая панель не должна лишить пользователя
возврата в flat.
Для скрытого stage 1 рекомендуется закрепить простой вариант:
1. Кнопка существует только в обычном View под активным Labs.
2. Kiosk читает последнее per-space предпочтение этого браузера.
3. `hp-labs=-iso` или `hp-labs=off` — обязательный аварийный путь: kiosk сразу
становится flat и не может восстановить iso из warm memo.
4. В kiosk нет новой панели/диалога stage 1.
5. Smoke покрывает загрузку kiosk с сохранённым `iso` и возврат в flat через
URL operation.
Если владельцу нужен переключатель прямо в kiosk, его надо отдельно поместить
в существующий long-press kiosk dialog; «рядом с шапкой» всё равно неверно.
### B6 — Грамматика Labs содержит противоречия и ломает комбинированный hash
**Где:** §2.2–2.4.
**Критичность:** blocker.
Не определено:
- кто сильнее при одновременных `?hp-labs=` и `#hp-labs=`;
- является URL полным replacement или операциями над storage;
- что делает `iso,-iso`, `off,iso`, повторный параметр;
- §2.2 требует не удалять параметр из URL, а §2.3 говорит, что `off` «очищает
и то, и другое»;
- как `#space=x&hp-labs=iso` сохраняет существующий deep link;
- что происходит при `history.back()`/`popstate`.
**Предлагаемый точный контракт:**
1. База — валидный набор из storage.
2. Query operations применяются слева направо, затем hash operations слева
направо; hash сильнее, потому что именно он реактивен внутри Lovelace.
3. `id` добавляет, `-id` удаляет, `off` очищает набор в этой позиции; следующие
токены снова могут добавлять.
4. Повторные `hp-labs` обрабатываются в порядке появления.
5. Если в URL был хотя бы один известный operation или `off`, итог пишется в
storage. Неизвестные значения сами по себе storage не переписывают.
6. URL никогда не переписывается механизмом Labs. Из §2.3 убрать слова об
очистке URL: `off` очищает **effective set и storage**, но остаётся видимым.
7. Hash разбирается общим helper вместе с `space`; оба порядка параметров и
percent-encoding тестируются. `_hashSpace()` не остаётся вторым regex parser.
8. `hashchange` реактивен; `popstate` перечитывает query/hash, если URL реально
сменился без reload.
### B7 — Не определена топология side faces и смысл «нет торцов в проёме»
**Где:** §5, AC 3/5/6.
**Критичность:** blocker.
`wallBodiesGeometry().geom` — MultiPolygon с внешними и внутренними rings, уже
после union, junction patches и opening cuts. «Граничные рёбра» недостаточно:
нужно определить winding, outward normal, holes, culling и порядок отрисовки.
Фраза «без торцов внутри проёма» двусмысленна. При полном разрыве стены
вертикальные jamb faces по краям проёма являются корректной частью объёма;
запретить их — значит получить визуально обрезанную плёнку вместо стены.
**Добавить:**
- faces строятся непосредственно из rings канонического MultiPolygon после
union/cuts; исходные room edges для extrusion не используются;
- winding нормализуется один раз, outward normal учитывает outer/hole ring;
- face видима по знаку dot product normal и фиксированного view direction;
- для фиксированной камеры задаётся детерминированный stable depth order;
- opening slot создаёт две exposed jamb faces по концам разрыва — они нужны;
- запрещены face/полоса, пересекающая сам gap, и cap на floor тоннеля;
- на stage 1 дверь, окно и ворота являются full-height gaps осознанно, так как
модель не хранит высоту подоконника;
- opening никогда не вырезает coincident partition/column — сохраняется
текущий порядок union extras после room opening cuts;
- top face использует whole geometry с `fill-rule:evenodd`;
- unit fixtures включают outer ring, hole, multipolygon, T/X join, opening у
угла и coincident independent body.
### B8 — Fallback может зациклить exception и оставить кнопку во лжи
**Где:** §9, AC 10.
**Критичность:** blocker.
«Вернуться в flat на этом кадре» не отвечает на вопросы: будет ли следующий
Lit render снова падать, что показывает `aria-pressed`, сохраняется ли `iso` в
localStorage и когда разрешён retry.
**Добавить state machine:**
- `desiredView` — сохранённое предпочтение;
- `effectiveView` — реально нарисованный `flat|iso`;
- исключение в pure geometry/iso template ловится на границе
`renderIsoScene()`, для `(space, geometryFingerprint)` ставится session latch;
- при latch effective view = flat, iso geometry больше не вызывается на каждый
HA state update;
- конфиг/layout и сохранённое предпочтение не меняются автоматически;
- кнопка отражает `effectiveView` (`aria-pressed=false`), явное повторное
нажатие или новый geometry fingerprint очищает latch и делает один retry;
- console error содержит issue, space, fingerprint и короткий reason, но без
config/entity payload; один раз на latch;
- ошибка HTML overlay projection также входит в эту границу, иначе получится
«стены flat, markers iso».
---
## Существенные замечания
### M1 — Spike ADR должен фиксировать больше, чем выбор renderer
Сейчас D6 требует ADR, но его обязательные решения не перечислены. ADR должен
закрыть до основной реализации:
- формулу и pivot проекции;
- camera constants, wall-height units и zScale;
- top/side fill, stroke, hatch и side shading в light/dark theme;
- ring normalization, face visibility и depth order;
- z-order floor → Glow/sun/decor → faces/top → screen-facing HTML overlays;
- осознанное правило stage 1: markers/room cards всегда выше wall faces и не
получают геометрическую occlusion;
- projected frame и flat↔iso viewport conversion;
- результат проверки SVG filter/clip/mix-blend на Chromium, Firefox, WebKit;
- причины отказа от проигравшего прототипа.
До ADR issue остаётся в статусе spike/implementation-prep, не renderer-ready.
### M2 — Fingerprint перечисляет не все входы iso geometry
В §8 добавить как минимум:
- `room_drafts` и их segment thickness;
- нормализованные `openCuts`/virtual intervals;
- canonical opening cuts;
- partitions и columns с shape/angle/diameter;
- `cell_cm`, grid pitch, coordinate scale/NORM;
- camera constants и wall-height constant;
- версию алгоритма projection/faces.
Массивы должны сериализоваться детерминированно, числа — нормализоваться как в
существующих geometry fingerprints. Display state (`hover`, HA states,
`show_borders`) не должен инвалидировать geometry cache. `show_borders:false`
просто не рисует cached top/sides, но physics остаётся прежней.
### M3 — `since`/`expires` требуют точной version semantics
В проекте нет зависимости `semver`; строкового сравнения допускать нельзя.
Зафиксировать parser `major.minor.patch[-prerelease]`, fail-closed для
некорректной registry entry и инвариант `since < expires`.
Рекомендуемое продуктовое правило: сравнивать numeric core, поэтому
`1.65.0-beta.1` уже достигает `expires: 1.65.0` и не тащит мёртвый флаг в новый
release cycle. Добавить тесты `1.64.9`, `1.65.0-beta.1`, `1.65.0`, malformed.
### M4 — Не определён runtime owner механизма Labs
Нужно указать, что availability flags глобальны для загруженного JS-модуля, а
effective `flat|iso` остаётся состоянием конкретной карточки/пространства.
Один module-level resolver/subscription не должен создавать по listener на
каждый render.
`window.__hpLabs` должен иметь нормативную форму, например frozen sorted array:
```ts
Object.freeze(['iso'])
```
При изменении URL property заменяется новым frozen array, все подключённые
карточки получают update. Нельзя отдавать внутренний mutable `Set`.
### M5 — Scope `houseplan-space-card` не указан
В репозитории есть второй renderer: `src/space-card.ts` + `src/space-render.ts`.
Текущий текст можно прочитать как требование объёмного вида для обеих карточек.
Рекомендация для stage 1: явно записать, что `houseplan-space-card` остаётся
flat и Labs `iso` на него не влияет. Его поддержка — отдельный будущий scope.
Иначе придётся сразу заводить вторую композицию сцены, что противоречит цели
скрытого первого этапа.
### M6 — Performance contract не совпадает с существующей инфраструктурой
`compare.mjs` использует profile-specific budgets, noise allowance,
relative+absolute thresholds и exact same runner. Просто потребовать «≤20% по
трём полям» недостаточно; `longTask.maxSingleMs` особенно нестабилен около
нуля, а `modelReadyMs` почти не измеряет переключение renderer.
Добавить отдельный профиль `large-house-isometric-v1`:
- текущий benchmark harness запускает candidate bundle с `hp-labs=iso` и
переключает view; тот же harness запускает base bundle, который игнорирует
неизвестный flag и остаётся flat;
- profile id в обоих reports одинаков, runtime/browser/fingerprint проверяются
существующим fail-closed контрактом;
- отдельный reviewed budget JSON задаёт 20% relative allowance **плюс**
абсолютный noise allowance;
- обязательные метрики: first stable iso frame, view toggle, pan/zoom,
HA-state update, space switch, long-task count/total/max, heap growth,
iso-cache entries/growth, rendered devices;
- candidate-only prerelease smoke получает абсолютные ceilings;
- перед завершением этапа выполняется exact-SHA full performance workflow, а
не локальное сравнение с другой машиной.
Фразу «flat не должен подорожать вообще» заменить на проверяемое: при
выключенном флаге iso geometry/cache/DOM отсутствуют и нет дополнительного
прохода по room/device collections; timing находится внутри noise allowance.
### M7 — Golden coverage слишком мала для новой системы координат
Две картинки не покрывают заявленный scope. Минимальная матрица stage 1:
1. desktop dark: mixed walls + openings + Glow/sun + devices;
2. desktop light: theme/shading/filter parity;
3. mobile portrait или узкий kiosk: fit, marker/label alignment, no clipping;
4. `show_borders:false`: стены не нарисованы, room fill/Glow сохраняются;
5. remount/toggle sequence проверяется smoke, а финальный кадр — golden при
необходимости.
Существующие flat baselines действительно не принимаются заново, если diff не
нулевой. Новые baselines принимаются только из полного Linux CI artifact по
действующему HP-QA-01 контракту.
### M8 — A11y toggle contract неполон
Для кнопки добавить:
- `aria-pressed="true|false"` по `effectiveView`;
- стабильный accessible name «Объёмный вид» / `Volumetric view`;
- focus остаётся на той же кнопке после переключения;
- active visual state не кодируется только цветом;
- DOM/tab order устройств и room actions совпадает с flat;
- stage 1 не добавляет projection animation: swap атомарный. Если анимация
будет добавлена через #82, `prefers-reduced-motion` делает её мгновенной.
---
## Замечания к тестам и формулировкам
### T1 — «innerHTML до и после ветки» нужно сделать воспроизводимым
Обычный тест не может сравнить текущий commit с кодом до ветки. Разделить
контракт:
- в одном candidate build сравнить no-param и unknown-param: нет iso nodes,
нет дополнительных WS/HTTP и config/layout writes;
- golden гарантирует нулевой diff существующих flat scenes между revisions;
- unit spy подтверждает, что iso geometry builder не вызывался;
- чтение собственного Labs localStorage не считать сетевым/сторным изменением,
но при отсутствии URL оно не должно переписывать ключ.
### T2 — Мутанты должны быть исполнимыми
Пункт «отдельная формула проекции HTML» нельзя надёжно поймать текстовым
поиском. Нормативный mutant: внести controlled offset только в overlay mapping;
smoke должен увидеть расхождение anchor больше 1 CSS px. Для cache mutant тест
меняет geometry in-place без `_cfgEpoch`; iso faces обязаны обновиться. Для
layer-copy mutant тест проверяет upper bound DOM face count как `O(E)`.
Для каждого из пяти mutants сохранить команду/patch id и имя краснеющего теста
в PR/issue evidence; ручной тезис «проверено» недостаточен.
### T3 — Opening wording
В AC 6 заменить «без швов и торцов внутри проёма» на:
> Проём является full-height gap. Внутри gap нет wall top/side полосы;
> вертикальные jamb faces на двух границах masonry разрыва являются ожидаемыми.
Это снимает конфликт с B7 и делает golden однозначным.
### T4 — Первый запуск и сохранённое предпочтение
В §9/§10 уточнить:
- без записи `houseplan_card_view_v1[space]` effective view всегда flat, даже
при активном Labs;
- toggle в обычном View пишет `flat|iso` per space;
- при неактивном/expired flag сохранённое `iso` игнорируется, не меняет DOM и
не попадает в warm memo;
- вход/выход editor не перезаписывает предпочтение;
- fallback B8 не перезаписывает предпочтение автоматически.
### T5 — Backlog — канонический источник
Issue #89 сейчас говорит «черновик продуктового и технического решения», тогда
как файл говорит «готово к реализации». По `AGENTS.md` Issue/Project являются
каноническими. После принятия новой редакции:
- добавить в body issue ссылку на stage 1 spec как нормативную;
- синхронизировать scope/acceptance criteria issue с утверждённой редакцией;
- оставить Project `Todo` до фактического начала, затем перевести в
`In progress`;
- не закрывать #89 после одного spike ADR: закрытие только после всех AC этапа.
---
## Рекомендуемая новая структура нормативных разделов
Чтобы не раздувать основной текст, достаточно добавить четыре подраздела:
1. **§4.4 Coordinate spaces and viewport** — B1, B2.
2. **§5.1 Wall-face topology and visual tokens** — B7, M1.
3. **§7.1 Native hit testing and warm continuity** — B3, B4, B5.
4. **§2.2.1 Labs operation precedence and version lifecycle** — B6, M3, M4.
Остальные замечания можно встроить в §8–§13.
## Definition of Ready после следующей итерации
ТЗ можно считать готовым к реализации, когда:
- [ ] определены plan/scene/client spaces, camera pivot/zScale и projected frame;
- [ ] записан алгоритм flat↔iso viewport conversion;
- [ ] обновлён warm-remount contract;
- [ ] исправлено pointer rule и убран iso marker-drag smoke;
- [ ] выбран однозначный kiosk escape contract;
- [ ] полностью определена Labs grammar и expiry semantics;
- [ ] определены ring/face/jamb/depth rules;
- [ ] определена fallback state machine;
- [ ] ADR имеет обязательный список решений;
- [ ] fingerprint содержит все входы;
- [ ] указан scope `houseplan-space-card`;
- [ ] создан исполнимый performance profile/budget plan;
- [ ] расширена golden/a11y/mutant matrix;
- [ ] issue #89 ссылается на утверждённое ТЗ и не противоречит ему.
После этого оценка stage 1 остаётся **L/XL с высоким риском**, но работа станет
декомпозируемой и проверяемой; менять выбранную продуктовую концепцию не нужно.
+301
View File
@@ -0,0 +1,301 @@
# #89 — Опциональный объёмный 2.5D/изометрический вид плана
**Статус:** исследование завершено; черновик продуктового и технического решения
**Дата:** 2026-08-11
**Приоритет:** P1 — высокая продуктовая ценность, высокая стоимость реализации
**Область:** режим просмотра и киоск; редакторы в первую версию не входят
**Исходные референсы:** предоставленные владельцем проекта `isometric-plan-demo.zip` и изображение `photo_2026-08-11_18-30-38.jpg`
## 1. Краткое решение
Добавить в Houseplan опциональный **«Объёмный вид»** — стилизованное 2.5D-представление существующего плана с приподнятыми стенами, видимыми боковыми гранями, мягкой тенью и изометрической камерой.
Это не отдельный редактор и не новая модель плана. Комнаты, стены, проёмы, перегородки, колонны, устройства, Glow и все состояния продолжают использовать текущие данные. Меняется только способ их проекции и визуализации.
Рекомендуемая первая пользовательская версия:
- переключаемый плоский/объёмный вид;
- только режим просмотра и киоск;
- фиксированный проверенный ракурс без свободного вращения камеры;
- редакторы всегда открываются в обычном плоском виде;
- без WebGL/Three.js и без 32 копий всего SVG;
- стены строятся из канонической геометрии Houseplan, проёмы действительно вырезаются из объёма;
- Glow, солнечные лучи, заливки и hover остаются функционально теми же эффектами на плоскости пола;
- диалоги и tooltips остаются обычным экранным UI и не наклоняются вместе с планом.
## 2. Пользовательская ценность
Ценность оценивается как **очень высокая**:
- план превращается из утилитарной схемы в визуально сильный центр dashboard;
- различия стен, комнат, проёмов и внешних зон считываются быстрее;
- скриншоты и демонстрации продукта становятся существенно убедительнее;
- Houseplan получает заметное визуальное отличие от обычных floorplan-карточек;
- существующая модель данных уже содержит большую часть необходимой геометрии — пользователь не строит второй план.
При этом объёмный вид не должен становиться обязательным. Плоский вид точнее для редактирования, плотных планов и слабых устройств и остаётся полностью поддерживаемым.
## 3. Что показало исследование демо
Переданный demo — удачный визуальный proof of concept, но не готовая архитектура для Houseplan.
Он использует:
- один SVG с вручную заданной геометрией;
- CSS `perspective`, `rotateX`, `rotateZ` и `preserve-3d`;
- 32 копии одного контура стен, поднятые последовательными `translateZ`, чтобы имитировать толщину по высоте;
- отдельный верхний контур стен;
- статические подписи и устройства внутри наклонённой плоскости;
- drag для вращения и wheel для масштаба.
Для маленькой статической сцены это работает и хорошо показывает продуктовый эффект. Прямой перенос подхода в Houseplan не подходит, потому что:
1. Геометрия Houseplan динамическая: смешанная толщина, T/X-стыки, перегородки, круглые и квадратные колонны, двери, окна и ворота.
2. В карточке есть тяжёлые динамические слои: Glow, spill через проёмы, солнечные лучи, hover, vacuum overlays и HTML-маркеры.
3. 20–32 копии сложных wall paths заметно увеличат DOM, raster/compositing cost и вероятность швов.
4. CSS 3D-контекст разрушается или уплощается рядом свойств, уже используемых карточкой: `filter`, opacity на предках, `clip-path`, mask и часть blend/compositing-сценариев.
5. Сейчас day/night использует `filter: brightness(...)` на `.zoomwrap`, а Glow — `mix-blend-mode: screen`; слепое помещение существующего дерева в CSS 3D создаёт высокий риск регрессий.
6. Координаты устройств и room labels сейчас считаются для плоского `viewBox` и выводятся HTML-слоем. Их нужно проецировать тем же каноническим преобразованием, иначе они разойдутся с планом.
Вывод: demo подтверждает **реализуемость и ценность визуального направления**, но production-рендерер нужно строить на геометрии Houseplan.
## 4. Рекомендуемая архитектура
### 4.1. Не полноценный 3D, а детерминированная 2.5D-проекция
Для первой версии рекомендуется обычный SVG с явной 2D-проекцией, а не CSS-стопка и не WebGL:
- floor/decor/room/glow-слои помещаются в SVG-группу с одной аффинной изометрической матрицей;
- верх стены — каноническое объединённое тело стены, спроецированное и сдвинутое на визуальную высоту;
- боковые грани — четырёхугольники, построенные из граничных рёбер wall body и вектора высоты;
- видимые боковые грани фильтруются по направлению камеры и сортируются по глубине;
- HTML-маркеры получают экранные координаты через ту же чистую функцию проекции;
- tooltips, dialogs и системные controls остаются вне проецируемой сцены.
Это позволяет сохранить обычный SVG compositor, mask/blend-поведение Glow и предсказуемую деградацию, а число элементов растёт по числу реальных граней, а не умножается на 20–32 слоя.
### 4.2. Канонические источники геометрии
Нельзя создавать вторую независимую модель стен. Рендерер обязан использовать:
- `wallBodiesGeometry()` / объединённые wall bodies из `src/wall-thickness.ts`;
- текущие opening cuts/tunnel geometry;
- существующую модель перегородок и колонн;
- текущие room polygons и decor geometry;
- текущий канонический light resolver и описанную в `docs/LIGHT.md` семантику.
Возможные новые модули:
- `src/render/isometric-projection.ts` — чистая математика проекции и обратного hit mapping;
- `src/render/isometric-walls.ts` — top/side faces и depth ordering;
- `src/render/isometric-scene.ts` — композиция слоёв;
- детерминированные fixtures/golden matrix отдельно от основной карточки.
Кэш геометрии строится по содержательному fingerprint геометрии, а не только по `_cfgEpoch`.
### 4.3. Высота стен
В модели сейчас нет полноценной высоты помещений/стен. В первой версии высота — **визуальный параметр представления**, а не архитектурный размер:
- единое безопасное значение по умолчанию;
- один общий параметр для карточки/пространства, если настройка вообще выводится пользователю;
- одинаковая высота у обычных стен, перегородок и колонн;
- виртуальные стены остаются линиями пола и не получают объём.
Не следует выводить высоту в сантиметрах: это создаст ложное обещание настоящей 3D-модели.
## 5. UX первой версии
### 5.1. Переключение
- В режиме просмотра появляется компактная кнопка с `mdi:cube-outline` и accessible name «Объёмный вид».
- Повторное нажатие возвращает «Плоский вид».
- Переключение не изменяет геометрию и не создаёт запись в command stack.
- Предпочтение вида хранится отдельно от модели плана; оно не должно создавать конфликтов совместного редактирования.
- В киоске используется настроенный вид по умолчанию; скрытая панель не должна делать возврат в плоский вид невозможным из настроек.
- Вход в любой редактор временно показывает плоский вид. После выхода восстанавливается предыдущий режим просмотра.
Плоский вид остаётся значением по умолчанию для существующих установок и fallback при ошибке/неподдерживаемом окружении.
### 5.2. Камера и навигация
MVP использует один тщательно подобранный ракурс либо 2–3 пресета. Свободное вращение и изменение tilt не входят в первую версию.
Причины:
- свободное вращение конфликтует с pan, pinch zoom, long press и кликами устройств;
- оно требует динамической сортировки граней на каждом кадре;
- маркетинговый эффект достигается фиксированным хорошим ракурсом;
- фиксированный ракурс детерминирован для golden image и поддержки.
Обычный zoom/pan сохраняется. Изменение масштаба должно быть совместимо с #82 и не менять фокус плана скачком при переключении вида.
### 5.3. Устройства и подписи
Для первой версии рекомендуется:
- позиция marker/room label проецируется вместе с планом;
- сама интерактивная карточка устройства остаётся достаточно читаемой и получает мягкую объёмную тень;
- tooltip/dialog всегда экранные и не наклоняются;
- touch target не уменьшается из-за визуального наклона;
- tab order и accessible name совпадают с плоским режимом.
Полностью «лежащие на полу» подписи красивее, но хуже читаются. Допустим компромисс: лёгкое согласование с ракурсом для подложки и screen-facing содержимое. Точный вариант выбирается по прототипу и a11y-проверке.
## 6. Поведение слоёв
| Слой/объект | Поведение в объёмном виде |
|---|---|
| Пол/заливка комнаты | Лежит на нижней плоскости, текущая семантика цвета сохраняется |
| Room hover | Затемняет заливку и подсвечивает внутренние границы без вспышки Glow |
| Физические стены | Верхняя поверхность + видимые боковые грани |
| Перегородки | Как физические стены; комнату автоматически не делят |
| Квадратные/круглые колонны | Экструдируются из своей текущей геометрии |
| Виртуальные стены | Пунктир на плоскости пола, без высоты |
| Дверь | Проём реально разрывает стену; символ состояния сохраняется. Вертикальная створка — polish-этап |
| Окно | Разрыв объёма с отдельным стилизованным оконным элементом; без моделирования реальной высоты подоконника |
| Ворота | Разрыв объёма; две наружные створки сохраняются |
| Opening tunnel fill | Цвет комнаты на полу, без швов; семантика Glow не меняется |
| Glow и spill | На плоскости пола, стеновые барьеры и проёмы работают по `docs/LIGHT.md` |
| Солнечные лучи | На плоскости пола, геометрия и настройки остаются текущими |
| Decor/backdrop | Лежит на floor plane; не создаёт объём автоматически |
| Vacuum path/outline | На floor plane; robot marker остаётся интерактивным |
| Device markers | Проецированная позиция, безопасный z-order и неизменная логика состояний |
| Tooltips/dialogs | Экранный overlay поверх сцены |
| `show_borders: false` | Невидимые стены остаются невидимыми, но продолжают влиять на площадь и свет |
## 7. Обязательные edge cases
Реализация и fixtures должны покрыть:
1. Выпуклые и вогнутые комнаты, отверстия и несколько несвязанных контуров.
2. Смешанную толщину стен, короткие сегменты, T/X-стыки и сложные объединения.
3. Проём у угла, несколько соседних проёмов, дверь/окно/ворота шире толщины стены.
4. Перегородку, примыкающую к стене без визуального шва.
5. Квадратную повёрнутую и круглую колонну.
6. Виртуальную стену между двумя толстыми стенами.
7. Комнату без физических стен и старую конфигурацию без thickness.
8. Glow с несколькими источниками, spill через открытые проёмы и hover комнаты.
9. Day/night brightness, непрозрачные/полупрозрачные заливки и additive blending.
10. Большую подложку, decor, текст, пунктирные линии и мебель.
11. Скрытые/удалённые/недоступные устройства и все режимы device presentation.
12. Touch: pinch не вызывает click, long press не оставляет phantom pan.
13. Возврат на вкладку: сцена не мигает и не пересобирается из пустого состояния.
14. Очень широкий/высокий план, detached terrace/porch и несколько пространств.
15. Светлая/тёмная тема, kiosk, reduced motion и high zoom.
## 8. Производительность и технические ограничения
- Не клонировать весь SVG или wall body десятки раз.
- Число side faces должно быть O(числу граничных рёбер), а не O(рёбра × визуальная высота).
- Pan/zoom не пересчитывает модельную геометрию; меняется только transform/viewBox.
- Геометрия стен пересчитывается только при изменении содержательного fingerprint.
- Цель на детерминированном большом fixture: плавный pan/zoom на desktop и не более 20% регрессии frame time относительно плоского вида.
- Переключение вида не должно показывать пустой/чёрный промежуточный кадр.
- При нехватке возможностей или исключении renderer карточка безопасно возвращается в плоский вид и сообщает диагностируемую причину в dev log.
- Новая тяжёлая runtime-зависимость и WebGL не допускаются без отдельного архитектурного решения.
## 9. Доступность
- «Объёмный вид» — визуальная альтернатива, не отдельный набор функций.
- Все устройства, комнаты и действия доступны с клавиатуры так же, как в плоском виде.
- Focus ring виден и не обрезается стеной/контейнером.
- Screen reader получает те же имена и состояния.
- `prefers-reduced-motion` отключает переход между проекциями, но не сам режим.
- Минимальные touch targets сохраняются в экранных координатах.
- Плоский вид остаётся fallback для пользователей, которым перспектива мешает чтению.
## 10. План реализации
### Этап 0 — технический spike, 2–4 рабочих дня
- детерминированный fixture со стенами, mixed thickness, проёмами, колонной, Glow и устройствами;
- два прототипа: CSS wall slices и явные side faces;
- замеры Chrome/Edge, Firefox и Safari/WebKit;
- проверка Glow/mask/blend, day/night и HTML overlays;
- ADR с окончательным выбором renderer.
Spike не включается пользователям и не считается завершением issue.
### Этап 1 — ship-ready фиксированный объёмный вид, 8–15 рабочих дней
- чистая математика проекции;
- floor plane, wall top и side faces;
- проёмы, перегородки и колонны;
- проекция markers/labels и pointer mapping;
- переключение view/editor/kiosk;
- кэш, fallback, unit/integration/golden/performance gates;
- RU/EN документация и changelog.
### Этап 2 — визуальный polish уровня референса, ещё 8–15 рабочих дней
- улучшенные двери, окна и ворота;
- мягкие тени/ambient depth без дорогого dynamic lighting;
- аккуратные floor/platform edges;
- доводка markers/labels и occlusion;
- дополнительные пресеты камеры и качества.
### Отдельный будущий этап — свободная камера, ещё 5–10+ рабочих дней
Свободное вращение/tilt, gesture arbitration и динамический depth sort не входят в базовый scope. Их ценность ниже, а риск заметно выше.
## 11. Оценка сложности
| Вариант | Срок | Риск | Оценка |
|---|---:|---:|---|
| Демонстрационный CSS stack на одном fixture | 2–4 дня | Средний | Хорош для spike, не для релиза |
| Production MVP с фиксированным ракурсом | 8–15 дней | Высокий | Рекомендуемый первый релиз |
| Визуально отполированный вариант уровня референса | 16–30 дней суммарно | Высокий | Реалистичная конечная цель |
| Свободная камера поверх polished renderer | +5–10 дней | Высокий | Позже, только по подтверждённой ценности |
| Настоящий WebGL/Three.js 3D renderer | 1–2+ месяца | Очень высокий | Не рекомендуется для этой цели |
Общая оценка: **L/XL, высокий архитектурный и визуальный риск, но очень высокая продуктовая отдача**. Сам эффект дешёв в статичном demo; дорого стоит сохранение всей существующей интерактивности и световой модели без регрессий.
## 12. Риски и меры
| Риск | Вероятность/влияние | Мера |
|---|---|---|
| Glow/blend/filter ломает CSS 3D | Высокие | Обычная SVG 2.5D-проекция вместо вложенного `preserve-3d` |
| Неверные T/X-стыки и проёмы | Высокие | Только канонические union bodies + geometry fixtures |
| Маркеры расходятся с планом | Высокие | Одна pure projection для SVG и HTML overlays |
| Падение FPS на больших планах | Средние/высокие | Side faces вместо десятков slices, кэш и perf gate |
| Нечитаемые подписи | Средние | Screen-facing/гибридный marker prototype и a11y review |
| Touch misclick при навигации | Средние/высокие | В MVP нет свободной камеры; существующие pan/pinch правила сохраняются |
| Режим превращается в второй renderer со своей логикой | Высокие | Общие resolvers/geometry; новый слой отвечает только за projection/presentation |
| Референс обещает больше, чем модель умеет | Средние | Называть «Объёмный вид», не «полноценный 3D»; явно ограничить высоту и окна |
## 13. Не входит в первую версию
- редактирование геометрии в перспективе;
- пользовательская высота каждой стены/комнаты;
- 3D-мебель и импорт моделей;
- настоящая физика света и динамические тени;
- свободный orbit camera;
- WebGL/Three.js;
- автоматическое превращение растровой подложки в 3D;
- изменение формата существующих room/wall/opening данных.
## 14. Acceptance criteria пользовательской версии
1. Пользователь может переключить текущий план между плоским и объёмным видом без изменения данных.
2. Существующие планы открываются плоскими и не требуют миграции.
3. В объёмном виде физические стены, перегородки и колонны имеют непрерывные top/side faces; виртуальные стены не экструдируются.
4. Двери, окна и ворота образуют реальные разрывы объёма, без тонких швов и торцов внутри проёма.
5. Glow, spill, sunlight, room fill и hover сохраняют текущую семантику и не мигают.
6. Device markers, room controls, tooltips и dialogs остаются кликабельными, читаемыми и доступными с клавиатуры.
7. Открытие редактора показывает плоский вид; выход восстанавливает предыдущий просмотр.
8. Zoom/pan/fit работают без скачка, phantom click и изменения сохранённой геометрии.
9. При ошибке объёмного renderer доступен безопасный плоский fallback.
10. Пройдены unit tests проекции/side faces, smoke interactions, golden matrix и performance comparison на детерминированных fixtures.
11. Обновлены `README.md`, `docs/USER-GUIDE.ru.md`, RU/EN changelog и release screenshots.
## 15. Рекомендуемая поставка
Фичу лучше вести отдельным релизным циклом:
1. внутренняя beta/spike без публичного toggle;
2. beta с фиксированным объёмным видом и полным fallback;
3. beta с doors/windows/markers polish и performance fixes;
4. stable только после golden review на реальных больших планах и проверки мобильного просмотра.
Главный критерий успеха — не максимальная «трёхмерность», а визуально сильный вид без потери надёжности Houseplan как интерактивной HA-карточки.