fix: align sun rays with plan north
Validate / docs (push) Successful in 25s
Validate / provenance (push) Successful in 42s
Validate / process-gate (push) Failing after 38s
Validate / changes (push) Successful in 37s
Validate / hacs (push) Skipped
Validate / hassfest (push) Skipped
Validate / backend (push) Skipped
Validate / frontend (push) Successful in 5m19s
Validate / performance_smoke (push) Failing after 1m49s
Validate / smoke (push) Failing after 2m9s
Validate / golden (push) Failing after 7m21s

Issue: #166
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-17 12:01:14 +03:00
parent 9c0e9fab68
commit 4ed86b38bc
18 changed files with 126 additions and 41 deletions
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -120,7 +120,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 || {}),
@@ -129,6 +130,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 } : {}),
};
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 22;
export const GOLDEN_MATRIX_VERSION = 23;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -161,8 +161,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
View File
@@ -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
+2 -2
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- 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
- Large plans no longer recompute an unused physical-wall union on every floor
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Оконные солнечные лучи теперь правильно складывают азимут Home Assistant с
буквальным направлением стрелки N и освещают физически верную сторону плана
при повёрнутом севере. Если раньше компас был выставлен зеркально для обхода
ошибки, после обновления верните стрелку к реальному северу
([#166](https://github.com/Matysh/houseplan-card/issues/166)).
## v1.64.0-beta.3 — 2026-08-14
- Большие планы больше не пересчитывают неиспользуемое объединение физических
+10 -2
View File
@@ -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
+6
View File
@@ -431,6 +431,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.
+5 -1
View File
@@ -952,10 +952,14 @@ marker на плане при этом не меняются.
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

+13 -13
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceFingerprint": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "d36b6f9f8139f31ef73a780c6511a640a26055efd9d7a24c24fd48b1d8379bf0"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "358e25ff9984d0fb0c03cfbb848df40c425fdfe64cd5f4f613b1e754ca9d4249"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "2f4770869f04c8d7f1e8ff33af1e8bd79f45ad6d97be6d5e6135b3a82fec9e4c"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "47d8fc2ac1c14cb699e990ca9c00d89b9ac57f6cc76b40420ceb26b4d7f50df4"
},
"device-editor": {
@@ -68,8 +68,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "7c3e25534fc819c45431907616c3d523961505859ee68af27cce2daec9f04fb0"
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "7a22a81e2abedf5a520bd36b7509d953cbfa9962b48c0ffbc233666a6215c04b"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -79,8 +79,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"imageSha256": "df3a54395c83bffd7a161f1fb3de0143e12e76567535aedf9883873ead74df91"
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "f6014caed7b7d28790b8548996ba09d806a4e4d51fbb7e8d3fb3c582ebe49167"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "4e7b3b1220a3fe104cf11e5b32a31b343dc95c45fd2cbe3365d145f0a66a560d"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "176abba71d41cfb045a33f82a794d9fbb2a5d3c48e6df66e6ec3a8e448311b11"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "4343126d84c3579b6dc970ed755152b1206c02aacff8abe43b89535f48eee2c1",
"sourceSha256": "ba7f9be6f4246172fbf3090bbf212631c6dd8e5bdb9a57bc7019f1644d79ef5c",
"imageSha256": "2199ed88b215bf2bff63bf665028f78b2aa6a92c3032b803931790f2ef071893"
}
}
+35
View File
@@ -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
View File
@@ -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). */
+10
View File
@@ -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)
@@ -168,7 +172,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
View File
@@ -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]) {