Files
houseplan-card/docs/specs/218-glow-floor-geometry.md
T
2026-08-20 13:43:20 +03:00

360 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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. Архитектура и зоны изменений
Ожидаемая цепочка:
```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.