# Issue #166 — солнечные лучи зеркально учитывают направление севера Статус: **ТЗ принято, готово к реализации** Дата: 2026-08-16 Тип: `bug` · приоритет: `P1` · user value: 8/10 · complexity: 4/10 · risk: 6/10 Issue: [#166](https://github.com/Matysh/houseplan-card/issues/166) Ветка: `issue/166-sun-north-rotation` Канонические документы: [SCOPE](../SCOPE.md), [SUN](../SUN.md), [USER-GUIDE](../USER-GUIDE.md), [USER-GUIDE.ru](../USER-GUIDE.ru.md), [TOUCH-SUPPORT](../TOUCH-SUPPORT.md), [TESTING](../TESTING.md), [CONFIG-COMPATIBILITY](../CONFIG-COMPATIBILITY.md). ## 1. Сценарий и продуктовый контекст Основная персона — владелец дома, который настроил план по реальной ориентации здания и ежедневно смотрит его в Full View либо на kiosk-панели. Он поворачивает стрелку N в настройках туда, где на плане действительно находится север, и ожидает, что оконные лучи будут соответствовать текущему положению Солнца. Это прямой контракт SCOPE J1: House Plan должен правдиво показывать текущее состояние дома. Выбор окна с неправильной стороны здания является фактически ложной визуализацией, даже если сам эффект декоративный. ## 2. Что человек увидит до и после **До:** если истинный север не совпадает с верхом холста, поворот стрелки N зеркально поворачивает солнечное направление. Чтобы осветилось физически верное окно, пользователю приходится намеренно ставить компас не по реальному северу. При `north_deg = 0` ошибка незаметна. **После:** стрелка N буквально указывает истинный север на плане. House Plan комбинирует это направление с азимутом `sun.sun`, выбирает окно на физически правильной стороне и ведёт луч от него внутрь комнаты. Ручная зеркальная компенсация больше не нужна. ## 3. Подтверждённая причина 1. `_compassPoint()` в `src/houseplan-card.ts` вычисляет угол через `atan2(dx, -dy)`: верх холста = 0°, право = 90°, низ = 180°, лево = 270°. 2. `_renderCompass()` поворачивает стрелку N на сохранённый `north_deg`, поэтому UI показывает именно направление истинного севера на холсте. 3. `docs/SUN.md` определяет `north_deg` тем же образом: градусы по часовой стрелке от верха холста до истинного севера. 4. `planSunAngle()` в `src/sun.ts` вопреки этому вычисляет `norm360(azimuth - northDeg)`. Вычитание отражает поворот относительно нулевой оси; для заявленной семантики направления должны складываться. 5. `test/sun.test.mjs` закрепляет неверный знак ожиданием «east sun + `north_deg=90` → up» и выбором верхнего окна. 6. `demo/fixtures/visual-matrix.mjs` использует `north_deg = 0`, поэтому действующие golden-сцены не различают сложение и вычитание. Репорт подтверждён по коду и является дефектом координатного преобразования. ## 4. Координатный контракт Все углы нормализуются в `[0, 360)` и растут по часовой стрелке. - `A` (`azimuth`) — направление от истинного севера к Солнцу из `sun.sun`: N=0°, E=90°, S=180°, W=270°. - `N` (`north_deg`) — направление истинного севера на холсте: верх=0°, право=90°, низ=180°, лево=270°. - `P` — направление **к Солнцу** на холсте. Каноническая формула: ```text P = norm360(N + A) toSun = (sin(P), -cos(P)) awayFromSun = -toSun ``` Здесь ось X растёт вправо, ось Y — вниз. `toSun` используется для проверки, какая внешняя нормаль окна смотрит на Солнце; `awayFromSun` остаётся направлением хода света от окна внутрь комнаты. Контрольные примеры: | Север на холсте N | Азимут A | Направление к Солнцу P | Сторона холста | |---:|---:|---:|---| | 0° (вверх) | 90° (восток) | 90° | справа | | 90° (вправо) | 0° (север) | 90° | справа | | 90° (вправо) | 90° (восток) | 180° | снизу | | 270° (влево) | 90° (восток) | 0° | сверху | | 350° | 20° | 10° | wrap через 360° | ## 5. Scope В задачу входят: 1. исправление преобразования `azimuth + north_deg` в единственной чистой функции солнечного направления; 2. сохранение действующего контракта вектора к Солнцу и противоположного вектора луча внутрь комнаты; 3. исправление unit-ожиданий, которые сейчас закрепляют неверный знак; 4. smoke-проверка выбора окон при ненулевом global `north_deg` и per-space override; 5. детерминированная golden-сцена с ненулевым севером, чтобы знак поворота был визуально различим; 6. исправление формулы и примеров в `docs/SUN.md` и пользовательских руководствах; 7. предупреждение о ранее компенсированных настройках в обоих changelog; 8. обычные bundle/release-артефакты пользовательского исправления. ## 6. Non-scope В задачу не входят: - изменение значений либо частоты обновления `sun.sun`; - новая астрономическая модель, геолокация или расчёт положения Солнца внутри House Plan; - изменение длины, цвета, opacity, fade, rim или clipping солнечных лучей; - изменение определения внешнего окна либо взаимное затенение крыльями здания; - изменение четырёхфазного фона `daynight`, который по контракту #146 не зависит от компаса; - изменение UI компаса, его диапазона, шага, inheritance или доступности; - изменение режима редакторов или добавление лучей в статическую `houseplan-space-card`; - автоматическая миграция либо эвристическое распознавание пользовательской компенсации старого дефекта. ## 7. Функциональное поведение ### 7.1. Преобразование направления `planSunAngle(azimuth, northDeg)` возвращает `norm360(azimuth + northDeg)`. Для любых конечных входов сохраняется текущая нормализация угла. `sunDirOnPlan()` продолжает возвращать единичный вектор к Солнцу в координатах холста. Архитектурный инвариант для текущих и будущих потребителей, включая SUN-CONTRAST: направление Солнца на плане берётся только из `planSunAngle()`. Ни render-слой, ни contrast-логика не вводят вторую формулу, зеркальное преобразование или дополнительную поправку знака. Знак меняется только в композиции двух систем координат. Нельзя одновременно инвертировать `toSun`, нормали окон или `awayFromSun`: это дало бы локально правильный тест, но снова перепутало бы освещённую сторону либо направление луча внутри комнаты. ### 7.2. Выбор окна и геометрия луча `windowLit()` продолжает сравнивать внешнюю нормаль окна с `toSun`. После исправления выбирается окно со стороны холста, соответствующей `P`. Геометрия луча продолжает строиться вдоль `-toSun`, то есть от освещённого окна внутрь принимающей комнаты. Все действующие правила exterior/interior, wall depth, room clipping, physical obstacles, gradient и rim сохраняются. Для контрольного плана с окнами на четырёх сторонах, `north_deg=90` и `azimuth=90` должно выбираться нижнее окно холста, а не верхнее. Луч от нижнего окна идёт вверх, внутрь комнаты. ### 7.3. Global и per-space Правило наследования не меняется: явный `north_deg` пространства выигрывает у global; пустой override наследует global; отсутствие обоих отключает оконные лучи. И global, и override используют одну исправленную формулу. Preview несохранённого значения в открытом диалоге должен давать тот же результат, что и значение после сохранения. ### 7.4. Гейты и соседние режимы Без изменений остаются: - отсутствие/невалидность `sun.sun` выключает оконные лучи; - `elevation <= 0` не создаёт геометрию, а 3°-порог и двухсекундный fade продолжают действовать по текущему контракту; - `sun_rays=false`, editor mode и отсутствие `north_deg` выключают слой; - `bg_mode=daynight` и декоративный свет #146 не используют `north_deg`; - memo key уже содержит `azimuth`, `elevation`, effective north и config epoch; его состав менять не требуется. ## 8. UX, accessibility, touch и i18n Компас, number input, 1°/15° шаги, pointer capture, клавиатурный ввод и подписи остаются прежними. Исправляется результат существующей настройки, а не interaction. Full View и kiosk сохраняют полный touch-контракт. Настройка компаса остаётся desktop-first поверхностью редактора настроек; новых жестов или hover-only действий нет. Новых i18n-ключей не требуется. EN/RU пользовательская документация должна однозначно сказать, что стрелка N направляется туда, где на плане находится истинный север. ## 9. Данные, migration и совместимость Схема не меняется: `north_deg` остаётся integer 0–359 на global и per-space уровнях. Backend validation, storage version, export/import и default `null` не меняются. Автомиграции `north_deg -> 360 - north_deg` нет. Она повредила бы корректно сохранённые по документированному смыслу значения и не может отличить их от намеренной компенсации. После обновления: - значения, выставленные по реальному северу, начинают давать правильные лучи; - `0°` и `180°` визуально сохраняют прежнее направление; - пользователю, который зеркально компенсировал дефект, нужно один раз вернуть стрелку N к реальному северу. Это явно указывается в EN/RU changelog. ## 10. Acceptance criteria и доказательства ### AC-01 — математический контракт `planSunAngle()` использует сложение и нормализацию. Таблица из раздела 4, включая wrap и некардинальный угол, проходит в `test/sun.test.mjs`. **Доказательство:** unit-тесты точных углов и `sunDirOnPlan()` с допуском для float. ### AC-02 — семантика компаса совпадает с геометрией Положения стрелки N сверху/справа/снизу/слева означают соответственно 0/90/180/270°, а одинаковое значение, заданное dial либо number input, даёт одинаковое направление лучей до и после сохранения. **Доказательство:** действующие compass smoke-проверки плюс направленный smoke с `north_deg=90`. ### AC-03 — освещается физически правильное окно На плане с четырьмя внешними окнами для `north_deg=90`, `azimuth=90` и положительной elevation выбирается нижнее окно холста; луч направлен от него внутрь. Внутреннее окно не участвует. **Доказательство:** unit `computeSunRays()` и production-bundle `demo/smoke_sun.mjs`. ### AC-04 — inheritance исправлен на обоих уровнях Global north и per-space override используют новый знак; удаление override снова применяет global. Отсутствие обоих по-прежнему не рисует лучи. **Доказательство:** unit наследования и smoke текущей full-card конфигурации. ### AC-05 — соседнее поведение не изменено Кардинальные направления при `north_deg=0`, night/elevation gates, exterior window selection, clipping, wall thickness, obstacle subtraction, ray length, fade/rim, editor gating и независимый four-phase background проходят существующие тесты без ослабления assertions. **Доказательство:** полный unit-набор и существующие sun smoke; отдельная проверка, что изменение `north_deg` не меняет resolved day-cycle #146. ### AC-06 — визуальная регрессия больше не маскируется В `GOLDEN_SCENARIOS` есть хотя бы одна детерминированная сцена с ненулевым `north_deg` и асимметричным сочетанием азимута/окон, где ошибочный знак выбрал бы другую сторону плана. Добавление либо семантическое изменение этой сцены сопровождается повышением `GOLDEN_MATRIX_VERSION`. **Доказательство:** обновлённый manifest/diff golden; ручное принятие только изменённой ожидаемой сцены. ### AC-07 — документация и release contract `docs/SUN.md` содержит формулу `norm360(north_deg + azimuth)`, а EN/RU guide объясняет буквальную ориентацию стрелки N. Оба changelog называют исправление и предупреждают пользователей старой ручной компенсации. **Доказательство:** docs diff и terminal trailers `Issue: #166`, `User-Visible: yes` в продуктовых коммитах реализации. ### AC-08 — performance, security и поддерживаемые поверхности Исправление не добавляет DOM, таймеров, сетевых вызовов, config writes либо новых пересчётов; геометрия по-прежнему memoized. View/kiosk работают на desktop и touch; security boundary не меняется. **Доказательство:** typecheck/unit/build в цикле, штатный performance gate и smoke/golden перед бетой, без новых security exceptions. ## 11. План тестирования ### В цикле реализации 1. `typecheck`. 2. Полный unit-набор, включая обновлённый `test/sun.test.mjs`. 3. Production build. Точные команды берутся из актуального `package.json`/runbook; набор не расширяется golden/smoke/performance на каждой итерации. ### Перед бетой 1. `demo/smoke_sun.mjs` и связанные sun smoke. 2. Golden matrix с новой ненулевой ориентацией и review diff. 3. Штатный performance gate, чтобы подтвердить отсутствие нового churn. 4. Остальные golden/smoke/performance проверки по release runbook. Полный HA harness на Windows не является каноном из-за `fcntl`; его обязательное доказательство остаётся в Linux CI. ### Мутационный гейт Перед передачей реализации на код-ревью автор вручную применяет каждый мутант, запускает указанный узкий тест и фиксирует, что тест красный; после возврата рабочей реализации тот же тест должен быть зелёным. | Мутант | Обязательное доказательство | |---|---| | Вернуть в `planSunAngle()` вычитание `azimuth - northDeg` | Табличный unit AC-01 падает хотя бы на контрольных сочетаниях `90° + 90°` и wrap через 360° | | Оставить сложение углов, но инвертировать `toSun` вместо исправления требуемой семантики вектора | Таблица углов AC-01 остаётся зелёной, а направленный unit AC-03 выбора нижнего окна при `north_deg=90`, `azimuth=90` падает | | Вернуть `north_deg: 0` в новой golden-сцене | Структурный тест golden matrix падает, потому что обязательная запись в `GOLDEN_SCENARIOS` больше не содержит ненулевой ориентации, а `GOLDEN_MATRIX_VERSION` не соответствует сценарию | Гейт не считается доказанным только визуальным diff: для каждого мутанта нужен исполняемый тест с заранее определённой причиной падения. ## 12. Риски и защита от регрессии 1. **Повторная инверсия не того вектора.** Защита: отдельно тестировать `toSun`, выбранное окно и `ray.dir` внутрь комнаты. 2. **Тесты снова проходят только при north=0.** Защита: обязательный `north_deg=90` и некардинальный wrap в unit, smoke и visual fixture. 3. **Незаметное изменение four-phase фона.** Защита: фон резолвится без compass dependency и получает отдельный regression assertion. 4. **Сломанная настройка у пользователей обходного решения.** Защита: никаких эвристик/миграции; явное предупреждение в обоих changelog и guide. 5. **Ослабление старых физических ограничений.** Защита: существующие тесты interior windows, elevation, grazing angle, clipping, wall depth и obstacles остаются обязательными. ## 13. Rollback Rollback — единый revert формулы, тестов, golden fixture и документации. Данных для отката нет: schema/storage не меняются. Частичный rollback недопустим — нельзя оставить старую формулу с новыми тестами/документацией либо наоборот. ## 14. Release-артефакты Реализация является user-visible и обязана в одном продуктовом коммите содержать: - запись в `docs/CHANGELOG.md`; - запись в `docs/CHANGELOG.ru.md`; - исправление `docs/SUN.md`; - синхронное уточнение `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`; - обновлённые unit и `demo/smoke_sun.mjs`; - ненулевую compass-сцену в golden fixture/manifest и принятый diff; - пересобранные поставляемые bundle-копии по release runbook; - terminal trailers `Issue: #166` и `User-Visible: yes`. ## 15. Принятые предположения 1. `north_deg` всегда означает показанное стрелкой направление истинного севера на холсте, а не угол, которым нужно повернуть сам план обратно к северу. 2. Азимут Home Assistant интерпретируется текущим контрактом: 0° — север, 90° — восток, рост по часовой стрелке. 3. Исправление не мигрирует намеренно неверные обходные значения, потому что их невозможно надёжно отличить от корректных. 4. Отдельного продуктового решения по UX не требуется: пользователь уже дал реальный север, меняется только неверный результат этой настройки.