${Hr.map(e=>{const i=yr[e],s=`background:radial-gradient(ellipse at 50% 88%, ${i.horizon} 0%, transparent 54%),linear-gradient(180deg, ${i.top} 0%, ${i.bottom} 100%);box-shadow:inset 0 0 90px ${i.vignette}`;return j`
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 не требуется: пользователь уже дал
+ реальный север, меняется только неверный результат этой настройки.
diff --git a/docs/specs/README.md b/docs/specs/README.md
index 946277ce..d3c3156b 100644
--- a/docs/specs/README.md
+++ b/docs/specs/README.md
@@ -1,6 +1,6 @@
# Спецификации задач P1 и P2
-Актуально на 2026-08-14.
+Актуально на 2026-08-16.
GitHub Issues и GitHub Projects (v2) остаются единственным каноническим backlog проекта. Этот каталог содержит развёрнутые ТЗ: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. Статус, приоритет и факт завершения меняются только в GitHub.
@@ -50,6 +50,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#146](https://github.com/Matysh/houseplan-card/issues/146) Четырёхфазный фон «Следует за Солнцем» | [146-four-phase-sun-background.md](146-four-phase-sun-background.md) |
| [#156](https://github.com/Matysh/houseplan-card/issues/156) Регрессии Full Performance перед v1.64.0 stable | [156-full-performance-regressions.md](156-full-performance-regressions.md) |
| [#164](https://github.com/Matysh/houseplan-card/issues/164) Активный цикл стиральной машины должен быть жёлтым | [164-washer-active-cycle.md](164-washer-active-cycle.md) |
+| [#166](https://github.com/Matysh/houseplan-card/issues/166) Солнечные лучи зеркально учитывают направление севера | [166-sun-north-rotation.md](166-sun-north-rotation.md) |
## P2
diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs
index e6947cc6..bba8f532 100644
--- a/scripts/mutation-gate.mjs
+++ b/scripts/mutation-gate.mjs
@@ -130,6 +130,41 @@ export const MUTANTS = [
replace: "{ id: 'openings-filled-tunnel-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',\n fillMode: 'none', customFill: { c: '#66717c', a: 0.55 }, glowEnabled: false,",
}],
},
+ {
+ id: 'sun-north-subtraction-restored',
+ guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ + '&& node --test --test-name-pattern="planSunAngle" test/sun.test.mjs',
+ because: 'возврат старого azimuth - northDeg снова зеркалит направление при '
+ + 'ненулевом севере; табличный AC-01 обязан покраснеть на 90+90 и wrap-кейсах',
+ patches: [{
+ file: 'src/sun.ts',
+ find: 'return norm360(azimuth + northDeg);',
+ replace: 'return norm360(azimuth - northDeg);',
+ }],
+ },
+ {
+ id: 'sun-to-sun-vector-inverted',
+ guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ + '&& node --test --test-name-pattern="north right plus east sun" test/sun.test.mjs',
+ because: 'формула угла остаётся правильной, но инверсия toSun выбирает окно с '
+ + 'противоположной стороны; направленный AC-03 обязан отличить окно от направления луча',
+ patches: [{
+ file: 'src/sun.ts',
+ find: ' const toSun = sunDirOnPlan(azimuth, northDeg);\n const away: [number, number] = [-toSun[0], -toSun[1]];',
+ replace: ' const direction = sunDirOnPlan(azimuth, northDeg);\n const toSun: [number, number] = [-direction[0], -direction[1]];\n const away: [number, number] = [-toSun[0], -toSun[1]];',
+ }],
+ },
+ {
+ id: 'sun-golden-north-neutralized',
+ guard: 'node --test --test-name-pattern="sun-ray golden" test/golden-matrix.test.mjs',
+ because: 'нулевой north_deg снова делает golden нечувствительным к знаку композиции; '
+ + 'структурный тест обязан требовать ненулевой север и асимметричный азимут',
+ patches: [{
+ file: 'demo/golden/matrix.mjs',
+ find: ' glowEnabled: false, allLightsOff: true, northDeg: 90,',
+ replace: ' glowEnabled: false, allLightsOff: true, northDeg: 0,',
+ }],
+ },
];
// --- механика ---------------------------------------------------------------
diff --git a/src/sun.ts b/src/sun.ts
index ffe3a8ab..dedcba05 100644
--- a/src/sun.ts
+++ b/src/sun.ts
@@ -23,7 +23,7 @@ export function norm360(deg: number): number {
* `north_deg` is how far true north is rotated clockwise from "canvas up".
*/
export function planSunAngle(azimuth: number, northDeg: number): number {
- return norm360(azimuth - northDeg);
+ return norm360(azimuth + northDeg);
}
/** Unit vector TOWARD the sun on the canvas (x right, y down). */
diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs
index df3414c2..6076247a 100644
--- a/test/golden-matrix.test.mjs
+++ b/test/golden-matrix.test.mjs
@@ -33,6 +33,10 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
&& scenario.sunRayPixels.minChannelDelta > 0
&& scenario.sunRayPixels.minChannelDelta <= 32, true, scenario.id);
}
+ if (typeof scenario.northDeg === 'number') {
+ assert.equal(Number.isInteger(scenario.northDeg)
+ && scenario.northDeg >= 0 && scenario.northDeg < 360, true, scenario.id);
+ }
if (scenario.openingPreviewPixels) {
assert.ok(scenario.openingPreview, scenario.id);
assert.equal(Number.isInteger(scenario.openingPreviewPixels.minPixels)
@@ -189,7 +193,13 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
+ assert.equal(GOLDEN_MATRIX_VERSION, 23);
assert.equal(space.settings.sun_rays, true);
+ assert.equal(scenario.northDeg, 90,
+ 'the sign-sensitive golden must keep a non-zero north direction');
+ assert.equal(space.settings.north_deg, 90);
+ assert.equal(fixture.states['sun.sun']?.attributes?.azimuth, 270,
+ 'north=90 plus azimuth=270 points to the top window; subtraction would point down');
assert.equal(space.openings.some((opening) => opening.type === 'window'), true);
assert.equal(fixture.states['sun.sun']?.state, 'above_horizon');
assert.equal(fixture.entities['sun.sun'], undefined,
diff --git a/test/sun.test.mjs b/test/sun.test.mjs
index 618e5c64..48791e4c 100644
--- a/test/sun.test.mjs
+++ b/test/sun.test.mjs
@@ -32,11 +32,17 @@ const WIN = {
};
const ALL = Object.values(WIN);
-test('planSunAngle: plain subtraction, wraps around the circle (359→0)', () => {
- assert.equal(planSunAngle(180, 0), 180);
- assert.equal(planSunAngle(0, 1), 359);
- assert.equal(planSunAngle(359, 359), 0);
- assert.equal(planSunAngle(10, 350), 20);
+test('planSunAngle: clockwise north and azimuth compose by addition', () => {
+ const cases = [
+ { north: 0, azimuth: 90, expected: 90 },
+ { north: 90, azimuth: 0, expected: 90 },
+ { north: 90, azimuth: 90, expected: 180 },
+ { north: 270, azimuth: 90, expected: 0 },
+ { north: 350, azimuth: 20, expected: 10 },
+ ];
+ for (const { north, azimuth, expected } of cases) {
+ assert.equal(planSunAngle(azimuth, north), expected, `north=${north}, azimuth=${azimuth}`);
+ }
assert.equal(norm360(-90), 270);
assert.equal(norm360(720), 0);
});
@@ -52,9 +58,9 @@ test('sunDirOnPlan: compass points map to canvas vectors (y grows down)', () =>
const d = sunDirOnPlan(az, 0);
assert.ok(near(d[0], x, 1e-12) && near(d[1], y, 1e-12), `az ${az}`);
}
- // rotating the compass rotates the whole sky: east sun, north_deg=90 → up
+ // The arrow points to true north on the plan. If north is right, east is down.
const d = sunDirOnPlan(90, 90);
- assert.ok(near(d[0], 0, 1e-12) && near(d[1], -1, 1e-12));
+ assert.ok(near(d[0], 0, 1e-12) && near(d[1], 1, 1e-12));
});
test('dayPhase: night is dark and dim, noon is white, sunrise is warm', () => {
@@ -525,11 +531,12 @@ test('computeSunRays: night → nothing at all', () => {
assert.deepEqual(computeSunRays(ROOMS, ALL, 90, -10, 0), []);
});
-test('computeSunRays: rotating the compass swings the light to another window', () => {
- // the same morning east sun, but the plan is rotated 90°: what the canvas
- // shows as "up" is now east → the NORTH-drawn window faces the sun
+test('computeSunRays: north right plus east sun selects the south window and shines inward', () => {
+ // The N arrow points right. East is therefore down on the canvas, so the
+ // lower window faces the sun and its light travels upward into the room.
const rays = computeSunRays(ROOMS, ALL, 90, 5, 90);
- assert.deepEqual(rays.map((r) => r.openingId), ['wN']);
+ assert.deepEqual(rays.map((r) => r.openingId), ['wS']);
+ assert.ok(near(rays[0].dir[0], 0, 1e-12) && near(rays[0].dir[1], -1, 1e-12));
// and the interior window still never lights up whatever the compass says
for (const nd of [0, 45, 90, 180, 270]) {
for (const az of [0, 90, 180, 270]) {