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

19 KiB
Raw Permalink Blame History

Issue #273 — Optimize схлопывает micro-thickness island у T-узла

  • Дата: 2026-08-23
  • Тип: bug / maintenance canonicalisation · приоритет P1
  • Оценка: пользовательская ценность 8/10 · ценность для разработки 8/10 · сложность 5/10 · риск 6/10
  • Issue: #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, #248, #258 и #271.

1. Сценарий и персона

Администратор явно нажимает «Оптимизировать планы» на старом/импортированном плане. На одном прямом ребре у T-стыка сохраняется профиль 22 → 15 → 22 см, где средний участок короче половины grid step и недоступен для осмысленного редактирования. Optimize завершается, но ступень толщины остаётся видимой и продолжает усложнять downstream multi-wall geometry.

Пользователь ожидает от явного maintenance-действия безопасной канонизации. Это J6: план после Optimize должен быть идемпотентным и физически осмысленным, не удаляя настоящие смысловые границы.

2. Подтверждённое воспроизведение

В приватном beta.5-экспорте после Optimize effective profile содержит:

22 см | 15 см длиной 1.381904 render unit | 22 см

Средний interval:

{
  "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 и применяет:

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, все неоднозначные случаи сохраняются.