mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
347 lines
19 KiB
Markdown
347 lines
19 KiB
Markdown
# 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, все неоднозначные случаи сохраняются.
|