Files
houseplan-card/docs/specs/229-merge-collinear-partitions.md
T
Codex 1ecd267138 docs: a room junction is a side, not just a corner (#229 r2 M1)
The tolerance fix in r2 named the room's nearest polygon vertex as the point
of contact, which silently excluded the T-junction — a partition meeting the
middle of a room wall. That is a documented product case (141-wall-junctions
§13.1) and the code already measures distance to the edge, not the vertex
(distToSegment over roomEdges). Merging would have run straight through a
legitimate node.

AC2 now proves the room case with a T-junction into the middle of a long
side, and a mutant restores the vertex-only search.

Issue: #229
User-Visible: no
2026-08-21 11:55:51 +03:00

313 lines
25 KiB
Markdown

# 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).