mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
docs(spec): handle topology-adjacent thickness islands
Issue: #273 User-Visible: no
This commit is contained in:
@@ -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, все неоднозначные случаи сохраняются.
|
||||
Reference in New Issue
Block a user