From 3fa98a09154cc26b89c23ec076eaa3b69f8b0560 Mon Sep 17 00:00:00 2001 From: Matysh Date: Fri, 28 Aug 2026 22:00:24 +0300 Subject: [PATCH] docs: actualize #20 door-state glow spec Issue: #20 User-Visible: no --- docs/specs/020-glow-open-door-spill.md | 300 ++++++++++++++++--------- 1 file changed, 189 insertions(+), 111 deletions(-) diff --git a/docs/specs/020-glow-open-door-spill.md b/docs/specs/020-glow-open-door-spill.md index e5e8ca9f..dea093fe 100644 --- a/docs/specs/020-glow-open-door-spill.md +++ b/docs/specs/020-glow-open-door-spill.md @@ -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 сценариев по файлам.