# CODE-REVIEW-361-r1 Issue: [#361](https://github.com/Matysh/houseplan-card/issues/361) — «Мебель: физическая толщина линий не масштабируется при zoom» Этап: code · заход r1 · блокирующих циклов израсходовано 0/4 до этого раунда ТЗ: `docs/specs/361-furniture-stroke-zoom.md` (принято зелёным в SPEC-REVIEW-361-r2) Ветка: `issue/361-furniture-stroke-zoom`, SHA после ребейза на dev: `0dd24b1e` (было `4b273e94`, поверх легло 1 dev-коммит; по §7.2 разбор полный) Коммит реализации: `0dd24b1e` — `fix: scale furniture strokes with plan zoom` (trailers `Issue: #361`, `User-Visible: yes`) ## Скоуп проверки Один продуктовый коммит `0dd24b1e`, диапазон `origin/dev...HEAD` (40 файлов, из них продуктовый код — `src/furniture.ts` (+55/-4), `src/houseplan-card.ts` (+30/-6); остальное — тестовые файлы, три копии бандла (`dist/`, `custom_components/houseplan/frontend/`, `demo/srv/assets` пересобирается локально), документация, changelog и docs-скриншоты. Зелёного Validate на `0dd24b1e` не найдено — все гейты ниже прогнаны мной локально. Продуктовое рассуждение: задача точно закрывает Core user job «мебель ведёт себя как физический элемент плана» (администратор дома размещает/просматривает мебель, физическая толщина контура должна масштабироваться с планом, как остальной decor). Диагноз и контракт не догадка — оба уже проверены по коду на этапе SPEC-REVIEW и подтверждаются здесь ещё раз по фактическому диффу. ## Как проверялось 1. Прочитан весь diff `src/furniture.ts`/`src/houseplan-card.ts`, ТЗ `docs/specs/361-furniture-stroke-zoom.md`, issue и все комментарии (включая оба раунда SPEC-REVIEW). 2. Прочитан контекст вокруг изменений: `_stageEl`, `_baseVb`, `_viewOr`, `_floorView`, существующий прецедент того же приёма (`perUnit = stage.clientWidth / view.w`, houseplan-card.ts:10643) и CSS (`plan.styles.ts`: `.zoomwrap > svg { width:100%; height:100% }`, `.stage { width:100% }`) — подтверждает, что `stage.clientWidth/clientHeight` корректно прокси размеру фактического SVG-viewport, как и в остальном коде. 3. Прогнаны дешёвые гейты сам (Validate на этом SHA не найден): - `npx tsc --noEmit` — **PASS**; - `npm test` — **1521 pass, 1 skip, 0 fail** (полный набор, единичный запуск ~23s); - `npm run build` + сверка трёх копий бандла (`git status --short` после `npm run build` и отдельно после `npm run bundle:sync`) — **чисто, дифф пуст**, три копии синхронны; - `node scripts/check-docs.mjs` (diff трогает `src/**`) — **PASS** (7 файлов, 10 внешних ссылок; отпечаток скриншотов принят вместе с обновлёнными `docs/images/*.png` в этом же коммите); - `npm run bundle:budget` — **PASS** (272848 B / 282000 B, запас 9152 B); - `node scripts/no-new-any.mjs --base origin/dev --head HEAD` — **PASS** (75 новых строк, новых `any` нет). 4. Дисциплина «тест должен уметь падать» применена к обоим новым тестовым файлам и к `demo/smoke_furniture.mjs`: временно откатил `src/furniture.ts` и `src/houseplan-card.ts` к версии `origin/dev` (тестовые файлы оставил новыми), пересобрал бандл и прогнал: - `node --test test/furniture-stroke-contract.test.mjs` → падает ровно на утверждении про `stroke-width=${strokeWidth}` в preview (ожидаемо: pre-fix preview использует `decorCmToUnits` напрямую, без `furnitureStrokePx`); - `node demo/smoke_furniture.mjs` → падает на `furnitureFollowsPhysicalCameraZoom: false` и `designerAndPrimitiveMatchOrdinaryDecor: false` — ровно тот дефект, который чинит задача. После проверки восстановил оба файла (`git diff HEAD` пуст) и пересобрал бандл заново — рабочее дерево совпадает с `HEAD` побайтово. 5. Прогнан целевой браузерный смок из AC1/AC3/AC4/AC6 на факт-коде: `node demo/smoke_furniture.mjs` → **OK**, все 90 полей `true`, включая растровые assertions (`furnitureFollowsPhysicalCameraZoom`, `designerAndPrimitiveMatchOrdinaryDecor`, `anisotropicResizeKeepsBothAxesEqual`, `rotatedArtworkKeepsTheSameThickness`, `viewportResizeRecalculatesTheSharedPhysicalStroke`). Смок меряет реальные пиксели через `getScreenCTM`+canvas, а не только DOM-атрибуты — доказательство не тавтологично. 6. `node scripts/smoke-select.mjs --base origin/dev --head HEAD` — вывод: 29 «прямых совпадений» по символам `_baseVb`/`_viewOr`/`_stageEl` плюс 3 «зарегистрированные связи» по `_stageEl`, и 1 «неопределённость» (`smoke_canvas_frame.mjs` ← `_baseVb`). Решение по каждой строке — ниже, отдельным разделом. 7. Инварианты модели (`npm run invariants`) не прогонялись: diff не трогает рёбра комнат, записи толщины стен, `layout`, `marker.space` или `open_spans` — только decor/furniture render. Основание: `git diff --stat` не содержит файлов геометрической модели, а изменённые функции (`furniturePlanScreenScale`, `furnitureStrokePx`) не пишут и не читают ничего из `WallSegmentModel`. 8. `python -m pytest tests_backend` не прогонялся: diff не трогает `custom_components/**/*.py` (только сгенерированные frontend-бандлы внутри `custom_components/houseplan/frontend/`). 9. Performance-профили не прогонялись: AC8 не называет конкретный профиль, а по коду видно, что `furnitureScreenScale` вычисляется один раз на весь decor-слой (`houseplan-card.ts:8096`), не на предмет — подтверждено также тестом `furniture-stroke-contract.test.mjs` («the viewport scale is resolved once for the whole decor layer»). 10. `npm run golden:verify` — diff меняет видимый render (AC1 в самой сути — экранная толщина мебели), поэтому гейт обязателен. Полный прогон (182 сценария) не запускался целиком по времени; вместо этого сделан целевой прогон `--mode=capture --scenario=` (единственный режим, допускающий фильтр — `verify` фильтр запрещает) по каждому сценарию, где decor-слой рисует реальную мебель, и по контрольным сценариям без мебели. Результат и его значение — в находке ниже. ## Находка (Medium, в скоупе) **Golden-бейзлайны с реальной мебелью не обновлены под новый физический stroke; `npm run golden:verify` в CI job `golden` (`.github/workflows/validate.yml:502`) упадёт на этом SHA.** Четыре сценария `demo/golden/matrix.mjs` рисуют мебель (`kind: 'furniture'`) в основном decor-слое, а не только в палитре: `furniture-plan-art-dark`, `furniture-placement-preview-light`, `decor-over-opaque-hover-light`, `decor-over-glow-base-dark`. Задача правильно меняет экранную толщину этой мебели (это и есть цель AC1/AC2), но ни один PNG в `demo/golden/baselines/` не тронут этим коммитом (`git diff origin/dev...HEAD -- demo/golden/baselines` — пусто), и `npm run golden:accept` в коммите не запускался. **Воспроизведение** (после `npm run bundle:sync`, чтобы `demo/srv/assets` был собран из факт-кода): ``` $ node demo/golden/run.mjs --mode=capture --scenario=furniture-plan-art-dark different furniture-plan-art-dark # report: differingPixels=2820, diffRatio≈0.0036 (порог 0.0005), maxObservedDelta=35 $ node demo/golden/run.mjs --mode=capture --scenario=furniture-placement-preview-light different furniture-placement-preview-light # differingPixels=995, diffRatio≈0.0014 $ node demo/golden/run.mjs --mode=capture --scenario=decor-over-opaque-hover-light different $ node demo/golden/run.mjs --mode=capture --scenario=decor-over-glow-base-dark different ``` Контроль на исходном (`origin/dev`, без фикса) коде — тот же сценарий, тот же бейзлайн: ``` $ git checkout origin/dev -- src/furniture.ts src/houseplan-card.ts && npm run bundle:sync $ node demo/golden/run.mjs --mode=capture --scenario=furniture-plan-art-dark passed furniture-plan-art-dark # actualSha256 == baselineSha256, differingPixels=0, diffRatio=0 ``` т.е. на `dev` этот сценарий побитово совпадает с бейзлайном, а после фикса — нет. Диф не шум окружения (тот же контроль на сценарии без мебели, `furniture-categories-light`, даёт идентичный `actualSha256`/`diffRatio` что на факт-коде, что на `dev` — это предсуществующий дрейф среды рендеринга, а не эффект задачи, и он не входит в находку). **Почему это Medium, а не High:** сам код корректен — растровый смок и юнит-тесты доказывают именно то поведение, которое требует ТЗ; расхождение с golden — ожидаемое следствие исправленного дефекта, а не побочная порча. Обязательный ремонт («если меняется видимый результат — принять новый бейзлайн через `npm run golden:accept -- --reviewed` из полного Linux CI artifact») уже прямо прописан в разделе «Release-артефакты» самого ТЗ (§361) — находка целиком в скоупе задачи и чинится без изменения кода: прогнать реальный CI Validate на этой ветке, вооружить golden-artifact и принять новые бейзлайны для этих 4 сценариев (`--expect-change=furniture-plan-art-dark,furniture-placement-preview-light,decor-over-opaque-hover-light,decor-over-glow-base-dark` или как назовёт их сам скрипт), затем закоммитить обновлённые PNG вместе с этим фиксом. Без этого шага `dev` получит красный job `golden` сразу после мержа — тот же класс проблемы, что #230/#234 оставили с job `docs`. ## Решение по smoke-select - 29 «прямых совпадений» (`_baseVb`, `_viewOr`, `_stageEl`) — эти методы **не изменены** диффом, только читаются в новой ветке рендера мебели (`_renderDecorLayer`, `_renderFurniturePlacementPreview`). Ни pan/zoom, ни backdrop, ни kiosk-логика в них не затронуты, поэтому прогон всех 29 избыточен для этой задачи — совпадение по имени объясняется тем, что это стандартный способ узнать текущий camera viewport, используемый по всему рендеру плана, а не признак задетой смежной функциональности. - `demo/smoke_furniture.mjs` — прогнан (раздел «Как проверялось», п.5) как единственный смок, названный в AC1/AC3/AC4/AC6, и как единственный, где действительно меняется тестируемое поведение. - `smoke_canvas_frame.mjs` (слабая связь, ← `_baseVb`) — просмотрен: сценарий про рамку вида/aспект, мебели и decor-strokes не касается; не прогонялся. - `smoke_edit_walk.mjs`, `smoke_editor_gestures.mjs`, `smoke_linked_virtual_light.mjs` (← `_stageEl`) — просмотрены: жесты редактора и виртуальный свет, к furniture-stroke отношения не имеют; не прогонялись. ## Проверено и корректно (по AC) - **AC1 (физический zoom) и AC3 (rotate+zoom):** `furniturePlanScreenScale` — ровно формула ТЗ, `min(viewportW/viewBoxW, viewportH/viewBoxH)` (учитывает letterboxing `xMidYMid meet`, не только `_zoom`). Растровый смок подтверждает удвоение толщины при переходе zoom1→zoom2 для designer- и primitive-артворка, включая повёрнутый на 30°. - **AC2 (анизотропный resize):** designerH/designerV сравниваются в одном фикстурном предмете с намеренно вытянутым native artwork — толщина по обеим осям совпадает в пределах 2 CSS px. Юнит-тест `furniture-stroke-contract.test.mjs` формально гарантирует, что `furnitureStrokePx` не видит `w`/`h`/`viewW`/`viewH` предмета вообще (принимает только готовый `strokeUnits` + `planScreenScale`) — анизотропии неоткуда взяться. - **AC4 (preview/commit parity):** `furnitureScreenScale` считается один раз в `_renderDecorLayer` и передаётся как параметр в `_renderFurniturePlacementPreview` — один источник числа на оба render-пути (проверено чтением: houseplan-card.ts:8095-8098, 8171); source-contract тест запрещает preview использовать `decorCmToUnits` напрямую в обход `furnitureStrokePx`. Смок `previewAndCommitShareThePhysicalStroke: true`. - **AC5 (совместимость):** полный `furniture.test.mjs` зелёный (включая перебор всех 56 symbols на «one finite path»); erase-hit и `data-hp`/`data-kind`/`data-symbol` не затронуты диффом — второй тест в `furniture-stroke-contract.test.mjs` проверяет, что `non-scaling-stroke` на erase-hit остался. Смок целиком (палитра, drag, resize, rotate, wall magnet, erase) зелёный. - **AC6 (layout safety):** `furniturePlanScreenScale`/`furnitureStrokePx` покрыты юнит- тестами на нулевые/`NaN`/`Infinity` входы — всегда возвращают конечное число; смок `viewportResizeRecalculatesTheSharedPhysicalStroke` проверяет пересчёт после ресайза viewport. - **AC7 (поддерживаемые поверхности), кроме golden — см. находку выше:** код рендера общий для View/kiosk/Background editor (`_renderDecorLayer` не ветвится по `_mode`, кроме `editing`/`erasing` UI-флагов, не влияющих на арифметику толщины); light/dark не входят в расчёт (только `style.color`) — проверено чтением. Один нетипичный случай, разобранный отдельно ниже. - **AC8 (perf/bundle):** typecheck/test/build/bundle:sync/bundle:budget зелёные (раздел «Как проверялось», п.3); `furnitureScreenScale` — один вызов на слой, не на предмет (тест это же гарантирует мутационно). - **AC9 (документация):** `docs/FURNITURE.md` больше не утверждает, что `non-scaling-stroke` держит именно физическую толщину — переформулировано на «rejects only that local width/depth distortion... renderer separately applies the outer plan viewBox scale». Оба changelog обновлены в этом же коммите с ссылкой на #361 (проверено — `User-Visible: yes` трейлер, дифф `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` внутри `origin/dev...HEAD` для единственного коммита). `docs/USER-GUIDE.ru.md` — добавлено ровно одно предложение, терминология («физическая толщина», «приближении/отдалении плана») согласована с остальным разделом, не изобретена. ## Один разобранный нетиповой случай (не находка, риск явно вне калибровки по ТЗ) В изометрическом режиме без структуры (`_baseVb()` → `!showBorders` ветка, «no-borders» сцена) фактический `viewBox` слоя декора берётся из `floorView` (houseplan-card.ts:11087-11090: `isoLayers?.structural ? view : floorView`), а `furnitureScreenScale` в `_renderDecorLayer` считается от непроецированного `planView = this._viewOr(this._baseVb())` (houseplan-card.ts:8095), то есть не от `floorView`. `_floorView` при `_renderProjection !== 'iso'` возвращает `view` без изменений (проверено чтением, houseplan-card.ts:5660-5665), поэтому расхождение существует только в этой конкретной iso-ветке. Оба входа `furniturePlanScreenScale` там всё равно конечны и положительны (клиентские размеры DOM и размеры сцены не бывают `NaN`/`Infinity`), так что итоговая толщина остаётся конечным числом — `NaN`/пропажа мебели невозможны. Именно это и есть весь обязательный минимум для iso по ТЗ («его текущая геометрия не должна падать или терять мебель, но отдельная визуальная калибровка изометрического stroke не входит в #361»); численная точность калибровки в этой ветке ТЗ прямо не гарантирует. Не заводится ни находкой, ни отдельным issue — разобрано и оставлено как явно принятый остаточный риск. ## Чего не проверял - Полный `npm run golden:verify` (182 сценария) целиком — прогнаны только сценарии с мебелью в decor-слое плюс контрольные без неё (см. находку и её обоснование); остальные 180 сценариев не имеют мебели и не могут пострадать от этого diff — не тратил на них время. - Полная browser-smoke матрица (200 файлов) — вне скоупа диффа; прогнан только целевой `smoke_furniture.mjs` и разобраны все связи, названные `smoke-select.mjs` (раздел выше). - `npm run invariants` — diff не геометрический (обоснование в п.7 «Как проверялось»). - `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py` (п.8). - Performance-профили/бенчмарки — не названы в AC, риск перфа закрыт чтением кода (одноразовый расчёт на слой) и существующим `npm run bundle:budget`. - Ручное тестирование в браузере человеком — не проводилось, весь цикл проверен автоматикой и чтением кода, как предписано процессом для код-ревью. ## Вывод Единственная находка — Medium, в скоупе задачи, не про корректность кода, а про незавершённый релизный артефакт (golden baselines), явно предписанный собственным ТЗ этой задачи. Верните на доработку: прогнать полный CI Validate на ветке, принять новые golden-бейзлайны для 4 названных сценариев через `npm run golden:accept -- --reviewed` из этого CI-прогона и закоммитить обновлённые PNG вместе с остальным изменением.