# Issue #218 — floating-point шум одной комнаты не гасит Glow всего пространства - **Issue:** https://github.com/Matysh/houseplan-card/issues/218 - **Связанные контракты:** #19, #55, #61, #65–#67, #197 - **Тип:** bug, обычный полный трек - **Приоритет:** P1 - **Пользовательское изменение:** да ## 1. Сценарий и персона **Персона:** домочадец или гость, который смотрит состояние освещения в View или kiosk, и администратор, проверяющий тот же план в редакторе устройств. **Сценарий:** в пространстве есть несколько комнат и включённый источник света. После обычных операций редактора две координаты общей границы могут отличаться на один ULP (`5.55e-17` или `1.11e-16`). На таком плане polyclip не завершает объединение полов. Сейчас одна комната с этим численным шумом делает пустым clipPath каждого источника пространства, поэтому Glow не виден ни при каких настройках, хотя источник включён и visibility polygon построен. ## 2. Что человек увидит до и после До исправления одна численно нестабильная комната может молча погасить все световые пятна пространства. После исправления обычный floating-point шум не влияет на свет, а действительно некорректная комната в худшем случае исключает только свою часть пола: остальные комнаты продолжают освещаться. ## 3. Подтверждённая причина Текущая цепочка: ```text _renderGlowLayer → intersectionPaths([visibilityPolygon], floor всех комнат) → unionBodies(floor) → polyclip union с исходными double-координатами → exception → null → lit=[] → пустой clipPath ``` `src/physical-geometry.ts` передаёт координаты в polyclip без численной стабилизации. `unionBodies()` проглатывает исключение и возвращает `null`, а `intersectionPaths()` не отличает сбой одной комнаты от отсутствующего пола. В результате fail-dark, нужный против утечки света за пределы дома, применяется к целому пространству вместо минимального проблемного фрагмента. Реальный экспорт владельца локализовал две пары координат с расхождением в последнем бите. Экспериментальное квантование входа union до `1e-6` единицы плана восстановило непустой clipPath. Экспериментальный патч в код не вошёл. ## 4. Нормативные источники и приоритет При расхождении применяются: 1. решения владельца и приёмка в #218; 2. это ТЗ после зелёного SPEC-REVIEW; 3. действующий fail-dark контракт Glow и smoke `smoke_glow_fail_dark`; 4. контракт #197: локальный сбой булевой операции не удаляет валидную геометрию всего пространства; 5. текущая реализация как compatibility baseline для явно не изменяемого поведения. ## 5. Цели 1. Сделать булевы операции пола устойчивыми к невидимому floating-point шуму. 2. Ограничить отказ одной комнаты этой комнатой, не всем пространством. 3. Сохранить fail-dark там, где свет действительно не должен рисоваться. 4. Сделать остаточную деградацию диагностируемой и не заспамить консоль. 5. Не менять сохранённые координаты и вид валидных планов. ## 6. Scope В задачу входят: - численная стабилизация входных колец общего boolean-слоя `src/physical-geometry.ts` непосредственно перед polyclip; - устойчивое пересечение visibility fan с набором комнат; - покомнатная деградация при исключении после стабилизации; - диагностический callback из pure geometry и дедуплицированный `console.warn` на уровне карточки с id пространства и id проблемной комнаты; - минимизированная regression fixture, сохраняющая проблемные координаты из реального экспорта без пользовательских имён, entity ids и остального конфига; - проверка full View, kiosk/тот же renderer и Glow-base; - unit, targeted smoke, mutation gate, golden verification и документация. ## 7. Не входит в задачу - изменение радиуса, цвета, opacity, falloff, blend или анимации Glow; - изменение visibility algorithm, occluders, проёмов или кладки; - ослабление защиты источника внутри стены и луча вне пола; - исправление произвольной самопересекающейся комнаты или автоматический repair; - изменение/сохранение/миграция координат пользователя; - предупреждение в UI, repair issue или новая настройка точности; - логирование названий комнат, координат, entity ids или полного конфига; - изменение статических room fills, если наблюдение Glow-base не связано с той же численной причиной; - принятие golden baseline на Windows или выпуск без команды владельца. ## 8. Численный контракт ### 8.1. Стабилизация - каждое конечное `x/y`, передаваемое из `unionBodies()` в polyclip, нормализуется с quantum `1e-6` единицы плана; - квантование применяется только к вычисляемой копии; входные массивы не мутируются; - `-0` канонизируется в `0`; - соседние дубликаты, возникшие после квантования, удаляются, замыкающая точка добавляется ровно один раз; - кольцо, у которого после нормализации меньше трёх различных точек или нулевая площадь в пределах quantum, считается непригодным и не передаётся polyclip; - результат детерминирован, не зависит от порядка тел и повторного вызова; - смещение любой точки не превышает половины quantum и многократно меньше физической/экранной точности плана. Quantum является техническим контрактом этой задачи. Его изменение после ревью требует доказать те же ULP-fixtures, raster parity и отсутствие слипания реально различных геометрических деталей. ### 8.2. Пересечение с полом Нормальный путь сохраняет объединение валидных комнат до intersection, чтобы перекрытия legacy-планов не превращались в evenodd-дыры. Если объединение или пересечение всего набора всё же падает, применяется покомнатный путь: 1. visibility fan остаётся ограничивающей геометрией и никогда не возвращается без floor clipping; 2. комнаты обрабатываются независимо; 3. успешные результаты объединяются в итоговый набор path fragments; 4. комната, для которой boolean operation упала, пропускается и сообщает свой индекс/id через diagnostic callback; 5. отсутствие всех валидных комнат возвращает `[]`. Fallback не может вернуть raw visibility fan или осветить область вне пола. ## 9. Диагностика Pure-функции геометрии не вызывают `console` напрямую. Они предоставляют структурированный сигнал о покомнатной ошибке. `_renderGlowLayer()` связывает индекс пола с `room.id` и пишет предупреждение формата, эквивалентного: ```text HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space , room ``` Требования: - нет room name, entity id, координат и текста polyclip exception; - одинаковая ошибка логируется не чаще одного раза на сочетание `space id + geometry fingerprint + room id`; - новый fingerprint может дать новое предупреждение, чтобы ремонт геометрии и повторный дефект были различимы; - валидный ULP-шум, исправленный квантованием без fallback, не создаёт warning; - warning не влияет на render result и не требует i18n. ## 10. Glow-base Наблюдение `0 из 6` проверяется на той же минимизированной fixture при условиях, когда текущий контракт действительно требует тёмную базу: Glow включён, а effective room fill отсутствует. Для каждой пригодной комнаты должен существовать `.glow-base`. Если тест уже зелёный, код Glow-base не меняется и результат фиксируется в implementation evidence. Если тест красный из-за того же raw-coordinate boolean path, исправление выполняется общей стабилизацией без отдельного renderer. Иная причина является отдельной задачей и не расширяет #218. ## 11. UX, accessibility и touch Новых элементов UI и взаимодействий нет. View/kiosk, mouse, touch и pen получают один и тот же исправленный render. Pointer targets, focus, tooltip, reduced motion и editor gestures не меняются. Ошибка не показывается домочадцу: для администратора остаётся redacted console warning только при реальной деградации. ## 12. Данные, migration, privacy и i18n Config, layout, backend schema, localStorage и сериализация не меняются. Миграция не нужна. Исходные координаты остаются байт-в-байт прежними после Open → Save без пользовательского изменения. Regression fixture хранит только минимальную анонимизированную геометрию. Новых строк и i18n-ключей нет. ## 13. Архитектура и зоны изменений Ожидаемая цепочка: ```text saved room coordinates (immutable) → normalized boolean input copy → normal union + intersection → per-room fail-dark fallback only on exception → path fragments + structured diagnostics → existing Glow clipPath ``` Ожидаемые зоны: - `src/physical-geometry.ts` — normalization, robust union/intersection API; - `src/houseplan-card.ts` — room ids, deduplicated redacted warning и cache key; - `test/physical-geometry.test.mjs` — ULP, malformed-room, immutability, permutation и fail-dark unit tests; - targeted browser smoke/fixture для реального шестикомнатного случая и Glow-base assertion; - `scripts/mutation-gate.mjs` и его registry test; - `docs/TESTING.md`, `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`; - generated bundles после build. Нельзя создавать второй Glow renderer или применять квантование при записи данных. ## 14. Performance и security - нормальный render path выполняет не больше одного дополнительного линейного прохода по вершинам перед уже существующей boolean operation; - покомнатная обработка запускается только после исключения/невалидного общего результата и не является постоянным per-frame N×boolean path; - существующий LRU clip cache сохраняется; diagnostic dedupe ограничен текущими space/fingerprint keys и очищается/ограничивается вместе с жизненным циклом карточки; - нельзя добавлять таймеры, observers, network calls или persisted telemetry; - warning redacted согласно §9; - fail-dark security boundary не ослабляется. ## 15. Риски | Риск | Последствие | Снижение риска и доказательство | |---|---|---| | Квантование находится в общем `unionBodies()`, а не только в Glow | Невидимый ранее сдвиг кладки, clean floor или `physicalBodiesPath` у соседнего потребителя | Quantum остаётся на много порядков меньше физической и экранной точности; unit покрывает площадь, вершины и input immutability, а существующие wall/floor/physical-geometry tests и golden обязаны остаться зелёными | | Legacy-план содержит реально перекрывающиеся комнаты | На fallback покомнатные fragments могут дать иное evenodd-покрытие, чем общий merged path | Fast path с общим union сохраняется; fallback не склеивает перекрывающиеся fragments в один evenodd path без нормализации покрытия; отдельная overlap-fixture сравнивает площадь/coverage с общим union | | Наблюдение `0 из 6` для Glow-base имеет другую причину | Основной баг будет исправлен, но вторичное наблюдение останется без объяснения | Targeted smoke фиксирует результат; при иной причине в implementation evidence прямо записывается отрицательный результат и создаётся отдельный issue только после подтверждения пользовательской проблемы | | Полный mutation gate слишком дорог для одного review-цикла | Реализация задержится либо мутанты будут заявлены без доказательства | Перед S7 обязательны `--check` и целевые мутанты #218; полный реестр остаётся предрелизным гейтом по PROCESS | | Повторный render одной битой комнаты заспамит консоль или раздует dedupe-set | Диагностика сама станет performance/memory-регрессией | Warning дедуплицируется по ограниченному space/fingerprint/room lifecycle, повторный render проверяется console-capture тестом, размер структуры проверяется source review | | Слишком широкий fallback случайно вернёт raw visibility fan | Свет протечёт за пределы дома или через кладку | Empty floor, source-in-masonry, outside-floor и total-failure tests остаются строгими fail-dark; existing `smoke_glow_fail_dark` не изменяется ради прохождения задачи | Откат §19 безопасен для данных и является последней мерой, если соседний boolean-потребитель меняет видимую геометрию либо performance выходит за действующий Glow budget. ## 16. Acceptance criteria 1. **AC1 — ULP normalization.** Два прямоугольника с общей гранью, отличающейся на один ULP, после `unionBodies()` дают одно тело, а normalization-specific assertion подтверждает канонические координаты без мутации входа. Простая пара является проверкой контракта normalization, но не обязана падать на старом polyclip сама по себе. **Доказательство:** unit; assertion координат красный при mutant без normalization. 2. **AC2 — реальная regression fixture.** Анонимизированная шестикомнатная fixture с точными проблемными парами координат даёт непустой `lit` и непустой DOM clipPath включённого источника. **Доказательство:** unit + targeted browser smoke; тест красный на коде до #218. 3. **AC3 — локальная деградация.** Самопересекающаяся/намеренно непригодная комната не удаляет пересечение с валидными комнатами. Проблемная комната не получает свет. **Доказательство:** unit с injected/deterministic failure. 4. **AC4 — диагностический след.** Fallback сообщает space/room id один раз на fingerprint и не раскрывает names, entities, coordinates или exception. **Доказательство:** unit/browser console capture и повторный render. 5. **AC5 — fail-dark.** Источник в кладке, visibility fan мимо пола, пустой floor и полный отказ всех комнат остаются тёмными. **Доказательство:** существующий `smoke_glow_fail_dark` без ослабления плюс unit. 6. **AC6 — неизменность валидных планов.** Обычные Glow-сцены дают ту же площадь с допуском boolean quantum и те же pixels в reviewed golden; входные arrays и persisted config не мутируются. **Доказательство:** unit, golden verify и config round-trip/source review. 7. **AC7 — Glow-base.** На regression fixture каждая eligible-комната имеет текущую Glow-base; если observation не воспроизводится, implementation evidence прямо это фиксирует без лишнего продуктового изменения. **Доказательство:** targeted browser smoke. 8. **AC8 — deterministic geometry.** Повторный вызов и перестановка room bodies дают эквивалентную площадь/path coverage; `-0`, adjacent duplicates и quantum-degenerate rings обрабатываются явно. **Доказательство:** unit. 9. **AC9 — performance/privacy.** Fast path остаётся O(vertices + текущие boolean operations), fallback не работает постоянно, warning redacted и deduplicated. **Доказательство:** source review и targeted performance smoke. 10. **AC10 — release artifacts.** Оба changelog, testing docs, mutation registry, generated bundles и screenshot/golden fingerprints актуальны. **Доказательство:** docs check, build parity и review diff. ## 17. План автотестов ### 17.1. Цикл реализации ```bash npm run typecheck npm test npm run build ``` Unit matrix: - 1 ULP и `±1e-12/±1e-9` на общей вертикальной/горизонтальной границе; - exact six-room minimized fixture; - invalid room среди двух валидных; - empty/missed floor и source-outside-floor; - input immutability, repeatability, reversed/permuted bodies; - structured callback и diagnostic dedupe. ### 17.2. Targeted smoke перед S7 ```bash node demo/smoke_glow.mjs node demo/smoke_glow_blending.mjs node demo/smoke_glow_fail_dark.mjs ``` Добавляется отдельный узкий smoke либо детерминированное расширение `smoke_glow` для ULP-fixture, DOM clipPath, Glow-base и console warning. ### 17.3. Mutation gate | id | Мутация | Обязанный guard | |---|---|---| | `union-quantization-removed` | убрать normalization перед polyclip | AC1/AC2 unit | | `union-failure-kills-space` | вернуть общий отказ всего floor | AC3 unit/smoke | | `union-failure-silent` | убрать structured diagnostic/warning | AC4 console test | | `glow-fail-dark-weakened` | вернуть raw fan или пропустить source guard | существующий fail-dark smoke | Перед S7 выполняются `node scripts/mutation-gate.mjs --check` и целевые мутанты, если их runtime укладывается в локальный review gate. ### 17.4. Golden и предрелиз Golden verify выполняется на существующих Glow-сценах: валидные планы не должны получить видимый raster diff. Если для #218 добавляется новый visual scenario, его baseline принимается только через `npm run golden:accept -- --reviewed` по полному Linux CI artifact перед бетой. Полный smoke/golden/performance — предрелизный гейт по PROCESS, не цикл реализации. ## 18. Release-артефакты В user-visible implementation commit обязательны: - `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` — одна комната с численным шумом больше не гасит Glow пространства; - `docs/TESTING.md` — ULP-fixture, покомнатный fail-dark fallback и диагностика; - targeted smoke/mutation registry; - generated `dist`, demo и integration bundles; - `docs/images/screenshots.json`, только если build/visual harness этого требует. User Guide не меняется: настройки и пользовательский сценарий остаются прежними. ## 19. Откат Откат выполняется одним user-visible implementation commit вместе с tests, bundles и changelog. Persisted rollback/migration не нужен. При откате вернётся старый полный fail-dark, но сохранённые планы останутся совместимыми. ## 20. Принятые предположения Можно свободно изменить на ревью ТЗ без вопроса владельцу: 1. quantum `1e-6` применяется ко всем входам `unionBodies()`, а не только Glow, потому что это общий boolean boundary и эксперимент #218 подтвердил его; 2. pure geometry сообщает индексы, а room/space ids добавляет renderer; 3. warning дедуплицируется по space + fingerprint + room; 4. приватный реальный экспорт не коммитится — только минимальная геометрия с сохранёнными проблемными double-значениями; 5. наблюдение Glow-base считается частью #218 только при той же корневой причине. 6. простая пара прямоугольников с 1 ULP на текущем polyclip уже объединяется; поэтому красный до исправления regression gate строится на конфликтующей многокомнатной топологии, а простая пара проверяет сам normalization contract.