26 KiB
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. Подтверждённая причина
Текущая цепочка:
_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. Нормативные источники и приоритет
При расхождении применяются:
- решения владельца и приёмка в #218;
- это ТЗ после зелёного SPEC-REVIEW;
- действующий fail-dark контракт Glow и smoke
smoke_glow_fail_dark; - контракт #197: локальный сбой булевой операции не удаляет валидную геометрию всего пространства;
- текущая реализация как compatibility baseline для явно не изменяемого поведения.
5. Цели
- Сделать булевы операции пола устойчивыми к невидимому floating-point шуму.
- Ограничить отказ одной комнаты этой комнатой, не всем пространством.
- Сохранить fail-dark там, где свет действительно не должен рисоваться.
- Сделать остаточную деградацию диагностируемой и не заспамить консоль.
- Не менять сохранённые координаты и вид валидных планов.
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, нормализуется с quantum1e-6единицы плана; - квантование применяется только к вычисляемой копии; входные массивы не мутируются;
-0канонизируется в0;- соседние дубликаты, возникшие после квантования, удаляются, замыкающая точка добавляется ровно один раз;
- кольцо, у которого после нормализации меньше трёх различных точек или нулевая площадь в пределах quantum, считается непригодным и не передаётся polyclip;
- результат детерминирован, не зависит от порядка тел и повторного вызова;
- смещение любой точки не превышает половины quantum и многократно меньше физической/экранной точности плана.
Quantum является техническим контрактом этой задачи. Его изменение после ревью требует доказать те же ULP-fixtures, raster parity и отсутствие слипания реально различных геометрических деталей.
8.2. Пересечение с полом
Нормальный путь сохраняет объединение валидных комнат до intersection, чтобы перекрытия legacy-планов не превращались в evenodd-дыры. Если объединение или пересечение всего набора всё же падает, применяется покомнатный путь:
- visibility fan остаётся ограничивающей геометрией и никогда не возвращается без floor clipping;
- комнаты обрабатываются независимо;
- успешные результаты объединяются в итоговый набор path fragments;
- комната, для которой boolean operation упала, пропускается и сообщает свой индекс/id через diagnostic callback;
- отсутствие всех валидных комнат возвращает
[].
Fallback не может вернуть raw visibility fan или осветить область вне пола.
9. Диагностика
Pure-функции геометрии не вызывают console напрямую. Они предоставляют
структурированный сигнал о покомнатной ошибке. _renderGlowLayer() связывает
индекс пола с room.id и пишет предупреждение формата, эквивалентного:
HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space <space-id>, room <room-id>
Требования:
- нет 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. Архитектура и зоны изменений
Ожидаемая цепочка:
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
- AC1 — ULP normalization. Два прямоугольника с общей гранью,
отличающейся на один ULP, после
unionBodies()дают одно тело, а normalization-specific assertion подтверждает канонические координаты без мутации входа. Простая пара является проверкой контракта normalization, но не обязана падать на старом polyclip сама по себе. Доказательство: unit; assertion координат красный при mutant без normalization. - AC2 — реальная regression fixture. Анонимизированная шестикомнатная
fixture с точными проблемными парами координат даёт непустой
litи непустой DOM clipPath включённого источника. Доказательство: unit + targeted browser smoke; тест красный на коде до #218. - AC3 — локальная деградация. Самопересекающаяся/намеренно непригодная комната не удаляет пересечение с валидными комнатами. Проблемная комната не получает свет. Доказательство: unit с injected/deterministic failure.
- AC4 — диагностический след. Fallback сообщает space/room id один раз на fingerprint и не раскрывает names, entities, coordinates или exception. Доказательство: unit/browser console capture и повторный render.
- AC5 — fail-dark. Источник в кладке, visibility fan мимо пола, пустой floor
и полный отказ всех комнат остаются тёмными. Доказательство: существующий
smoke_glow_fail_darkбез ослабления плюс unit. - AC6 — неизменность валидных планов. Обычные Glow-сцены дают ту же площадь с допуском boolean quantum и те же pixels в reviewed golden; входные arrays и persisted config не мутируются. Доказательство: unit, golden verify и config round-trip/source review.
- AC7 — Glow-base. На regression fixture каждая eligible-комната имеет текущую Glow-base; если observation не воспроизводится, implementation evidence прямо это фиксирует без лишнего продуктового изменения. Доказательство: targeted browser smoke.
- AC8 — deterministic geometry. Повторный вызов и перестановка room bodies
дают эквивалентную площадь/path coverage;
-0, adjacent duplicates и quantum-degenerate rings обрабатываются явно. Доказательство: unit. - AC9 — performance/privacy. Fast path остаётся O(vertices + текущие boolean operations), fallback не работает постоянно, warning redacted и deduplicated. Доказательство: source review и targeted performance smoke.
- AC10 — release artifacts. Оба changelog, testing docs, mutation registry, generated bundles и screenshot/golden fingerprints актуальны. Доказательство: docs check, build parity и review diff.
17. План автотестов
17.1. Цикл реализации
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
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. Принятые предположения
Можно свободно изменить на ревью ТЗ без вопроса владельцу:
- quantum
1e-6применяется ко всем входамunionBodies(), а не только Glow, потому что это общий boolean boundary и эксперимент #218 подтвердил его; - pure geometry сообщает индексы, а room/space ids добавляет renderer;
- warning дедуплицируется по space + fingerprint + room;
- приватный реальный экспорт не коммитится — только минимальная геометрия с сохранёнными проблемными double-значениями;
- наблюдение Glow-base считается частью #218 только при той же корневой причине.
- простая пара прямоугольников с 1 ULP на текущем polyclip уже объединяется; поэтому красный до исправления regression gate строится на конфликтующей многокомнатной топологии, а простая пара проверяет сам normalization contract.