27 KiB
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:
- декоративные contact/leaf тени стен и дверных полотен — убрать;
- дверь между Hallway и Under-Stairs Closet визуально «вросла» в стену — починить геометрию/порядок отрисовки;
- иконки устройств/замков после проекции 2D→2.5D налегают друг на друга — нужен общий детерминированный collision-pass;
- серый/двойной контур дверных полотен — убрать нарисованный 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).
Вердикт: жёлтый.
Материал раунда
- Ветка:
issue/583-isometric-visual-corrections, коммитe2801e4e8b1b— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
7640cc7b520c24eadf4a6ea2ddff6b58a3f03178git log --all --format='%H %T' | grep 7640cc7b520c - Тело issue:
5c6f59d62afacaf36715df788207cc8b3df775aab0991091d0d0ae322f9ac844 - Вердикт конвейера:
yellow· High 0