diff --git a/docs/specs/229-merge-collinear-partitions.md b/docs/specs/229-merge-collinear-partitions.md new file mode 100644 index 00000000..ec5034a5 --- /dev/null +++ b/docs/specs/229-merge-collinear-partitions.md @@ -0,0 +1,253 @@ +# Issue #229 — сращивание коллинеарных отрезков стен + +- **Issue:** https://github.com/Matysh/houseplan-card/issues/229 +- **Связанные контракты:** #173 (инструмент «Стены»), #218/#223/#224 (устойчивость геометрии к floating-point), #228 (проблемы при рисовании) +- **Тип:** enhancement, обычный полный трек +- **Приоритет:** P2 +- **Пользовательское изменение:** да +- **Touch editor: not exposed** — задача не добавляет ни одного жеста; поведение + на сенсорных экранах не меняется вовсе (`docs/TOUCH-SUPPORT.md` §153). + +## 1. Сценарий и персона + +**Персона:** администратор плана — тот, кто рисует и поддерживает планировку +(`docs/SCOPE.md`, job J6). + +**Сценарий:** длинную наружную стену рисуют не одним движением, а в несколько +кликов — так удобнее ставить точки по узлам сетки и следить за длиной. Стена +получается прямой, но состоит из отрезков, и на каждом стыке остаётся узел. +Дальше этот узел мешает: при выделении цепляется не та часть, при перетаскивании +стена ломается пополам, толщину приходится назначать по кускам. + +**Момент:** сразу после завершения цепочки в режиме «Стены». + +## 2. Что человек увидит до и после + +**До:** прямая стена, нарисованная в пять кликов, — это пять отрезков с четырьмя +узлами. Внешне шов не виден, но он проявляется при первом же взаимодействии. + +**После:** та же стена — один отрезок без узлов. Узел остаётся только там, где +для него есть причина: примыкание другой стены или пересечение. + +## 3. Подтверждённая причина + +`_finishWallChain` (`houseplan-card.ts:6558`) режет цепочку по числу поставленных +точек и добавляет **по одной независимой перегородке на сегмент**: + +```ts +for (let i = 0; i < segmentCount; i++) { + const segment = segments[i]; + sp.partitions.push({ id: `partition-${seed}-${i}`, a: …, b: …, cm: segment.cm }); +} +``` + +Слияния коллинеарных соседей нет ни здесь, ни позже. + +**Асимметрия.** Стены комнат такую компактизацию проходят: +`normalizeWallIntervals` (`wall-thickness.ts:1258`) «compact every maximal solid +run of one thickness», оптимизатор её вызывает (`plan-optimizer.ts:483`) и +считает результат в `wallsMerged`. Независимые перегородки в оптимизаторе только +выравниваются по сетке (`align-grid.ts:262`). То есть правило «одна физическая +протяжённость — одна запись» уже сформулировано и действует на половину модели. + +**Подтверждено на данных.** В экспорте владельца из #228 (`1.json`, 9 +перегородок) есть пара коллинеарных соседей одной толщины с общим концом: +`#0 (cm=29, 48.95 м)` и `#4 (cm=29, 16.05 м)`, угол 0.0°. + +## 4. Продуктовые решения владельца (2026-08-21) + +1. **Момент слияния — сразу при завершении цепочки.** Нарисовал прямую в пять + кликов, получил одну стену. +2. **Уже нарисованные планы** сращиваются в «Оптимизировать планы» — явным + действием, с отчётом и возможностью отмены. +3. **Проём на стыке не отменяет слияние:** отрезки сращиваются, а позиции + проёмов пересчитываются так, чтобы двери и окна остались физически на месте. + +## 5. Цели + +- Прямой участок одной толщины без причин для узла хранится одной записью. +- Ни один проём не смещается физически при слиянии. +- Правило «одна протяжённость — одна запись» действует и для перегородок. + +## 6. Scope + +- Чистый модуль слияния (`src/wall-merge.ts`) — правила без DOM, проверяемые юнитами. +- Вызов при завершении цепочки (`_finishWallChain`). +- Вызов в «Оптимизировать планы» с новым счётчиком в отчёте. +- Пересчёт `host.t` проёмов, висящих на сращиваемых перегородках. +- i18n строки счётчика (en + ru), оба changelog. + +## 7. Не входит в задачу + +- **Стены комнат** — их компактизация уже работает (`normalizeWallIntervals`), не трогаем. +- **Колонны и черновики контуров** — у них своя жизнь, слияние к ним неприменимо. +- **Разрезание** сросшейся стены — существующий инструмент, поведение не меняется. +- **Промахи примыкания** (#228, пункт 2) — соседняя причина зубцов, отдельная задача. +- Автоматическое слияние при любой записи конфига — отвергнуто решением §4.1/§4.2. + +## 8. Контракт поведения + +### 8.1. Когда две перегородки сращиваются + +Обе одновременно: + +1. **одинаковая толщина** — `cm` совпадает точно; +2. **коллинеарны** — модуль векторного произведения направляющих ≤ `EPS_ANGLE`; +3. **имеют общий конец** — расстояние между концами ≤ `EPS_JOIN`; +4. **на общем конце нет причины для узла** (§8.2). + +Результат — одна запись: концы дальние, `cm` прежний, `id` — от той из двух, +что идёт раньше в массиве (детерминированно, не по времени создания). + +### 8.2. Что считается причиной оставить узел + +На общем конце сходится что-то ещё: + +- третья перегородка (любой толщины, любого направления); +- ребро комнаты; +- колонна; +- конец сохранённого черновика контура. + +Пересечение (не касание концами) узла не создаёт: перегородки в модели +независимы и пересекаться могут без общей вершины — такой случай не является +стыком и в слиянии не участвует. + +### 8.3. Допуски + +Один источник истины на обе проверки, объявленный константами в модуле: + +- `EPS_ANGLE` — коллинеарность; +- `EPS_JOIN` — совпадение концов. + +Оба выражаются в долях шага сетки, а не в абсолютных единицах: планы живут при +`cell_cm` от 1 до 25 (см. #230), и абсолютный допуск на одном масштабе будет +слишком строгим, на другом — слишком щедрым. + +**Отдельно про floating-point.** После #218/#223/#224 известно, что почти +совпадающие координаты — норма. Допуск обязан их прощать, но не настолько, чтобы +склеить то, что разведено намеренно: нижняя граница — заведомо больше ULP-шума, +верхняя — заведомо меньше одного шага сетки. + +### 8.4. Проёмы + +Позиция проёма хранится как доля длины хозяина: `along = host.t * axisLength` +(`partition-openings.ts:64`). При слиянии длина меняется, поэтому для каждого +проёма, чей хозяин участвует в слиянии: + +1. вычисляется абсолютная позиция вдоль старого хозяина; +2. пересчитывается в долю новой длины с учётом того, какой конец стал началом; +3. если хозяин поглощён — `host.id` переписывается на выжившую запись. + +**Инвариант:** координаты центра проёма в единицах плана до и после слияния +совпадают с точностью до `EPS_JOIN`. Именно это проверяет AC3 — не «поле +пересчитано», а «дверь физически не сдвинулась». + +### 8.5. Границы применения + +- при завершении цепочки сращиваются только записи **этого пространства**; +- слияние применяется многократно до стабилизации: три отрезка подряд дают одну + запись, а не две; +- порядок обхода не влияет на результат (проверяется перестановочным тестом, + как в #218). + +## 9. Данные, i18n, a11y, privacy, security + +- **Данные:** формат перегородки не меняется; меняется их количество и `host.id` + части проёмов. Новых полей нет, `CONFIG_SCHEMA` не трогается, миграции нет. +- **i18n:** строка счётчика в отчёте «Оптимизировать планы» (en + ru). +- **a11y, privacy, security:** без изменений. + +## 10. Performance + +Слияние — разовый проход по перегородкам пространства при завершении цепочки +(десятки записей) и при оптимизации. Влияния на кадр рендера нет: в горячем пути +ничего не добавляется, наоборот, записей становится меньше. + +## 11. Риски + +1. **Проём уезжает.** Главный риск: `host.t` относителен. Закрывается §8.4 и AC3 + с проверкой в единицах плана, а не в долях. +2. **Склеили то, что разведено намеренно.** Пользователь мог оставить два отрезка + с зазором меньше допуска. Нижняя граница `EPS_JOIN` и AC5 стерегут это. +3. **Потеря узла, который был нужен.** Узел на примыкании — часть модели стен; + §8.2 перечисляет причины явно, AC2 проверяет каждую. +4. **Недетерминированность.** Выбор выжившего `id` и порядок обхода не должны + зависеть от времени или порядка кликов — AC6. + +## 12. Acceptance criteria + +1. **AC1 — прямая цепочка становится одной записью.** Пять кликов по одной + прямой при неизменной толщине дают одну перегородку без узлов; концы совпадают + с крайними точками цепочки. **Доказательство:** `unit` + `smoke`. +2. **AC2 — узел с причиной остаётся.** Слияния не происходит, если на общем конце + есть третья перегородка, ребро комнаты, колонна или конец черновика — четыре + отдельных случая. **Доказательство:** `unit`. +3. **AC3 — проём не двигается.** Перегородка с дверью посередине сращивается с + соседней; координаты центра проёма в единицах плана до и после совпадают, + `host.id` указывает на существующую запись, `host.t` в пределах `[0,1]`. + Тест красный до реализации §8.4. **Доказательство:** `unit`. +4. **AC4 — разная толщина не сращивается.** Два коллинеарных отрезка с разными + `cm` остаются двумя записями. **Доказательство:** `unit`. +5. **AC5 — допуски.** Отрезки, разведённые на расстояние больше `EPS_JOIN`, не + сращиваются; отрезки, отличающиеся на ULP-шум, — сращиваются. + **Доказательство:** `unit`. +6. **AC6 — детерминизм.** Результат не зависит от порядка перегородок в массиве: + перестановка входа даёт тот же набор записей (сравнение по геометрии). + **Доказательство:** `unit`. +7. **AC7 — оптимизатор.** «Оптимизировать планы» сращивает уже накопленные швы, + отчёт показывает их число, повторный запуск ничего не меняет + (идемпотентность). **Доказательство:** `unit` (`optimizePlans`). +8. **AC8 — ничего лишнего.** Стены комнат, колонны и черновики контуров не + изменяются; количество комнат и их геометрия те же. + **Доказательство:** `unit` + diff review. +9. **AC9 — release-артефакты.** Оба changelog, строка счётчика в обоих языках, + `dist`/demo/integration бандлы идентичны друг другу. + **Доказательство:** diff + сверка копий бандла. + +## 13. План автотестов + +- `test/wall-merge.test.mjs` — чистые правила: AC1, AC2 (четыре причины), AC4, + AC5, AC6, плюс многократное слияние из §8.5. +- `test/` рядом с оптимизатором — AC7 (слияние накопленного, идемпотентность). +- Проёмы (AC3) — юнит на пересчёт `host.t` с проверкой абсолютных координат через + `resolvePartitionOpening`. +- `demo/smoke_wall_chain_merge.mjs` — цепочка из пяти кликов по прямой в браузере: + на выходе одна запись, узлов на прямом участке нет. +- Существующие смоки рисования и `smoke_subarea` — прогон без правок. + +## 14. Мутационный гейт (`scripts/mutation-gate.mjs`) + +| id | Патч | Guard | +|---|---|---| +| `partition-merge-disabled` | не вызывать слияние в `_finishWallChain` | смок AC1 | +| `partition-merge-ignores-thickness` | сращивать при разном `cm` | юнит AC4 | +| `partition-merge-ignores-junction` | сращивать через примыкание третьей стены | юнит AC2 | +| `partition-merge-keeps-relative-t` | не пересчитывать `host.t` | юнит AC3 | + +## 15. Release-артефакты + +- `docs/CHANGELOG.md` + `docs/CHANGELOG.ru.md` — `User-Visible: yes`; +- строка счётчика в отчёте оптимизатора (en + ru); +- `docs/USER-GUIDE*` — одна строка о том, что прямой участок хранится одной + стеной, а узлы остаются на примыканиях; +- golden не затрагивается: видимый результат на плане не меняется, меняется + внутреннее представление. + +## 16. Откат + +Слияние необратимо в данных (две записи стали одной), но это не потеря: разрезать +стену обратно можно существующим инструментом. Код откатывается снятием вызова; +для планов, уже прошедших слияние, откат не нужен — они остаются валидными. + +## 17. Принятые предположения (техническое, менять свободно) + +1. **Отдельный модуль `src/wall-merge.ts`** вместо правки на месте: правила + тогда проверяются юнитами без браузера — тот же приём, что в `space-order.ts` + (#220). +2. **Выживает `id` записи, идущей раньше в массиве.** Любое правило годится, лишь + бы оно было детерминированным и не зависело от времени; это — самое простое. +3. **Конкретные значения `EPS_ANGLE` и `EPS_JOIN`** подбираются при реализации; + ТЗ фиксирует только их природу (доли шага сетки) и границы (§8.3). +4. **Слияние при завершении цепочки применяется ко всему пространству**, а не + только к новым записям: цепочка могла примкнуть к нарисованному ранее отрезку, + и шов на стыке — тот же самый случай. diff --git a/docs/specs/README.md b/docs/specs/README.md index fb62ed82..4b6eff5a 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -61,6 +61,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным | [#219](https://github.com/Matysh/houseplan-card/issues/219) Единая палитра замков и glyph на оранжевых подложках | [219-lock-orange-palette.md](219-lock-orange-palette.md) | | [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) | | [#220](https://github.com/Matysh/houseplan-card/issues/220) Порядок пространств перетаскиванием вкладок | [220-space-tab-reorder.md](220-space-tab-reorder.md) | +| [#229](https://github.com/Matysh/houseplan-card/issues/229) Сращивание коллинеарных отрезков стен | [229-merge-collinear-partitions.md](229-merge-collinear-partitions.md) | | [#226](https://github.com/Matysh/houseplan-card/issues/226) Entity-marker не дублируется родительским HA-устройством | [226-entity-parent-dedup.md](226-entity-parent-dedup.md) | | [#223](https://github.com/Matysh/houseplan-card/issues/223) Optimize канонизирует координаты без floating-point шума | [223-optimize-coordinate-canonicalization.md](223-optimize-coordinate-canonicalization.md) |