Files
houseplan-card/docs/specs/273-optimize-topology-island.md
T
2026-08-23 20:23:08 +03:00

347 lines
19 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 #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`
- Статус ТЗ: реализовано, готово к code review
Канонические документы: `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 или
вторым room-topology endpoint;
- прямой 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;
- 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 доказательство.
**Доказательство:** focused geometry unit в `test/wall-thickness.test.mjs` на
effective profile до/после optimizer candidate.
### AC3. Два topology endpoints сохраняются
Table-driven negative fixture, где micro interval соединяет два room/T nodes,
остаётся byte-equivalent. То же для одного opening/open-span endpoint, даже
если neighbors случайно равны.
**Доказательство:** table-driven negative unit в
`test/plan-optimizer.test.mjs` с deep-equal persisted walls.
### AC4. Остальная negative matrix #198 остаётся зелёной
- `length = 0.5 pitch` и больше;
- разные neighbor thickness;
- missing/zero/open neighbor;
- chain/overlapping candidates;
- conflicting exact owners;
- offset/perpendicular/parallel coincidence с room profiles/open cuts;
- настоящий intentional thickness change на topology boundary.
**Доказательство:** расширенная existing #198 negative unit matrix в
`test/plan-optimizer.test.mjs`.
### AC5. Детерминизм и immutability
Reversed endpoints, wall/room permutations, normalized/render scales и
повторный вызов дают один output; inputs deep-equal до/после. Candidates не
каскадируют.
**Доказательство:** permutation/scale table unit и serialized-input snapshot в
`test/plan-optimizer.test.mjs`.
### 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.
**Доказательство:** targeted production-bundle Optimize browser smoke.
### AC7. Runtime остаётся lossless
Без `optimizePlans()` `wallIntervals()`, editor и renderer сохраняют исходный
15-см interval. Новый predicate не вызывается из Save/render helpers.
**Доказательство:** source-level contract unit и существующие lossless
wall/editor regression tests.
### AC8. Мутант ловит слишком широкий guard
Mutation возвращает прежнее условие `isTopologyNode(a) || isTopologyNode(b)`
либо отключает разрешённый single-T branch. AC1 обязан падать. Negative AC3
остаётся зелёным на чистом коде и доказывает, что фикс не равен удалению guard.
**Доказательство:** отдельная исполняемая запись `scripts/mutation-gate.mjs`.
### 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. Совпадение synthetic endpoint с примыканием independent partition/draft не
классифицируется в #273: текущий helper получает только room profile и
`open_spans`, а не `space.partitions`/drafts. Такое совпадение не удаляет и
не перемещает independent axis и меняет только `cm` central room span на
уже доказанную двумя соседями толщину; отдельная identity-aware защита
потребует расширения optimizer input и не заявляется этой задачей.
5. #271 нужна независимо: renderer не должен удлинять short ray даже до
обслуживания данных. #273 отвечает только за обещание Optimize.
6. Продуктовых вопросов нет: исправляется только доказанный равными соседями
artificial breakpoint, все неоднозначные случаи сохраняются.