feat: выбор граней окон для солнечных лучей (#577)

Issue: #577
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-15 00:16:35 +03:00
parent 949422fe91
commit db4b397e2e
70 changed files with 1209 additions and 885 deletions
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- General settings now let window sunlight start at either the inner window
corners (the existing default) or the outer window corners. The outer mode
carries the visible shaft only through the physical window tunnel before it
reaches the room; sun direction, reach, colour and shadows are unchanged
([#577](https://github.com/Matysh/houseplan-card/issues/577)).
- Pinch-zooming in the Home Assistant Companion app no longer repeatedly moves
the plan between compositor paths while the fingers are still down. Walls,
floors, fills, hatching and lighting now remain continuously painted across
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- В общих настройках теперь можно выбрать, от каких углов окна начинаются
солнечные лучи: от внутренних (прежнее поведение по умолчанию) или от
внешних. Во внешнем режиме видимая часть луча проходит к комнате только
через физический оконный тоннель; направление, длина, цвет и тени не меняются
([#577](https://github.com/Matysh/houseplan-card/issues/577)).
- При масштабировании щипком в приложении Home Assistant план больше не
переключается между режимами композитинга, пока пальцы остаются на экране.
Стены, пол, заливки, штриховка и свет теперь остаются непрерывно видимыми при
+14
View File
@@ -97,6 +97,20 @@ new frontend restores the disabled behavior after upgrade. Full backup/import
preserves the setting and the privacy-safe support projection includes only a
validated boolean.
## Sun-ray window face (#577)
`settings.sun_ray_origin` is an optional global enum: `inner` or `outer`.
Absence and an unknown read-side value resolve to `inner`, preserving the exact
pre-#577 source geometry without rewriting an existing configuration. Saving
General settings materialises a valid value; new installations start with
`inner`. Backend writes reject every other value. There is deliberately no
per-space override and no model/store version bump.
Full backup/import preserves the enum. The privacy-safe support projection
includes only a validated `inner`/`outer` scalar. Older frontends ignore a
preserved value and temporarily render the historical inner mode; a current
frontend restores the selected mode after upgrade.
## Summary panel namespace (#437)
`settings.summary_panel` is an optional shared versioned object. Absence means
+21 -14
View File
@@ -204,17 +204,24 @@ when BOTH hold:
perpendicular depth (`len · cos`, see «Dissolving») would be thinner
than the wall it came through.
The wedge is a PARALLELOGRAM: the window's full **room-side span**, from
one inner corner of the opening to the other, extruded by the same
The wedge is a PARALLELOGRAM. The global `settings.sun_ray_origin` enum selects
its full source span: `inner` starts at the two room-side corners (the default
and the exact pre-#577 behaviour), while `outer` starts at the two exterior
corners. Missing or unknown values resolve to `inner`; this setting has no
per-space override. The selected span is extruded by the same
length along the direction AWAY from the sun (light falls inward), so
its far edge is parallel to the wall, clipped by the receiving room's
**inner contour** when wall thickness is set (`inset` of the polygon by
half the wall thickness — see `docs/WALL-THICKNESS.md`); otherwise by
the room polygon (`polyclip` intersection). With wall depth `d`, the
source span is translated from the wall centreline by `d/2` along the
inward normal. Thus both crisp side edges begin exactly at the two inner
corners of the opening at every incidence angle (`d = 0` keeps the
previous centreline/full-span geometry). Its length
the room polygon (`polyclip` intersection). In `outer`, the clip also includes
only the physical rectangular window tunnel between the exterior span and the
clean-floor contour; the surrounding wall body and outside facade remain
occluders. With wall depth `d`, `inner` translates the source from the
centreline by `+d/2` along the inward normal and `outer` by `-d/2`. Thus both
crisp side edges begin exactly at the selected two corners at every incidence
angle (`d = 0` makes both modes identical). The nominal length and gradient
start at that selected span rather than adding the wall depth to the old reach.
Its length
is `k(elevation)` in window lengths: ~1.75 at sunrise/sunset tapering to ~0.56 at the zenith
(`0.56 + 1.19·(1 − elevation/90)^1.6` — the v1.56 curve
`0.8 + 1.7·(1 − elevation/90)^1.6` times `RAY_LENGTH_K` = 0.7, owner
@@ -245,10 +252,10 @@ Two rounds with the owner on the same day:
wedge, which feathered the SIDES too. Wrong: a shaft of sunlight
through a window has crisp sides. Only its reach fades.
So the falloff is one-dimensional: **along the ray, from the room-side opening
So the falloff is one-dimensional: **along the ray, from the selected opening
inward, and nothing else.** Three invariants have to hold at once:
1. the whole room-side opening is at peak alpha — light does not start out
1. the whole selected opening span is at peak alpha — light does not start out
half-dark at one end of the window;
2. every ray fades over the same distance, its own `len`;
3. the wedge's far edge lies exactly on an iso-alpha line, so the shaft
@@ -257,21 +264,21 @@ inward, and nothing else.** Three invariants have to hold at once:
**The axis of the fade is the wall's INWARD NORMAL, not the ray.**
The light is a bundle of PARALLEL rays, so the distance a point has
travelled from the room-side source span is `depth / cos`, where `depth`
travelled from the selected source span is `depth / cos`, where `depth`
is its perpendicular distance from that span and `cos = dir·normal` is fixed
for the whole wedge. That is an affine function of the point, and its
level sets are straight lines PARALLEL TO THE WALL. A linear gradient
whose axis is the normal therefore describes the travelled distance
exactly:
- `x1,y1` = the middle of the room-side window span (any point of that span —
- `x1,y1` = the middle of the selected window span (any point of that span —
they all have depth 0);
- `x2,y2` = that point plus `normal · len · cos` — `SunRay.depth`, the
perpendicular depth a ray reaches after running the full `len`;
- a point `source + dir·u` lands on offset `u / len`, whichever ray it
rode in on.
Hence: the inner opening span is all at offset 0 (invariant 1), the alpha at any
Hence: the selected opening span is all at offset 0 (invariant 1), the alpha at any
point is a function of how far its own ray has run (invariant 2), and
the parallelogram's far edge — parallel to the wall — IS the gradient's
last iso-alpha line (invariant 3). The «30 % shorter» reach is then a
@@ -319,7 +326,7 @@ of its cheap half, verbatim: «тонкая (1px) чёрная граница п
The contract:
- **Two side edges only.** The rim runs along the two edges that leave
the inner opening corners and travel inward with the ray — `a → a+dir·len`
the selected opening corners and travel inward with the ray — `a → a+dir·len`
and `b → b+dir·len`. Never the source edge `a-b` (that is the source,
not a boundary) and never the far edge (there is nothing left to
outline there — the fill is already at zero, see below).
@@ -331,7 +338,7 @@ The contract:
`x1,y1,x2,y2`** (the wall's inward normal, `depth` long) and **the same
normalised curve** — `rimStops()` returns `rayStops()` by identity, not
by copy, so the two can never drift apart. Only the colour and the peak
differ: `RIM_MAX_ALPHA` = 0.42 at the inner opening, tuned on the demo rig
differ: `RIM_MAX_ALPHA` = 0.42 at the selected opening, tuned on the demo rig
against both extremes (below ~0.3 the line vanishes on paper at kiosk
scale, above ~0.5 it reads as an ink contour over the dark glow
canvas). Zero from `RAY_FADE_END` = 85 % on, like the fill.
+15
View File
@@ -2848,6 +2848,21 @@ require hands on real hardware — they remain for the human pass.
corners of the window opening (the full span translated inward by half
the wall depth), including at an oblique sun angle; no edge starts on the
wall centreline [auto: unit `sun.test.mjs` + `smoke_wall_thickness`]
- [ ] **Window face (#577):** General settings always shows the global
`inner`/`outer` selector, even when rays are off; save/reopen preserves it.
Missing/invalid read-side values resolve to `inner`, backend writes reject
invalid values, full export/import and support projection preserve only a
valid enum [auto: `smoke_sun`, `sun.test.mjs`,
`config-schema-parity.test.mjs`, `test_validation.py`,
`test_ha_import_export.py`, `test_support_package.py`]
- [ ] In `outer`, a thick-wall ray starts at both exterior window corners and
reaches the clean floor only through the physical opening tunnel; wall
body and exterior space never light up. Direction, nominal reach, fade,
colour, thresholds and shadows are identical to `inner`; at `d = 0` the
two results are byte-identical. Switching the pending dialog value
invalidates the geometry memo without waiting for a server revision
[auto: `sun.test.mjs`, `smoke_sun`; golden regression: existing inner
sun scenarios]
- [ ] Brightness + the 3° threshold (2026-08-03): wedges are visibly brighter
(peak alpha 0.30, was 0.18) yet still readable over white paper AND the
dark glow canvas; there is NO gradual ramp near the horizon — below 3°
+10 -5
View File
@@ -908,9 +908,9 @@ YAML-сущность без `unique_id` и строки в Entity Registry: п
Флаг «Открывается в другую сторону» меняет только направление створок: у двери
и окна зеркалит их относительно оси, а у ворот меняет направление 10° поворота.
Положение символа не сдвигается к грани стены. Свет проходит через дверь,
ворота или открытый проём по ширине
внутреннего тоннеля и отсекается откосами. Солнечный луч начинается из
внутренних углов оконного тоннеля.
ворота или открытый проём по ширине внутреннего тоннеля и отсекается откосами.
Солнечный луч начинается из внутренних или внешних углов оконного тоннеля —
режим выбирается в общих настройках, по умолчанию используются внутренние.
В компактной Static-карточке открытый проём также разрывает тело стены и
продолжает текущую заливку/Glow-base через тоннель. Отображение уже существующих
@@ -1638,7 +1638,10 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
компаса. Стрелка N должна буквально указывать туда, где на рисунке находится
истинный север; это не обратная поправка на поворот плана.
3. При необходимости включите **Солнечный свет через окна**.
4. Для отдельного этажа задайте локальный север/режим/включение либо оставьте наследование.
4. В поле **Лучи солнца** выберите начало от внутренних углов окна (прежнее
поведение по умолчанию) или от внешних углов. Это общая настройка для всех
пространств; она доступна и тогда, когда сами лучи временно выключены.
5. Для отдельного этажа задайте локальный север/режим/включение либо оставьте наследование.
Если раньше вы зеркально выставляли компас, чтобы компенсировать ошибочное
направление лучей, после обновления верните стрелку N к реальному северу.
@@ -1652,7 +1655,9 @@ WebP и безопасный SVG; лимит 2 МиБ относится к со
| Переход через 3° | Слой плавно появляется/исчезает за 2 секунды |
| Окно внутреннее | Луча нет |
| Окно не смотрит на солнце | Луча нет |
| Толстая внешняя стена | Луч начинается из внутренних углов тоннеля окна |
| Толстая внешняя стена, режим «внутренние углы» | Луч начинается с комнатной стороны оконного тоннеля |
| Толстая внешняя стена, режим «внешние углы» | Луч начинается с фасадной стороны и проходит к комнате только внутри оконного тоннеля |
| Стена нулевой толщины | Оба режима выглядят одинаково |
| «Скрыть проёмы» включено | Символ окна скрыт, луч продолжает работать |
| Белый план | По бокам луча есть тонкая 1px тёмная кромка для читаемости |
| Любая погода, включая дождь и снег | Погода не влияет на лучи |
+11 -8
View File
@@ -7,7 +7,7 @@ Status: **implemented / evolving** (post beta.4 redesign). Visual reference:
Owner intent: thick walls form one continuous hatched body (seamless L and T),
grow both ways from the room centreline, fills/light stay inside the inner
contour, displayed area is the clean floor, and sun wedges start at the two
room-side corners of an opening.
corners of the globally selected inner or outer opening face.
Code: `src/wall-thickness.ts`, render in `src/houseplan-card.ts` /
`src/space-render.ts`. Sun: `src/sun.ts`. Tests: `test/wall-thickness.test.mjs`,
@@ -369,11 +369,14 @@ invoke it.
## 5. Sun
Wedges do not draw through wall bodies. Their full source span is translated
from the centreline by half the wall depth along the receiving room's inward
normal, so both side rays begin at the two room-side corners of the window
opening. Clip to the receiving room's inner contour. `d = 0` keeps the previous
centreline/full-span wedge. `hide_openings` hides the symbol only.
Wedges do not draw through wall bodies. The global `settings.sun_ray_origin`
selects their source face. `inner` (default and legacy fallback) translates the
full span from the centreline by `+d/2` along the receiving room's inward
normal, so both sides begin at the room-side corners. `outer` translates it by
`-d/2` and includes only the rectangular physical window tunnel before the
receiving room's inner contour; adjacent wall and exterior space stay clipped.
The nominal length and gradient begin at the selected span. `d = 0` makes both
modes identical. `hide_openings` hides the symbol only.
## 6. Tool / hooks / i18n
@@ -446,8 +449,8 @@ handle and cannot split or re-key their atomic records
`demo/smoke_resize_wall_thickness.mjs`); an eligible uniformly thick exact
wall moves through the fixed-topology safe pipeline and Undo restores its
source (`demo/smoke_room_resize.mjs`);
the zero-wall preview paints above the real body; sun starts at the room-side
opening corners; nav mode restores after `can_write`; a 1 cm body uses
the zero-wall preview paints above the real body; sun starts at the selected
inner/outer opening corners; nav mode restores after `can_write`; a 1 cm body uses
solid-only in both full and static cards while a 20 cm body keeps its hatch;
door/window/gate tunnels repeat outer/shared room fills without an axis seam
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
+12 -12
View File
@@ -3,7 +3,7 @@
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"oxipng": "oxipng 10.2.0",
"sourceFingerprint": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceFingerprint": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"captureScriptSha256": "9e4b0ae533407fe003a129078aec020f2c83d67cdb6fc6da696ce3d829beaeb5",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -15,7 +15,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "76c35a87810e3dc6890bda7b43da133f57d8860031204a62cda7115c15950090"
},
"view-touch": {
@@ -26,7 +26,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "795585acb09488236ebb8028a7fcbd918404c5fe01560bb9b4559de13d441625"
},
"space-create": {
@@ -37,7 +37,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "9f0970de09772674c3156b9fc5b2d49de0ceb22be11ecf8b136047d37d100a84"
},
"room-contour-close": {
@@ -48,7 +48,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "0c354a79f9c3660788b6c5e889a27b6bd78b1f209dc0eef6bb43e2c1344028c4"
},
"plan-context-tray": {
@@ -59,7 +59,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "64169c543291bc4df806dcf8ff484b42c8ea55eb343323d945344e0e28f2ce97"
},
"device-editor": {
@@ -70,7 +70,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "3c8703175605fcf0387d654cd30783ecfd33e21743e70b45190a5ae1a8d355c2"
},
"device-display-preview": {
@@ -81,7 +81,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "5d6193202102f5e6d85567f1111fa105895b933979fbf369c6ba0b1029deb823"
},
"background-editor": {
@@ -92,7 +92,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "375900a29aa62632883ede6727b7dbb8b48cabe9d7d0e16425f9779c837f3dd9"
},
"room-card": {
@@ -103,7 +103,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "3d85424676c823ddca3fa7dab3cce089b6030e48c05d16d2d48d922c66c0cbd2"
},
"device-info": {
@@ -114,7 +114,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "ac962820f8e1c5cb5b042d39b18dc95905fa840cb2d4749060d991be4f6977c2"
},
"pdf-export": {
@@ -125,7 +125,7 @@
},
"theme": "light",
"language": "en",
"sourceSha256": "58a0c82061aeaa744df96d8f089d2048469e69743920a6d9d67d4c0b84029d87",
"sourceSha256": "1104398c6aa8885020c8f8b714582b66f586c9cb845846d52813990d87f94454",
"imageSha256": "4537692fd57a3ed95528eddd748eee5cb6e456ffab7cc49169853d62459252cd"
}
},