Files
houseplan-card/SPEC-REVIEW-089-isometric-view-stage1.md
T

504 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ревью ТЗ `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 с высоким риском**, но работа станет
декомпозируемой и проверяемой; менять выбранную продуктовую концепцию не нужно.