mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
Merge issue #166 into dev
Issue: #166 User-Visible: no # Conflicts: # custom_components/houseplan/frontend/houseplan-card.js # demo/srv/assets/houseplan-card.js # dist/houseplan-card.js # docs/CHANGELOG.md # docs/CHANGELOG.ru.md # docs/images/screenshots.json
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -122,7 +122,8 @@ export function prepareGoldenFixture(scenario) {
|
||||
fixture.devices[scenario.deviceId].name = scenario.deviceName;
|
||||
}
|
||||
if (scenario.fillMode || scenario.bgMode || typeof scenario.glowEnabled === 'boolean'
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean') {
|
||||
|| typeof scenario.sunRays === 'boolean' || typeof scenario.showBorders === 'boolean'
|
||||
|| typeof scenario.northDeg === 'number') {
|
||||
const space = requireSpace();
|
||||
space.settings = {
|
||||
...(space.settings || {}),
|
||||
@@ -131,6 +132,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
...(typeof scenario.glowEnabled === 'boolean' ? { glow_enabled: scenario.glowEnabled } : {}),
|
||||
...(typeof scenario.sunRays === 'boolean' ? { sun_rays: scenario.sunRays } : {}),
|
||||
...(typeof scenario.showBorders === 'boolean' ? { show_borders: scenario.showBorders } : {}),
|
||||
...(typeof scenario.northDeg === 'number' ? { north_deg: scenario.northDeg } : {}),
|
||||
...(scenario.customFill ? { custom_fill: scenario.customFill } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,8 +169,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
// The golden screenshot is backed by a second, sun-layer-hidden capture.
|
||||
// A real painted ray must account for enough changed pixels; DOM-only
|
||||
// presence or an accidentally accepted empty baseline is not sufficient.
|
||||
glowEnabled: false, allLightsOff: true,
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 0, elevation: 24 } } },
|
||||
glowEnabled: false, allLightsOff: true, northDeg: 90,
|
||||
stateOverrides: { 'sun.sun': { attributes: { azimuth: 270, elevation: 24 } } },
|
||||
sunRayPixels: { minPixels: 500, minChannelDelta: 4 },
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...sunWindow },
|
||||
{ id: 'lighting-fill-light-axis-split-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',
|
||||
|
||||
+4
-3
@@ -82,11 +82,12 @@ const res = await page.evaluate(async () => {
|
||||
await setSun(90, -10);
|
||||
out.nightEmpty = domPolys().length === 0;
|
||||
|
||||
// 6) rotated compass: the same morning east sun now lights the NORTH window
|
||||
// 6) N points right: the same morning east sun is down on the canvas and
|
||||
// therefore lights the SOUTH window
|
||||
cfg().settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.rotatedCompass = JSON.stringify(litIds()) === '["wN"]';
|
||||
out.rotatedCompass = JSON.stringify(litIds()) === '["wS"]';
|
||||
cfg().settings.north_deg = 0;
|
||||
touchCfg();
|
||||
|
||||
@@ -127,7 +128,7 @@ const res = await page.evaluate(async () => {
|
||||
sp.settings.north_deg = 90;
|
||||
touchCfg();
|
||||
await setSun(90, 5);
|
||||
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wN"]';
|
||||
out.spaceNorthOverride = JSON.stringify(litIds()) === '["wS"]';
|
||||
delete sp.settings.north_deg;
|
||||
touchCfg();
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -7,6 +7,11 @@
|
||||
an active cycle. Power-on alone remains neutral, Power-off still fades stale
|
||||
activity, and ordinary lone relays keep their existing behaviour
|
||||
([#164](https://github.com/Matysh/houseplan-card/issues/164)).
|
||||
- Window sunlight now combines the Home Assistant azimuth with the literal
|
||||
direction of the compass N arrow, so a rotated real north lights the
|
||||
physically correct side of the plan. Users who mirrored the compass to work
|
||||
around the previous bug should point it back to the real north
|
||||
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
|
||||
|
||||
## v1.64.0-beta.3 — 2026-08-14
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
активного цикла. Одного Power=`on` по-прежнему недостаточно, Power=`off`
|
||||
подавляет устаревший активный статус, а обычные одиночные реле сохраняют
|
||||
прежнее поведение ([#164](https://github.com/Matysh/houseplan-card/issues/164)).
|
||||
- Оконные солнечные лучи теперь правильно складывают азимут Home Assistant с
|
||||
буквальным направлением стрелки N и освещают физически верную сторону плана
|
||||
при повёрнутом севере. Если раньше компас был выставлен зеркально для обхода
|
||||
ошибки, после обновления верните стрелку к реальному северу
|
||||
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
|
||||
|
||||
## v1.64.0-beta.3 — 2026-08-14
|
||||
|
||||
|
||||
+10
-2
@@ -31,10 +31,15 @@ soft wedges from exterior windows. Neither creates entities nor calls services.
|
||||
ONLY when (azimuth, elevation) or the config change — never on every
|
||||
`hass` tick. The wedge layer memoises on
|
||||
`(azimuth, elevation, config rev, space id)`.
|
||||
- Angle on the plan: `plan_angle = azimuth − north_deg` (normalised to
|
||||
0–360). With `north_deg = 0` the top of the canvas is north; the
|
||||
- Angle on the plan: `plan_angle = north_deg + azimuth` (normalised to
|
||||
0–360). Both bearings increase clockwise: `north_deg` is the literal
|
||||
direction of true north on the canvas, and `azimuth` is the clockwise
|
||||
bearing from that north. With `north_deg = 0` the top of the canvas is north; the
|
||||
direction TOWARD the sun on the canvas is
|
||||
`(sin(plan_angle), −cos(plan_angle))` (y grows downward).
|
||||
- `planSunAngle()` is the only source of the sun direction on the plan.
|
||||
Rendering and contrast consumers must not repeat the composition or add a
|
||||
second mirror/sign correction.
|
||||
|
||||
## Compass — `settings.north_deg`
|
||||
|
||||
@@ -42,6 +47,9 @@ soft wedges from exterior windows. Neither creates entities nor calls services.
|
||||
north. Lives in the GENERAL settings (⚙) as a circular dial: drag
|
||||
the «N» arrow around the ring, 1° steps, 15° with Shift held; a
|
||||
plain number input sits next to it for accessibility and precision.
|
||||
- The arrow is literal: point N toward the place where true north lies on the
|
||||
drawing. It is not an instruction to enter how far the plan was rotated in
|
||||
the opposite direction.
|
||||
- Per-space override in the space settings (empty = inherit), the same
|
||||
pattern as `show_lqi` / `fill_mode`.
|
||||
- While `north_deg` is null at BOTH levels window rays are inert and the
|
||||
|
||||
@@ -440,6 +440,12 @@ day through golden hour to night and can fall back to browser time. Window rays
|
||||
require `sun.sun`, a configured north direction and suitable exterior windows.
|
||||
Weather cloud cover may reduce ray intensity.
|
||||
|
||||
For window rays, point the compass N arrow toward the place where true north
|
||||
actually lies on the drawing. The value is the literal clockwise direction
|
||||
from canvas-up to north, not an opposite correction for a rotated plan. If you
|
||||
previously mirrored the compass to compensate for the old ray-direction bug,
|
||||
return it to the real north after updating.
|
||||
|
||||
Rays remain visual only: they do not change Home Assistant state. Walls and
|
||||
physical obstacles clip them; changing north or window geometry recalculates
|
||||
the result.
|
||||
|
||||
@@ -957,10 +957,14 @@ Power=`off`/`unavailable` подавляет даже устаревший ак
|
||||
|
||||
1. В общих настройках выберите фон **Следует за солнцем** при необходимости.
|
||||
2. Если нужны оконные лучи, укажите север числом 0–359° или поверните стрелку
|
||||
компаса.
|
||||
компаса. Стрелка N должна буквально указывать туда, где на рисунке находится
|
||||
истинный север; это не обратная поправка на поворот плана.
|
||||
3. При необходимости включите **Солнечный свет через окна**.
|
||||
4. Для отдельного этажа задайте локальный север/режим/включение либо оставьте наследование.
|
||||
|
||||
Если раньше вы зеркально выставляли компас, чтобы компенсировать ошибочное
|
||||
направление лучей, после обновления верните стрелку N к реальному северу.
|
||||
|
||||
### Поведение
|
||||
|
||||
| Условие | Результат |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 286 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"sourceFingerprint": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceFingerprint": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "2f4770869f04c8d7f1e8ff33af1e8bd79f45ad6d97be6d5e6135b3a82fec9e4c"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -57,7 +57,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "47d8fc2ac1c14cb699e990ca9c00d89b9ac57f6cc76b40420ceb26b4d7f50df4"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -68,8 +68,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -79,8 +79,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "15d1ebb8e22290b7d945597c86b9cda6de2fc3abfd7c7948428a8a5d7fd28706",
|
||||
"sourceSha256": "da33c4f75cf2469e330b33f11fd5fa04d36861af0c75eb110758b7fff2688fcb",
|
||||
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
# 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 не требуется: пользователь уже дал
|
||||
реальный север, меняется только неверный результат этой настройки.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
+18
-11
@@ -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]) {
|
||||
|
||||
Reference in New Issue
Block a user