mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
@@ -1,145 +1,223 @@
|
||||
# ТЗ #20 — Динамический проход Glow через открытые двери и ворота
|
||||
# ТЗ #20 — Glow проходит через дверь по её фактическому состоянию
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/20
|
||||
- Приоритет: P2 по GitHub Project v2
|
||||
- Статус ТЗ: готово к реализации
|
||||
- Связано: #19 additive Glow, #36 room Glow override, #55 independent Glow overlay
|
||||
- Каноническая модель света: [`docs/LIGHT.md`](../LIGHT.md)
|
||||
- Связано: #19, #55, #71, #92, #306
|
||||
|
||||
## Цель
|
||||
## Сценарий
|
||||
|
||||
Состояние привязанного к проёму контакта управляет только проходом Glow через
|
||||
этот проём: открытая дверь или ворота пропускают свет в соседнюю комнату,
|
||||
закрытая — не создаёт световой сектор. Окна по-прежнему не пропускают Glow;
|
||||
их световая модель остаётся частью солнечных лучей.
|
||||
Домочадец или гость смотрит House Plan в обычном View либо на киоск-панели в
|
||||
момент, когда в одной комнате горит свет, а дверь с привязанным контактным
|
||||
датчиком между комнатами открывается или закрывается.
|
||||
|
||||
> **Актуализация 2026-08-11.** Модель транспорта переписана (#71): отдельного
|
||||
> `doorSector()` больше нет. Свет — это полигон видимости из лампы, а проём —
|
||||
> вырез в кладке (`_lightBarriers`). Поэтому реализация этого ТЗ сводится к
|
||||
> одному: проём с нулевой открытой частью просто НЕ вырезается из кладки, то
|
||||
> есть для света он такая же стена, как окно или наружная дверь. Всё, что ниже
|
||||
> сказано про коэффициент открытия и его источник состояния, остаётся в силе;
|
||||
> устарели только упоминания секторов.
|
||||
## Что человек увидит до и после
|
||||
|
||||
## Что происходит сейчас
|
||||
Сейчас свет проходит через такую дверь независимо от её состояния; после
|
||||
изменения закрытая дверь остановит Glow, открытая пропустит его, а частично
|
||||
открытые ворота или cover оставят проход соответствующей ширины.
|
||||
|
||||
`_renderGlowLayer()` в `src/houseplan-card.ts` считает passages как все проёмы,
|
||||
кроме окон, и добавляет `doorSector()` для каждого подходящего проёма независимо
|
||||
от `_openingAmt()`. Поэтому визуально закрытая по контакту дверь продолжает
|
||||
пропускать Glow. Статические wall bodies уже имеют вырез проёма, а выход света
|
||||
в соседнюю комнату создаёт именно дополнительный sector.
|
||||
## Проблема
|
||||
|
||||
Следствие для реализации: не нужно на каждом HA tick перестраивать стеновые
|
||||
bodies или менять модель комнаты. Достаточно сделать состояние частью resolver
|
||||
проходов и генерировать sector только для ненулевой открытой части. Описанное в
|
||||
issue объединение interval понадобится лишь при будущем переходе к interval
|
||||
subtraction; в текущей SVG-модели отдельные clipPath children уже объединяются
|
||||
как union.
|
||||
Каноническая модель `docs/LIGHT.md` строит полигон видимости по реальной кладке:
|
||||
дверь, ворота или сохранённый passage вырезаются из стены и поэтому всегда
|
||||
прозрачны для Glow. `_lightBarriers()` классифицирует тип и наличие пола по обе
|
||||
стороны, но не учитывает состояние привязанного `opening.contact`. В результате
|
||||
визуальный символ может показывать закрытую дверь, а свет продолжает проходить
|
||||
через полный проём.
|
||||
|
||||
## Канонический коэффициент открытия
|
||||
Старое описание задачи упоминало `doorSector()` и отдельные световые секторы.
|
||||
Такой модели после #71 нет: задача меняет только ширину выреза в той же кладке,
|
||||
которая уже является источником барьеров и fail-dark проверки источника.
|
||||
|
||||
Ввести чистый resolver `openingLightAmount(opening, hass, bindingStatus)` с
|
||||
результатом `0..1`. Он должен использовать тот же источник состояния, что и
|
||||
визуал проёма, чтобы дверь не выглядела закрытой и одновременно не пропускала
|
||||
полный свет.
|
||||
## Скоуп
|
||||
|
||||
Правила:
|
||||
- двери и ворота, привязанные к доступному `binary_sensor` или door-like
|
||||
`cover`;
|
||||
- полный, нулевой и частичный коэффициент открытия;
|
||||
- проёмы в контурных стенах и в независимых перегородках;
|
||||
- единый коэффициент для символа проёма и апертуры Glow;
|
||||
- инвалидация barrier/region cache только при изменении релевантного состояния;
|
||||
- View и kiosk; та же геометрия участвует в существующих golden/smoke сценах.
|
||||
|
||||
| Случай | Коэффициент |
|
||||
## Не-скоуп
|
||||
|
||||
- новая настройка, новый элемент интерфейса или сохранение runtime-состояния;
|
||||
- изменение конфигурационной схемы и миграция;
|
||||
- солнечные лучи, room fill, площадь, бумага, tunnel fill и геометрия символа
|
||||
кроме уже поддерживаемого коэффициента открытия;
|
||||
- попытка угадать механику створки: частичная апертура всегда сужается симметрично
|
||||
относительно центра;
|
||||
- свет за наружной дверью или воротами;
|
||||
- изменение fallback переходных состояний cover без числовой позиции;
|
||||
- автоматическое управление дверью или связанной сущностью.
|
||||
|
||||
## Контракт поведения
|
||||
|
||||
### 1. Один коэффициент открытия
|
||||
|
||||
Канонический `openingAmount()` остаётся единственным источником числа `0..1`,
|
||||
которое используют и видимый символ, и Glow. Resolver получает тип проёма,
|
||||
состояние, `invert` и при наличии — `attributes.current_position` связанного
|
||||
cover.
|
||||
|
||||
| Проём / источник | Коэффициент `amount` |
|
||||
|---|---:|
|
||||
| дверь/ворота без привязанного контакта | `1` — сохраняется нынешняя семантика постоянного проёма |
|
||||
| известное закрытое/неактивное состояние | `0` |
|
||||
| известное открытое/активное состояние | `1` |
|
||||
| `cover` с конечным `current_position` | `clamp(position / 100, 0, 1)` |
|
||||
| тот же источник при `invert: true` | `1 - amount` |
|
||||
| привязка missing/disabled либо `unknown`/`unavailable` | семантика непривязанного проёма: `1` для двери/ворот |
|
||||
| окно при любом состоянии | `0` для Glow |
|
||||
| `passage` | всегда `1` |
|
||||
| дверь/ворота без contact | `1` — действующая семантика постоянного проёма |
|
||||
| доступный бинарный contact, закрыто/неактивно | `0` |
|
||||
| доступный бинарный contact, открыто/активно | `1` |
|
||||
| доступный cover с конечным `current_position` | `clamp(position / 100, 0, 1)` |
|
||||
| известное значение при `invert: true` | `1 - amount` |
|
||||
| contact missing/disabled либо `unknown`/`unavailable` | fallback непривязанной двери/ворот: `1`; `invert` его не меняет |
|
||||
| окно | его символ использует resolver как сейчас, но апертура Glow всегда `0` |
|
||||
|
||||
Переходные `opening`/`closing` без числового прогресса не должны создавать
|
||||
выдуманный процент: используется последний известный стабильный amount в
|
||||
runtime-кеше, а если его нет — семантика непривязанного проёма. Runtime-кеш не
|
||||
сохраняется в config и очищается при смене привязки/пространства.
|
||||
Для cover без конечного `current_position` сохраняется действующая интерпретация
|
||||
его строкового state. Задача не вводит last-known кеш и не меняет существующее
|
||||
поведение `opening`/`closing` без позиции. Значения `NaN`, бесконечность и
|
||||
нечисловые строки считаются отсутствующей позицией.
|
||||
|
||||
## Геометрия прохода
|
||||
### 2. Реальная апертура в кладке
|
||||
|
||||
1. Из `passages` исключаются окна и проёмы с amount `<= 0`.
|
||||
2. Для `amount === 1` используется текущий `doorSector()` без изменения
|
||||
геометрии и отсечения стеновыми откосами.
|
||||
3. Для `0 < amount < 1` рабочая апертура сужается до `rlen * amount` вокруг
|
||||
центра проёма; `doorSector()` получает её новые концы и прежний tunnel depth.
|
||||
4. Сектор по-прежнему создаётся только когда `hasRoomBehind()` подтверждает
|
||||
соседнюю комнату. Наружная дверь не освещает фон/бумагу вне дома.
|
||||
5. Физические стены, перегородки и колонны продолжают вычитаться существующим
|
||||
`floorMinusBodies()`; новая логика не ослабляет их occlusion.
|
||||
6. Несколько пересекающихся секторов объединяются SVG clip union. Порядок
|
||||
openings в config не должен менять результат.
|
||||
1. Сначала действует существующая allowlist `door | gate | passage` и проверка
|
||||
пола по обе стороны. Окно, неизвестный будущий тип и наружный проём остаются
|
||||
непрозрачными при любом состоянии.
|
||||
2. Для прошедшего классификацию проёма вычисляется `amount`.
|
||||
3. При `amount <= 0` вырез не создаётся: исходная кладка остаётся целой и
|
||||
останавливает свет.
|
||||
4. При `amount >= 1` используется существующий полный вырез без изменения.
|
||||
5. При `0 < amount < 1` длина выреза равна `renderedLength * amount`, а его центр,
|
||||
ось и глубина стены не меняются.
|
||||
6. Правило одинаково для контурной стены и независимой перегородки. Для
|
||||
перегородки масштабируется именно `PartitionOpeningCut`, а не сохранённый
|
||||
`OpeningCfg` и не видимая геометрия стены.
|
||||
7. Несколько проёмов обрабатываются действующей boolean-геометрией. Порядок
|
||||
записей в конфиге не меняет результат и не создаёт швов между пересекающимися
|
||||
вырезами.
|
||||
|
||||
Сужение вокруг центра — сознательная v1-аппроксимация: модель не знает тип
|
||||
механики створки (сдвижная, одно- или двустворчатая). Специфическое смещение
|
||||
апертуры от края возможно только после появления типа открывания в модели.
|
||||
Источник, поставленный внутрь закрывшегося проёма, попадает в непрозрачную
|
||||
кладку и полностью подавляется существующим fail-dark guard — так же, как
|
||||
источник внутри стены. Отдельного исключения для такого источника нет.
|
||||
|
||||
## Кеширование и обновление
|
||||
### 3. Обновление и кеши
|
||||
|
||||
Статическая геометрия проёмов остаётся привязана к `_cfgEpoch`. Для Glow clip
|
||||
добавляется `openingStateSignature` текущего пространства:
|
||||
Статическая геометрия и HA-состояние разделяются:
|
||||
|
||||
`opening-id:round(amount,3)` для дверей и ворот, отсортировано по id.
|
||||
- `geometryFingerprint` по-прежнему считается только из пространства,
|
||||
`cell_cm` и grid pitch и используется для повторного recut общей стеновой
|
||||
геометрии;
|
||||
- `openingStateSignature` содержит отсортированные по id записи только
|
||||
интерьерных дверей/ворот с привязкой: `id:amount`, где amount округлён до
|
||||
`0.001`;
|
||||
- `barrierFingerprint` включает `geometryFingerprint` и
|
||||
`openingStateSignature`; он ключует `_lightBarrierPool` и возвращается в
|
||||
region cache;
|
||||
- кеш физических тел независимых стен учитывает фактические частичные cuts, а
|
||||
не только множество id прозрачных проёмов;
|
||||
- посторонний HA tick с той же signature обязан попадать в кеш; глобальная
|
||||
очистка на каждый update запрещена.
|
||||
|
||||
Signature включается в `_glowClipCache` key; hass revision, время и состояния
|
||||
посторонних сущностей в key не входят. Изменение контакта должно дать новый clip
|
||||
в ближайшем обычном render tick. Существующий LRU limit сохраняется; отдельная
|
||||
полная очистка кеша на каждый HA update запрещена.
|
||||
Изменение contact должно попасть в обычный render tick без сохранения config.
|
||||
Пулы остаются ограниченными действующими LRU-лимитами.
|
||||
|
||||
## Совместимость и границы scope
|
||||
## UX
|
||||
|
||||
- Config/model/migration не меняются.
|
||||
- Непривязанные двери и ворота выглядят и освещают точно как до задачи.
|
||||
- Состояние контакта влияет только на Glow и существующий визуал проёма; room
|
||||
fill, площадь, hover, tunnel fill и солнечные лучи не меняются.
|
||||
- #55 обязан использовать этот же resolver после отделения Glow от fill mode.
|
||||
- #19 смешивает уже рассчитанные pools и не меняет геометрию sectors.
|
||||
- Недоступный или деактивированный HA contact не превращает архитектурный проём
|
||||
в стену и не блокирует сохранение/просмотр плана.
|
||||
Новых контролов и сообщений нет. Пользователь продолжает привязывать contact в
|
||||
существующем диалоге проёма. Результат виден непосредственно на плане:
|
||||
|
||||
## UX и диагностика
|
||||
- закрытие убирает Glow за дверью;
|
||||
- открытие возвращает его;
|
||||
- позиционный cover меняет ширину светового прохода вместе с отображаемой
|
||||
степенью открытия.
|
||||
|
||||
Новых настроек не добавляется. В существующей информации о проёме допустимо
|
||||
показывать фактический процент только если HA действительно отдаёт
|
||||
`current_position`; бинарный contact остаётся «открыто/закрыто». Ошибка чтения
|
||||
контакта не должна создавать toast на каждый state tick.
|
||||
Недоступность или деактивация contact не превращает архитектурный проём в стену
|
||||
и не порождает повторяющиеся toast/ошибки.
|
||||
|
||||
## Проверки
|
||||
## Модель данных и миграция
|
||||
|
||||
### Unit
|
||||
Модель и сохранённый JSON не меняются. Используются существующие поля
|
||||
`opening.contact` и `opening.invert` и live-state Home Assistant. Старые планы
|
||||
не мигрируются и при отсутствии доступной привязки отображаются как раньше.
|
||||
|
||||
- amount для unbound/open/closed/inverted/unknown/unavailable/disabled;
|
||||
- `cover.current_position`: 0, 1, 50, 100 и значения вне диапазона;
|
||||
- переходные состояния с/без last stable value;
|
||||
- narrowing endpoints и сохранение tunnel-depth clipping;
|
||||
- signature детерминирована и не зависит от порядка openings.
|
||||
## i18n
|
||||
|
||||
### Geometry/render
|
||||
Новых строк нет. Существующие названия состояния проёма и contact не меняются.
|
||||
|
||||
- лампа в A + закрытая дверь в B: Glow остаётся в A;
|
||||
- та же дверь открыта: sector появляется в B за один tick;
|
||||
- 50%: сектор уже полного и проходит между откосами толстой стены;
|
||||
- ворота повторяют дверь; окно не пропускает Glow;
|
||||
- наружная дверь не освещает фон;
|
||||
- две соседние/перекрывающиеся двери не создают тёмный шов;
|
||||
- перегородка или колонна за проёмом продолжает отсекать свет.
|
||||
## Критерии приёмки и доказательства
|
||||
|
||||
### Regression/performance
|
||||
- **AC1.** Лампа в комнате A и закрытая привязанная дверь в B: B не получает
|
||||
Glow. Доказательство: unit геометрии + `demo/smoke_glow.mjs`.
|
||||
- **AC2.** После перехода того же contact в открытое состояние Glow появляется
|
||||
в B не позднее следующего render tick, без config save. Доказательство:
|
||||
browser smoke с переключением fake `hass`.
|
||||
- **AC3.** `cover.current_position = 50` создаёт центрированный вырез ровно в
|
||||
половину полной длины, а символ получает тот же `amount`. Доказательство:
|
||||
unit единого resolver и geometry input + smoke-пиксели по обе стороны jamb.
|
||||
- **AC4.** `invert` меняет известное бинарное/позиционное значение, но не меняет
|
||||
fallback missing/disabled/unknown/unavailable. Доказательство: unit-матрица.
|
||||
- **AC5.** Ворота повторяют дверь; passage всегда прозрачен; окно, неизвестный
|
||||
тип и наружная дверь/ворота всегда непрозрачны для Glow. Доказательство: unit
|
||||
классификатора + расширенный glow smoke.
|
||||
- **AC6.** Те же full/zero/partial правила работают для проёма с
|
||||
`host.kind = partition`; закрытая перегородка и её jamb остаются барьерами.
|
||||
Доказательство: geometry unit + smoke fixture с независимой перегородкой.
|
||||
- **AC7.** Закрытие проёма, внутри которого стоит источник, полностью убирает
|
||||
его Glow. Доказательство: source-guard unit/smoke.
|
||||
- **AC8.** Одинаковая signature переиспользует barrier cache при постороннем HA
|
||||
update; изменение amount создаёт новый barrier/region key; LRU остаётся
|
||||
bounded. Доказательство: cache contract unit.
|
||||
- **AC9.** На large-house fixture переключение 20 door contacts не нарушает
|
||||
действующий `stateUpdate`/Glow performance budget. Доказательство: exact-SHA
|
||||
CI performance smoke перед бетой.
|
||||
- **AC10.** Golden-сцена показывает closed/open/50% на толстой стене без тёмных
|
||||
швов и без света снаружи. Доказательство: reviewed Linux golden arteфакт.
|
||||
|
||||
Golden: closed/open/50% на толстой стене, light и dark HA themes. Large-house
|
||||
fixture переключает 20 контактов; `stateUpdate` p95 остаётся внутри действующего
|
||||
HP-PERF budget, cache bounded, geometry counters не растут от посторонних HA
|
||||
updates.
|
||||
## План автотестов
|
||||
|
||||
## Критерии приёмки
|
||||
1. Расширить `test/logic.test.mjs`: binary, cover position `0/1/50/100`, clamp,
|
||||
invalid value, invert, outage fallback, passage/window.
|
||||
2. Вынести чистую подготовку light apertures/signature либо покрыть
|
||||
существующий чистый geometry boundary так, чтобы unit проверял длину,
|
||||
центрирование, порядок и partition cut без Lit/DOM.
|
||||
3. Расширить `demo/smoke_glow.mjs` динамической сменой contact и измерением
|
||||
пикселей в A, в апертуре, в B, за jamb и снаружи. Тот же smoke проверяет
|
||||
контурную стену и перегородку.
|
||||
4. Добавить/обновить одну детерминированную golden-сцену closed/open/50%; baseline
|
||||
принимается только из полного Linux CI arteфакта по процессу.
|
||||
5. Перед `S7-code-review`: `npm run typecheck`, `npm test`, `npm run build`,
|
||||
`npm run bundle:sync`, `npm run bundle:budget`, `node demo/smoke_glow.mjs`,
|
||||
`node demo/docs/capture.mjs`, затем проверка синхронности bundle tree.
|
||||
6. Перед бетой: exact-SHA Validate с golden, полным browser matrix и
|
||||
performance smoke.
|
||||
|
||||
- Закрытый привязанный проём не создаёт Glow sector, открытый создаёт.
|
||||
- Частичное открытие даёт пропорционально более узкий, корректно отсечённый
|
||||
стеновыми откосами сектор.
|
||||
- Непривязанные проёмы и окна полностью сохраняют прежнее поведение.
|
||||
- Изменение состояния не требует config save и отображается за один render tick.
|
||||
- Model, room area, sun, opening tunnel fill и физическая occlusion не меняются.
|
||||
- Unit, golden, geometry regression и performance gate зелёные.
|
||||
## Риски и меры
|
||||
|
||||
| Риск | Мера |
|
||||
|---|---|
|
||||
| stale Glow после изменения contact | state signature входит и в barriers, и в region fingerprint |
|
||||
| частичный проём режется в контуре, но не в перегородке | отдельный AC6 и общий чистый aperture input |
|
||||
| HA update пересчитывает дорогую boolean-геометрию без нужды | signature содержит только релевантные id/amount, LRU остаётся bounded |
|
||||
| источник внутри закрытой двери протекает на одну сторону | AC7 фиксирует общий fail-dark body |
|
||||
| визуал и свет используют разные проценты | один `openingAmount()` и unit single-source contract |
|
||||
| partial cut создаёт микрошов или вылезает за jamb | clamp, центрирование, существующая boolean-геометрия и golden |
|
||||
|
||||
## Откат
|
||||
|
||||
Изменение не пишет данные. Безопасный откат — вернуть полную апертуру для всех
|
||||
интерьерных door/gate/passage и удалить state signature из barrier cache. Планы,
|
||||
привязки и сохранённые trails при этом не меняются.
|
||||
|
||||
## Release-артефакты
|
||||
|
||||
- записи RU/EN changelog со ссылкой на #20;
|
||||
- актуализация `docs/LIGHT.md` и при необходимости архитектурного описания кеша;
|
||||
- синхронные production/demo bundle trees;
|
||||
- reviewed golden baseline, если пиксели закономерно изменились;
|
||||
- в release body — отдельный пункт только если владелец сочтёт эффект значимым,
|
||||
иначе общий `Small fixes and improvements`.
|
||||
|
||||
## Принято предположительно, поменять свободно на ревью
|
||||
|
||||
- форма чистого aperture helper и место его файла;
|
||||
- точная сериализация signature при сохранении семантики и округления `0.001`;
|
||||
- способ построения частичного `PartitionOpeningCut` без мутации config;
|
||||
- разбиение smoke/golden сценариев по файлам.
|
||||
|
||||
Reference in New Issue
Block a user