Files
houseplan-card/docs/reviews/CODE-REVIEW-583-r1.md
T
2026-09-16 06:01:12 +00:00

335 lines
27 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.
# CODE-REVIEW-583-r1
Issue: #583 · Материал: `e2801e4e8b1b7a38be88a6dea210caba0e756783` (заход r1, полный трек)
Ветка: `issue/583-isometric-visual-corrections`
Коммиты в диапазоне `origin/dev...HEAD`: `5cae9350` (fix), `e2801e4e` (test)
## Скоуп проверки
ТЗ (см. тело issue #583, раздел `## ТЗ`) описывает четыре независимых
presentation-дефекта скрытого 2.5D режима, найденных на реальном стенде после
#570:
1. декоративные contact/leaf тени стен и дверных полотен — убрать;
2. дверь между Hallway и Under-Stairs Closet визуально «вросла» в стену —
починить геометрию/порядок отрисовки;
3. иконки устройств/замков после проекции 2D→2.5D налегают друг на друга —
нужен общий детерминированный collision-pass;
4. серый/двойной контур дверных полотен — убрать нарисованный stroke.
14 AC, полный трек (не light: геометрия + новый алгоритм коллизий).
Диапазон правок: `src/iso-openings.ts`, `src/iso-overlays.ts`,
`src/iso-scene-render.ts`, `src/styles/plan.styles.ts`, соответствующие
unit/contract тесты, `demo/smoke_isometric_contract.mjs`,
`demo/smoke_isometric_live_touch.mjs`, `docs/ISOMETRIC.md`,
`docs/ARCHITECTURE.md`, `docs/STATUS.md`. Никаких изменений вне класса
A/B/C/D (backend, схема, layout, rooms/openings/devices не тронуты — совпадает
с §8 ТЗ «модель данных и миграция»). Trailers на обоих коммитах:
`Issue: #583` / `User-Visible: no`; `docs/CHANGELOG*.md` не менялись — верно
для скрытого экспериментального режима (§14 ТЗ).
## Как проверялось
**Дешёвые гейты этого SHA уже подтверждены зелёным Validate** (мутанты +
push-Validate, ссылки в комментариях issue: run 35059679236 и run
35059894623) — `tsc`/`test`/`build` повторно не гонял ради самого факта
зелёности, но:
- **`npm test` перегнал сам** (не поверил на слово): `2719 passed, 0 failed,
1 skipped` — совпадает с заявленным автором числом.
- **`npm run bundle:sync`** (пересобирает и раскладывает бандл) — зелёный,
`cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js`
и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — три копии
побайтово идентичны, `git status` после пересборки чист (закоммиченный
бандл уже свежий).
- **`npm run bundle:budget`** — зелёный: initial View 291127 B / потолок
291700 B (headroom 9939 B, тот же предупреждающий запас, что и до задачи —
не регрессия этой задачи).
- **`node scripts/no-new-any.mjs --base origin/dev --head HEAD`** — «Новых
any нет» (382 добавленные строки в 4 файлах).
- **`node scripts/check-docs.mjs`** — обязателен, т.к. диф трогает `src/**`.
В строгом режиме (по умолчанию) — ERROR «screenshot source fingerprint is
stale»; это **ожидаемо** и не находка: отпечаток документации считается по
всему `src/**`, поэтому любая фронтенд-правка его обнуляет (PROCESS.md §8).
Проверил, как это классифицирует сам CI: `validate.yml` считает
свежесть скриншотов **предупреждением** на обычном пуше и **ошибкой** только
на PR/кандидате беты/по кнопке (`classify-changes.mjs`/`heavyGatesRequested`).
Прогнал `--screenshots=warn` (режим обычного пуша) — «Documentation checks
passed (7 files, 12 external links)» с тем же warn. Остальное содержимое
`check-docs` (гайды, ченджлоги, скриншот-индекс, внешние ссылки) — зелёное.
- **`node scripts/smoke-select.mjs --base origin/dev --head HEAD`** —
«Прямое совпадение» в основном по символу `cellCm` (широко используемое имя
параметра в десятке несвязанных смоков — слабая связь, не гонял: аналог
примера из инструкции с `smoke_wall_junctions`). Содержательные совпадения:
`demo/smoke_isometric_contract.mjs` (прямое, символ `isoFixedLightTransform`,
напрямую тронутый диффом) и `demo/smoke_wall_thickness.mjs`
(«зарегистрированная связь», символ `openingSymbolOffset`, тронутый в
`leafBasis`). Прогнал оба — **OK**. Дополнительно прогнал
`demo/smoke_isometric_live_touch.mjs` (сам тронут диффом, проверяет ровно
снятые тени/структуру в touch-контексте) — **OK**. Остальные 247 смоков не
гонял: тема (двери/тени/коллизии/иконки) в них не встречается, а полный
прогон — предрелизный гейт (PROCESS §8).
- **`npm run golden:verify`** (полный набор, ~172 сцены) — прогнал целиком,
т.к. диф меняет видимый рендер (геометрия/стили/слои) и AC1-3/8/13 явно
требуют golden-доказательство. Результат: **160 passed, 11 different, 1
error**. Разбор ниже.
- **Perf**: `demo/benchmark_large_house.mjs --profile=large-house-isometric-v1`
прогнал вручную с `--samples=2 --warmups=0` как грубую сверку на порядок
величины (не авторитетный гейт — методика калибровки требует 7 samples +
warmup и сравнение с базовым SHA, которого у меня нет). Цифры (порядка
1-5 сек на разных метриках) не показали разрыва «в разы» вроде прежнего
#160 (9870мс/3500мс потолок). Один замер `resizePreviewMs=5038` при
hardMax 2200 — но без warmup первый замер всегда шумный, вывод неавторитетен,
не привожу как находку. Формальный `performance_smoke`/Full Performance —
предрелизный гейт (PROCESS §8, `heavyGatesRequested()` не включает его на
обычном пуше; выполняется на кандидате беты/PR/nightly). AC11 явно требует
его как доказательство — это остаётся долгом перед бетой, не блокером
ревью.
- **`node --test test/single-source-numbers.test.mjs`** — зелёный. Диф не
вводит новую пользовательскую величину, показываемую дважды (48 CSS px —
внутренний геометрический параметр, не отображается пользователю) —
правило неприменимо.
- **Инварианты модели** (`npm run invariants`) — не гонял: диф не трогает
`layout`, room edges, `marker.space`, `open_spans` или любую другую
каноническую запись геометрии — только derived-presentation слой 2.5D
(подтверждено чтением диффа: только `src/iso-*.ts` + стили + доки/тесты).
§8 ТЗ прямо это утверждает.
- **`python -m pytest tests_backend`** — не гонял, Python не тронут.
Условие честности: гейты, которые НЕ гонял — полный `smoke`-набор (250 файлов,
кроме трёх целевых), калиброванный `performance_smoke`/Full Performance,
`pytest`, `model-invariants`. Причина каждого — выше.
## Разбор кода
### Тени (AC1)
`src/styles/plan.styles.ts` убирает `.iso-contact-shadow`/`.iso-leaf-shadow` и
их forced-colors/no-filter упоминания, оставляя только `.iso-ambient-shadow`.
`src/iso-scene-render.ts`: `renderIsoShadows()` — теперь чистый stub
(`emptySvg()`), `renderIsoDefs()` больше не принимает `'shadows'` как `root`,
фильтры `hp-iso-contact-shadow`/`hp-iso-leaf-shadow` удалены. Тест
`isometric-contract.test.mjs` проверяет ровно один `isoFixedLightTransform`
(было три) и отсутствие текстовых следов удалённых классов/фильтров в
исходнике. Смок `smoke_isometric_contract.mjs` подтверждает в браузере:
`fixedLightVectorShared` теперь `shadowNodes.length === 1` и явно проверяет
`!root().querySelector('.iso-contact-shadow, .iso-leaf-shadow, #hp-iso-contact-shadow, #hp-iso-leaf-shadow')`.
`Glow`/`SUN` не тронуты (проверено чтением: единственные правки — в
`iso-openings.ts`/`iso-overlays.ts`/`iso-scene-render.ts`/стилях, ни один из
них не пересекается с sun/glow модулями). **Соответствует AC1.**
### Дверь «вросла» в стену (AC2, AC3)
`src/iso-openings.ts` `leafBasis()`: для `door`/`gate` смещение (`offset`)
теперь равно `input.face` (реальная выбранная физическая грань стены) вместо
`openingSymbolOffset(...)` (центр стены). Сохранённые координаты и Flat-символ
не меняются — офсет применяется только внутри производного Iso-базиса, что
проверено алгебраически: `origin = (x,y) + face.offset`, а
`selectedStart/selectedEnd` в `buildIsoOpeningBasis` вычисляются той же
формулой (`start/end + offset`), т.е. `leaf.hinge` для leaf 0 у door/gate
теперь буквально совпадает с `face.selectedStart` — ровно то, что проверяет
новый тест `'door and gate face matrix keeps every live state on the selected
physical hinge'` (перебор angle×side×flipH для door/gate, плюс проверка
конечности всех 5 surfaces на amount 0/0.5/1). Окна не тронуты (тот же
`openingSymbolOffset`, тест `isoWindowFlipStaysCentred` подтверждает). Revision
policy поднята 2→3 (инвалидация LRU кэша — правильно, т.к. геометрия
меняется).
`buildIsoWallDepthQueue()`: локальное per-window упорядочивание слотов по
`cameraDepth` расширено на **любой** opening layer (было только `window`),
чтобы вращающийся door/gate-призм не инвертировал свои front/back грани.
Новый тест `'shared painter queue keeps rotating door prism faces in physical
camera order'` строит реальный door-basis, реверсирует порядок входных
surfaces и проверяет: (а) слоты одной двери сортируются строго по
`cameraDepth`, (б) слоты **чужих** стен не двигаются. Читал алгоритм
(`openingSlots`/`nextOpeningSlot` — переименованный, но структурно тот же
механизм, что был для окон) — не нашёл способа, которым переупорядочивание
одного opening могло бы задеть слот другой стены/проёма: индексация строго по
ключу `type\0id`. **Соответствует AC2/AC3.**
### Контур двери (AC2 §6.2 п.5)
`.iso-opening-panel.iso-material-matte-leaf { stroke: none }` во всех темах
(light/dark/auto/forced-colors — прямая замена `stroke: <цвет>` →
`stroke: none`, окна (`iso-material-glass-*`) не тронуты — их бордер остаётся).
Проверено тестом (`isometric-contract.test.mjs`: `stroke:\s*none` в трёх
тема-вариантах + `iso-material-glass-side... stroke: #8fb4c7` для окна) и живьём
браузерным смоком. **Соответствует.**
### Взаимные столкновения устройств/замков (AC4-11)
Новый чистый резолвер `resolveIsoOverlayCollisions()` в `src/iso-overlays.ts`
(строки после существующего `resolveIsoOverlayPlacement`, никакая существующая
функция не изменена, только импортирован `unprojectFloorPoint`). Прочитал
алгоритм построчно:
- **Один общий бюджет, не добавочный** (AC7): `IsoOverlayPlacement.raisedScene`
— это исходная **непровязанная** проекция floor-anchor (без wall-nudge).
`nudgeScene`/`nudgeCss` в каждой промежуточной и финальной плейсменте —
это смещение **от `raisedScene`**, не инкремент от предыдущего шага.
Кандидатные офсеты (`ISO_OVERLAY_GROUP_OFFSETS_CSS`, решётка шагом 1 CSS px,
предвычислена один раз на модуль, отсортирована по расстоянию) — абсолютные
векторы от `raisedScene`, и та же константа `ISO_OVERLAY_MAX_NUDGE_CSS_PX=48`
режет и wall-safety, и group-pass. Проверил алгебраически, что
`placementAtGroupOffset`/`candidate()` действительно считают
`visualScene = base.raisedScene + offsetScene`, а не
`base.visualScene + offsetScene` — бюджет не удваивается. Юнит-тест
`'group collision reports a deterministic residual without exceeding the
absolute cap'` прямым числом подтверждает `nudgeDistanceCss <= 48`.
- **Приоритет** (AC6): `stableItems` сортируются по возрастанию уже
требуемого `nudgeDistanceCss` (кто меньше отклонился от исходной точки —
тот раньше «застолбит» позицию), при равенстве — по
`isoOverlayCollisionKey(kind,id)` (стабильный, не зависит от HA registry
order). Тест `'group collision separates a solvable dense set independently
of input order'` прогоняет прямой и реверсный порядок входа и требует
побитового совпадения итоговых центров — прошёл бы только при
действительно детерминированном тай-брейке.
- **Room labels исключены** (AC8): `entries.flatMap((entry) => entry.kind ===
'room-label' ? [] : [...])` в `iso-scene-render.ts` — labels физически не
передаются в `resolveIsoOverlayCollisions`. Тест `'render scene separates
device roots without moving labels...'` проверяет `label.placement.
nudgeDistanceCss === 0` при налегающих устройствах вокруг.
- **Кандидат остаётся в комнате/вне стен** (AC4/AC5): для не-базового офсета
код заново проверяет `pointStrictlyInRoom`, `segmentStrictlyInRoom` (путь от
текущей позиции к кандидату не выходит из комнаты) и
`footprintNearSilhouette` — те же существующие геометрические примитивы,
что использует wall-safety пасс (не новые, не продублированные).
- **Плотная нерешаемая группа** (AC5): при отсутствии свободного кандидата —
`placementAtGroupOffset(..., residual=true)` помечает `status:'degraded'`,
`reason:'overlay-collision'`, но **не меняет** `footprint`/DOM-owner (только
сдвигает позицию в пределах кэпа) — отдельные hit/focus остаются, т.к.
рендер (`houseplan-card.ts`, не тронут этим диффом) по-прежнему рисует
каждый entry как отдельный HTML-узел. Юнит-тест с комнатой 1×1 и
`screenHalfSize:[30,30]` подтверждает `residualPairs.length===1`,
`status:'degraded'`, `reason:'overlay-collision'`.
- **Кэш/no-accumulation** (AC10, AC11 частично): `collisionSignature` —
чистая функция входных placement/footprint/unitsPerPixel, `previous` берётся
из `Map`, keyed по `input.wallSilhouettes` (ссылка) и `mode`
(`'live'|'fit'`). Тест `'... caches permutations'`: реверс порядка
устройств → `Object.is` (`assert.strictEqual`) тот же объект сцены —
подтверждает, что памоизация действительно попадает в кэш, а не просто
пересчитывает одинаковый результат.
- **Fit skips live search** (AC "fit envelope"): `mode==='fit'` передаёт
`{ placements: new Map(), residualPairs: [] }` без вызова резолвера; тест
`'fit probing deliberately skips live group displacement'` подтверждает
оба устройства остаются в одной точке (не разведены).
- **Производительность** (AC11, частично): поиск кандидатов — предвычисленная
решётка ограниченного радиуса (≈π·48²≈7238 точек при шаге 1px), с ранним
выходом на первом бесконфликтном кандидате и хеш-сеткой (`nearby()`,
`ISO_OVERLAY_GROUP_CELL_CSS_PX=64`) вместо честного all-pairs скана; дорогая
ветка входит только для реально конфликтующих элементов (у остальных первый
`candidate(baseOffsetCss)` сразу без конфликтов и возвращается). Статический
разбор — не заменяет калиброванный perf-гейт (см. «как проверялось» выше).
**Соответствует AC4-11** по прочитанному коду + прогнанным юнит-тестам,
которые реально способны упасть (проверил, что тест на приоритет/кэш ловит
регресс: реверс входного порядка/повторный вызов — не тривиальные ассерты).
### Документация
`docs/ISOMETRIC.md`, `docs/ARCHITECTURE.md`, `docs/STATUS.md` — обновлены
консистентно с кодом (проверил построчно): убраны упоминания
contact/leaf-теней и per-window-only переупорядочивания, добавлено описание
group-pass, host-face pivot для door/gate, revision 2→3. `docs/STATUS.md`
корректно ссылается на #583 в описании Hidden Alpha Stage.
## Находка (Medium, в скоупе)
**Золотой (golden) семантический контракт для iso-дверей не обновлён под новое
поведение и падает исключением, а не просто устаревшим пикселем.**
Воспроизведение: `npm run golden:verify` (прогнал целиком) →
`isometric-opening-symbol-parity-dark` заканчивается статусом **`error`**
(не `different`), с сообщением:
```
semantic golden Iso centre failed for golden-iso-door: 12.5
```
Причина: `demo/golden/run.mjs`, функция `assertOpeningSymbolContract()`,
ветка `else` (surface `'iso'`, ~строки 852-870) — для каждого opening с
`offset: 'center'` в `demo/golden/matrix.mjs` (`openingIsoContract`, id
`golden-iso-door`/`golden-iso-gate-default`/`golden-iso-gate-flipped`)
жёстко требует, чтобы середина хорды `hinge + closedVector/2` (для двери) или
среднее `hinge` двух листов (для ворот) совпадала с канонической точкой
`cfg.x*1000, cfg.y*1000` с точностью `1e-5`. Это было верно **до** этой
задачи, когда `leafBasis` центрировал door/gate так же, как окна. После
правки `src/iso-openings.ts:238-240` (`offset = input.face` для door/gate)
хинг сознательно уходит на выбранную физическую грань стены — контракт
обязан был получить то же исключение для door/gate, которое уже получили
`test/iso-openings.test.mjs`, `test/isometric-contract.test.mjs` и
`demo/smoke_isometric_contract.mjs` (все три обновлены именно под это же
изменение в этом же диффе), но `demo/golden/run.mjs`/`matrix.mjs` — нет.
Почему это не «просто ещё один different-пиксель, подождёт беты»: `different`
означает, что PNG не совпал с эталоном и решается штатной пересъёмкой
(`golden:accept --reviewed`) перед бетой — так по праву оставлены 11 других
`isometric-*` сцен в этом диффе. `error` — это исключение **внутри самого
харнесса капчура**: сцену `isometric-opening-symbol-parity-dark` невозможно
даже переснять (`golden:capture` бросит то же исключение), пока не поправлен
`assertOpeningSymbolContract`/конфиг `openingIsoContract`. Это заблокирует
именно ту процедуру пересъёмки, которую этот же диф просит выполнить перед
бетой для AC13, и будет молча висеть до следующей задачи, если её не
исправить сейчас — тот же класс риска, что #230/#234 (docs) и #171/#207
(тихий пропуск).
Фикс — того же размера и в том же файле/соседнем конфиге, что и уже сделанные
три обновления: разрешить door/gate «центр после проекции» смещённым на
`face.offset` (или явно проверять `leaf.hinge === face.selectedStart/selectedEnd`
как это уже делает `test/iso-openings.test.mjs`), оставив строгую
центровку только для window. Находится строго в скоупе задачи (тот же
контракт «дверь/ворота больше не центрированы в 2.5D», который задача сама
вводит) — чинится в этом же раунде, отдельный issue не заводится (#202).
## Что проверено и корректно
- Тени, обводка, порядок граней двери/окна, host-face pivot, collision-pass,
labels-exclusion, кэш/детерминизм, документация — см. разбор выше.
- Trailers, changelog-обязательства, бандл (3 копии), `no-new-any`,
bundle-budget — зелёные.
- `npm test` (2719/2719, 1 skipped) и целевые browser-smokes — зелёные,
перепроверено лично, не только со слов автора.
- `golden:verify`: **все 11 расхождений строго ограничены isometric-сценами**
(тени/дверь/коллизии/stroke — ровно то, что задача меняет); ни одна
неизометрическая сцена (160 штук) не сдвинулась — сильное свидетельство
отсутствия побочных регрессий за пределами заявленного скоупа.
## Чего не проверял (и почему)
- Полный `smoke`-набор (247 из 250 файлов) — вне выборки diff/AC, дорого,
предрелизный гейт.
- Калиброванный `performance_smoke`/Full Performance на точном SHA — не
запускается на обычном пуше (`classify-changes.mjs`), предрелизный гейт;
сделал только некалиброванную ручную сверку на порядок величины, без
выводов на её основе.
- `python -m pytest tests_backend` — Python не тронут.
- `npm run invariants` — геометрическая модель/layout не тронуты (только
derived presentation).
- Пересъёмка/приёмка 11 ожидаемо изменившихся golden-baseline — сознательно
оставлена автором на предрелизный этап (PROCESS §8); это не переигрывается
мной, но привязана к найденной находке (harness должен сначала научиться
снимать `isometric-opening-symbol-parity-dark` без исключения).
## Вердикт
Один Medium **в скоупе задачи** (сломанный семантический golden-контракт для
двери/ворот в iso, воспроизводится, чинится в этой же ветке), High — нет.
Все 14 AC подтверждены чтением кода + юнит/contract-тестами + целевыми
браузерными смоками + полным прогоном golden (кроме калиброванного perf).
**Вердикт: жёлтый.**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/583-isometric-visual-corrections`, коммит `e2801e4e8b1b` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `7640cc7b520c24eadf4a6ea2ddff6b58a3f03178`
```
git log --all --format='%H %T' | grep 7640cc7b520c
```
- Тело issue: `5c6f59d62afacaf36715df788207cc8b3df775aab0991091d0d0ae322f9ac844`
- Вердикт конвейера: `yellow` · High 0