# 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:6538`) режет цепочку по числу поставленных точек и добавляет **по одной независимой перегородке на сегмент**: ```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`, `houseplan-card.ts:6538`). - Вызов в «Оптимизировать планы» с новым счётчиком в отчёте. - Пересчёт `host.t` и материализованной проекции `x/y/angle` проёмов, висящих на сращиваемых перегородках. - 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. Что считается причиной оставить узел На общем конце сходится что-то ещё: - третья перегородка (любой толщины, любого направления); - ребро комнаты; - колонна; - конец сохранённого черновика контура. Пересечение (не касание концами) узла не создаёт: перегородки в модели независимы и пересекаться могут без общей вершины — такой случай не является стыком и в слиянии не участвует. **Все четыре причины проверяются одним допуском `EPS_JOIN`** — тем же, которым проверяется совпадение концов самих перегородок (§8.3). Отдельного допуска для ребра комнаты, колонны или черновика нет: иначе один и тот же зазор считался бы стыком в одном случае и не считался в другом, а AC2 перестал бы быть однозначным. Точка стыка берётся у ребра комнаты — **ближайшая точка на любой стороне полигона**, а не только его вершина; у колонны — её центр, у черновика — конец сохранённого контура. Именно сторона, а не вершина: T-стык перегородки к середине комнатной стены — штатный случай продукта (`docs/specs/141-wall-junctions.md` §13.1 прямо называет примыкание «к середине существующей wall/partition»), и такой узел обязан пережить слияние. Примитив для этого в коде уже есть — `distToSegment` по рёбрам комнаты (`plan-snap-overlay.ts:313`), считающий расстояние до отрезка. Ревизия r3: прежняя редакция говорила «ближайшая вершина полигона» и пропускала ровно этот случай (находка M1 ревью r2). ### 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` переписывается на выжившую запись. 4. **пересчитывается материализованная проекция** `x/y/angle` того же проёма. `docs/CONFIG-COMPATIBILITY.md` (#132) объявляет legacy `x/y/angle` обязательной компаньонкой хоста: «the legacy `x/y/angle` siblings remain a materialized compatibility projection for older readers», и прямо требует, чтобы full export, plan-only export, merge и оптимизация сохраняли согласованность. В коде это уже норма: после любого изменения хозяина вызывается `materializePartitionOpening` (`houseplan-card.ts:7824` — перетаскивание перегородки, `:12007` — правка проёма). Слияние — такое же изменение хозяина и обязано делать то же самое. **Инвариант:** координаты центра проёма в единицах плана до и после слияния совпадают с точностью до `EPS_JOIN` — **и в резолвленном виде, и в материализованной проекции**. Именно это проверяет AC3: не «поле пересчитано», а «дверь физически не сдвинулась», причём для обоих читателей — текущего фронтенда и того, который умеет читать только `x/y/angle`. ### 8.5. Границы применения - слияние применяется многократно до стабилизации: три отрезка подряд дают одну запись, а не две; - порядок обхода не влияет на результат (проверяется перестановочным тестом, как в #218). ### 8.6. Что именно сращивается при завершении цепочки Решения §4.1 и §4.2 делят работу: рисование чинит **свой** шов, накопленное чинит «Оптимизировать планы» — там для этого есть отчёт и отмена. Поэтому при завершении цепочки слияние затрагивает только записи, связанные с этой цепочкой: сегменты самой цепочки и те существующие перегородки, с которыми она имеет общий конец, — далее транзитивно, пока цепочка стыков не оборвётся. Иными словами, компонента связности по общим концам, содержащая хотя бы один новый сегмент. Перегородки, не связанные с новой цепочкой, не трогаются, даже если между собой они образуют шов, подлежащий слиянию. Такой шов дождётся оптимизатора. **Почему не «всё пространство».** В пространстве владельца из #228 уже лежит коллинеарная пара `#0`/`#4`. Если дорисовать стену в другом углу, «слияние по всему пространству» молча починило бы и эту пару — то есть правка старых данных без отчёта и без выделенной отмены, ровно то, что §4.2 обещает делать только по явной команде. ## 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 — узел с причиной остаётся.** Слияния не происходит, если на общем конце есть третья перегородка, ребро комнаты, колонна или конец черновика — четыре отдельных случая. Случай «ребро комнаты» проверяется **T-стыком к середине стороны**, а не только к её углу: тест с примыканием в середину длинного ребра красный, если реализация ищет лишь вершины полигона. **Доказательство:** `unit`. 3. **AC3 — проём не двигается, в обоих представлениях.** Перегородка с дверью посередине сращивается с соседней; координаты центра, полученные через `resolvePartitionOpening`, до и после совпадают, `host.id` указывает на существующую запись, `host.t` в пределах `[0,1]`. Одновременно материализованные `x/y/angle` того же проёма пересчитаны и согласованы с резолвленной позицией (#132). Тест красный до реализации §8.4 — и остаётся красным, если пересчитан только `host.t`, а проекция устарела. **Доказательство:** `unit`. 4. **AC4 — разная толщина не сращивается.** Два коллинеарных отрезка с разными `cm` остаются двумя записями. **Доказательство:** `unit`. 5. **AC5 — допуски.** Отрезки, разведённые на расстояние больше `EPS_JOIN`, не сращиваются; отрезки, отличающиеся на ULP-шум, — сращиваются. **Доказательство:** `unit`. 6. **AC6 — детерминизм.** Результат не зависит от порядка перегородок в массиве: перестановка входа даёт тот же набор записей (сравнение по геометрии). **Доказательство:** `unit`. 7. **AC7 — оптимизатор.** «Оптимизировать планы» сращивает уже накопленные швы, отчёт показывает их число, повторный запуск ничего не меняет (идемпотентность). **Доказательство:** `unit` (`optimizePlans`). 8. **AC8 — ничего лишнего.** Стены комнат, колонны и черновики контуров не изменяются; количество комнат и их геометрия те же. Отдельно: завершение цепочки не трогает перегородки вне её компоненты связности — пространство с готовым швом в стороне после рисования новой стены сохраняет этот шов до запуска оптимизатора (§8.6). **Доказательство:** `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 | | `junction-checks-room-vertices-only` | искать примыкание комнаты только по вершинам полигона | юнит AC2 (T-стык) | | `partition-merge-keeps-relative-t` | не пересчитывать `host.t` | юнит AC3 | | `partition-merge-skips-materialization` | не обновлять `x/y/angle` проёма | юнит AC3 | | `chain-merge-sweeps-whole-space` | сращивать все перегородки пространства, а не компоненту цепочки | юнит AC8 | ## 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. **Область слияния при завершении цепочки** описана нормативом §8.6, а не здесь: это не свободное предположение, а граница между двумя решениями владельца (§4.1 и §4.2). Ревизия r2: прежняя редакция говорила «применяется ко всему пространству» — сращивало бы и старые швы в другом углу, обходя обещанный для них отчёт и отмену (находка M1 ревью r1).