From b5fbe633a60f8a2091552204fc7ff9e3da67fb06 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 23 Aug 2026 19:41:22 +0300 Subject: [PATCH] docs(spec): handle topology-adjacent thickness islands Issue: #273 User-Visible: no --- docs/specs/273-optimize-topology-island.md | 322 +++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/specs/273-optimize-topology-island.md diff --git a/docs/specs/273-optimize-topology-island.md b/docs/specs/273-optimize-topology-island.md new file mode 100644 index 00000000..26e8fdc6 --- /dev/null +++ b/docs/specs/273-optimize-topology-island.md @@ -0,0 +1,322 @@ +# Issue #273 — Optimize схлопывает micro-thickness island у T-узла + +- Дата: 2026-08-23 +- Тип: bug / maintenance canonicalisation · приоритет P1 +- Оценка: пользовательская ценность 8/10 · ценность для разработки 8/10 · + сложность 5/10 · риск 6/10 +- Issue: [#273](https://github.com/Matysh/houseplan-card/issues/273) +- Ветка: `issue/273-optimize-topology-island` +- Статус ТЗ: готово к ревью + +Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`, +`docs/USER-GUIDE.ru.md`, `docs/CONFIG-COMPATIBILITY.md` и `docs/TESTING.md`. + +Связанные задачи: +[#198](https://github.com/Matysh/houseplan-card/issues/198), +[#248](https://github.com/Matysh/houseplan-card/issues/248), +[#258](https://github.com/Matysh/houseplan-card/issues/258) и +[#271](https://github.com/Matysh/houseplan-card/issues/271). + +## 1. Сценарий и персона + +Администратор явно нажимает «Оптимизировать планы» на старом/импортированном +плане. На одном прямом ребре у T-стыка сохраняется профиль `22 → 15 → 22 см`, +где средний участок короче половины grid step и недоступен для осмысленного +редактирования. Optimize завершается, но ступень толщины остаётся видимой и +продолжает усложнять downstream multi-wall geometry. + +Пользователь ожидает от явного maintenance-действия безопасной канонизации. +Это J6: план после Optimize должен быть идемпотентным и физически осмысленным, +не удаляя настоящие смысловые границы. + +## 2. Подтверждённое воспроизведение + +В приватном beta.5-экспорте после Optimize effective profile содержит: + +```text +22 см | 15 см длиной 1.381904 render unit | 22 см +``` + +Средний interval: + +```json +{ + "key": "0.887500,0.345833@0.0000", + "cm": 15, + "a": [0.8875, 0.345833333], + "b": [0.888881904, 0.345833333] +} +``` + +Правый сосед начинается в `0.888881904` и имеет `22 см`; левый effective +сосед того же прямого parent edge также `22 см`. При `GRID_PITCH = 4.166667` +длина центра меньше строгого порога `0.5 × GRID_PITCH = 2.083333`. + +Endpoint `a = (887.5, 345.833)` совпадает с T-узлом: в эту точку приходит +перпендикулярная room boundary. Endpoint `b = (888.881904, 345.833)` — только +off-grid wall-thickness breakpoint внутри прямого ребра. Сам T-node не должен +двигаться; требуется убрать только синтетическую границу `b` и наследовать +доказанные `22 см`. + +Экспорт не коммитится. Тест использует минимальную анонимную topology. + +## 3. Подтверждённая причина + +#198 добавила optimizer-only helper +`collapseIsolatedWallThicknessIslands()`. Он корректно находит короткий центр +между двумя одинаковыми соседями, но затем собирает все room polygon vertices +и open-cut endpoints в общий список `nodes` и применяет: + +```ts +if (isTopologyNode(a) || isTopologyNode(b)) continue; +``` + +Защита трактует случаи «оба endpoints — смысловые границы» и «только один +endpoint — существующий T-node, а второй создан самим micro interval» одинаково. +В подтверждённом случае target thickness уже доказана двумя соседями на одном +parent edge, поэтому изменение толщины центра не удаляет/двигает T-node и не +объединяет геометрию через него. + +Runtime/editor helpers намеренно остаются lossless; дефект находится только в +слишком широком guard явного Optimize. + +## 4. Что человек увидит до и после + +**До:** Optimize оставляет почти точечную ступень `15 см` между двумя стенами +`22 см`; на большом масштабе толщина и bevel выглядят неверно. + +**После:** preview сообщает изменение, а после Apply профиль становится ровным +`22 см`. T-node, перпендикулярная стена, rooms, openings и координаты не +двигаются. Undo возвращает исходные exact entries. Повторный Optimize — no-op. + +## 5. Scope + +### Входит + +- узкое расширение optimizer-only правила #198 для одного room-topology + endpoint; +- классификация синтетического и смыслового endpoint; +- одновременная non-cascading замена толщины доказанных candidates; +- действующие preview/report/Apply/server Undo/storage/idempotence contracts; +- effective interval и render-thickness evidence; +- unit negative matrix, mutation и targeted Optimize smoke; +- пользовательская/техническая документация и оба changelog. + +### Не входит + +- runtime cleanup при чтении, обычном Save или редактировании; +- изменение `normalizeWallIntervals()`, `wallIntervals()` и persisted schema; +- удаление любого короткого участка или выбор толщины по большинству; +- схлопывание между разными соседними thickness; +- очистка interval, ограниченного двумя topology nodes; +- снятие защиты с opening/open-span endpoints; +- перемещение/удаление T-node либо соединение разных parent edges; +- renderer finite-ray fix #271; +- новый UI/report field/i18n/backend/model version. + +## 6. Контракт безопасного кандидата + +### 6.1 Базовые условия #198 + +Central effective interval может наследовать thickness соседей только если: + +1. длина строго `< 0.5 × GRID_PITCH` с действующим ULP guard; +2. слева и справа есть непосредственно соприкасающиеся positive solid + intervals; +3. все три pieces лежат на одной прямой и относятся к одному original parent + edge рассматриваемого room profile; +4. оба соседа имеют одну одинаковую положительную `cm`, отличную от central; +5. exact owners не конфликтуют; +6. candidates собраны на неизменённом snapshot и не применяются каскадно. + +### 6.2 Разрешённый один T-endpoint + +В дополнение к §6.1 допускается ровно один endpoint central interval, +совпадающий с room polygon vertex/derived T-node другого incident edge, если: + +- второй endpoint не является room vertex, endpoint opening/open span или + endpoint другого physical axis; +- прямой parent edge продолжается через T-node своим непосредственным + одинаковым соседом; +- target thickness одинакова по обе стороны central interval; +- замена меняет только `cm` central exact span; coordinate T-node и incident + perpendicular intervals остаются byte-equivalent после canonicalization. + +То, что shared/outer `kind` меняется у T-node, само по себе не запрещает замену: +physical thickness доказана одинаковыми соседями. Kind и ownership не +переписываются helper-ом. + +### 6.3 Всегда блокирующие случаи + +Candidate сохраняется, если: + +- оба central endpoints являются room/topology vertices; +- любой endpoint является opening/open-span boundary; +- второй endpoint совпадает с отдельной room/partition/draft axis boundary; +- target thickness слева/справа различается; +- один сосед отсутствует/zero/open/non-collinear; +- central длина равна или больше половины шага; +- есть overlapping/conflicting exact owners; +- два соседних micro intervals образуют цепочку или candidates перекрываются. + +### 6.4 Результат + +Helper меняет `cm` exact owner либо materialise-ит доказанный replacement тем +же способом, что #198. Следующий `normalizeWallIntervals()` собирает +максимальные равные пролёты. Input config/arrays не мутируются; output +детерминирован по room/wall order, endpoint direction и coordinate scale. + +## 7. Preview, запись, Undo и compatibility + +Первый `optimizePlans()` возвращает `changed: true`; затронутое пространство +входит в `canonicalized`, а уменьшение exact fragments отражается действующим +`wallsMerged`. Нового счётчика/строки нет. Cancel ничего не пишет; Apply +использует обычную paired transaction; server Undo возвращает исходный +`22→15→22` профиль. + +После canonical storage round-trip второй Optimize возвращает `changed: false` +и нулевые report deltas по контракту #248. `model_version`/schema не меняются. +Старый клиент читает единый обычный 22-см wall entry. Runtime без Optimize +по-прежнему losslessly показывает исходные данные. + +## 8. UX, accessibility, touch, security и performance + +- Используются существующие admin-only preview/Apply/Cancel/Undo. +- Новых controls, focus/keyboard/touch/ARIA и locale keys нет. +- Новых HA calls, permissions, URL/HTML и security boundaries нет. +- Pass исполняется только по явному Optimize. Допустим дополнительный bounded + lookup endpoints в уже построенном profile; render/state ticks не меняются. +- Нельзя добавлять глобальный all-pairs geometry scan, если те же отношения + выводятся из parent/profile indices. + +## 9. Acceptance criteria и доказательства + +### AC1. Реальный `22→15→22` у T-node схлопывается + +Minimized fixture сохраняет T-node в `a`, synthetic off-grid endpoint в `b`, +central length `1.381904`, neighbors `22`, centre `15`. После Optimize +effective profile и persisted walls не содержат `15 см`/`b`; остаётся единый +22-см physical span, а perpendicular edge/T coordinate не меняются. + +**Доказательство:** focused `test/plan-optimizer.test.mjs` плюс +`wallIntervals()` assertion. + +### AC2. Визуальная толщина действительно ровная + +До Optimize point/edge probes различают 15- и 22-см half-depth. После результата +`wallBodiesGeometry()` имеет одинаковые faces по обе стороны бывшего `b` и не +создаёт локальную ступень. Это не golden-only доказательство. + +### AC3. Два topology endpoints сохраняются + +Table-driven negative fixture, где micro interval соединяет два room/T nodes, +остаётся byte-equivalent. То же для одного opening/open-span endpoint, даже +если neighbors случайно равны. + +### AC4. Остальная negative matrix #198 остаётся зелёной + +- `length = 0.5 pitch` и больше; +- разные neighbor thickness; +- missing/zero/open neighbor; +- chain/overlapping candidates; +- conflicting exact owners; +- offset/perpendicular/parallel coincidence; +- настоящий intentional thickness change на topology boundary. + +### AC5. Детерминизм и immutability + +Reversed endpoints, wall/room permutations, normalized/render scales и +повторный вызов дают один output; inputs deep-equal до/после. Candidates не +каскадируют. + +### AC6. Preview/Apply/Undo/idempotence + +Production-bundle smoke доказывает: + +- Preview показывает существующие aggregate counts; +- Cancel не пишет; +- Apply сохраняет ровный profile через authoritative endpoint; +- reload видит тот же canonical JSON; +- Undo возвращает exact исходный micro profile; +- следующий Optimize no-op. + +### AC7. Runtime остаётся lossless + +Без `optimizePlans()` `wallIntervals()`, editor и renderer сохраняют исходный +15-см interval. Новый predicate не вызывается из Save/render helpers. + +### AC8. Мутант ловит слишком широкий guard + +Mutation возвращает прежнее условие `isTopologyNode(a) || isTopologyNode(b)` +либо отключает разрешённый single-T branch. AC1 обязан падать. Negative AC3 +остаётся зелёным на чистом коде и доказывает, что фикс не равен удалению guard. + +### AC9. Локальные гейты реализации + +- `npm run typecheck`; +- `npm test`; +- `npm run build` и bundle parity; +- `node scripts/check-docs.mjs`; +- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` и выбранный + Optimize smoke; +- целевой mutation. + +Полные golden/smoke/performance и Linux HA harness остаются prerelease gates. + +## 10. Ожидаемые файлы + +Product code: + +- `src/plan-optimizer.ts` либо узкий pure optimizer helper. + +Tests/evidence: + +- `test/plan-optimizer.test.mjs`; +- при необходимости `test/wall-thickness.test.mjs` для render profile; +- targeted Optimize browser smoke и registry; +- `scripts/mutation-gate.mjs`. + +Документация: + +- `docs/WALL-THICKNESS.md`, `docs/CONFIG-COMPATIBILITY.md`, + `docs/TESTING.md`; +- `docs/USER-GUIDE.md`, `docs/USER-GUIDE.ru.md`; +- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`. + +## 11. Release-артефакты + +Implementation-коммит имеет trailers `Issue: #273` и `User-Visible: yes`, +обновляет оба changelog в том же commit. Отдельные i18n/schema/backend/security +артефакты не нужны. Если source fingerprint затронет docs screenshots, они +принимаются только из полного Linux workflow artifact. Перед beta обязательны +полный golden, smoke, performance и exact-SHA Validate. + +## 12. Риски и меры + +| Риск | Мера | +|---|---| +| Удаление намеренной границы у узла | Ровно один room-topology endpoint, equal neighbors, same parent; AC3/AC4. | +| Opening boundary ошибочно сочтётся synthetic | Явный запрет §6.3 и unit. | +| Target выбран через kind/majority | Только равная `cm` обоих непосредственных соседей. | +| Цепочка схлопнется каскадно | Snapshot candidates и overlap guard #198. | +| Чистый JSON, но renderer всё ещё ступенчатый | AC2 проверяет effective geometry. | + +## 13. Rollback + +Откатывается single-T allowance вместе с unit/smoke/mutation и документацией. +Уже оптимизированный единый 22-см entry остаётся валидным; восстановить старый +micro interval можно только существующим server Undo или backup. Миграции и +отдельного data rollback нет. + +## 14. Принятые технические предположения + +1. Разрешается один **room/T topology** endpoint, но opening/open-span endpoint + остаётся блокирующим: проём — явная смысловая граница. +2. Изменение shared/outer kind у T не делает thickness неоднозначной, если два + непосредственных parent-edge соседа имеют одну `cm`. +3. Helper может классифицировать synthetic endpoint через profile provenance, + а не отдельный глобальный registry; конкретная структура техническая. +4. #271 нужна независимо: renderer не должен удлинять short ray даже до + обслуживания данных. #273 отвечает только за обещание Optimize. +5. Продуктовых вопросов нет: исправляется только доказанный равными соседями + artificial breakpoint, все неоднозначные случаи сохраняются.