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
25 KiB
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) режет цепочку по числу поставленных
точек и добавляет по одной независимой перегородке на сегмент:
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)
- Момент слияния — сразу при завершении цепочки. Нарисовал прямую в пять кликов, получил одну стену.
- Уже нарисованные планы сращиваются в «Оптимизировать планы» — явным действием, с отчётом и возможностью отмены.
- Проём на стыке не отменяет слияние: отрезки сращиваются, а позиции проёмов пересчитываются так, чтобы двери и окна остались физически на месте.
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. Когда две перегородки сращиваются
Обе одновременно:
- одинаковая толщина —
cmсовпадает точно; - коллинеарны — модуль векторного произведения направляющих ≤
EPS_ANGLE; - имеют общий конец — расстояние между концами ≤
EPS_JOIN; - на общем конце нет причины для узла (§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). При слиянии длина меняется, поэтому для каждого
проёма, чей хозяин участвует в слиянии:
-
вычисляется абсолютная позиция вдоль старого хозяина;
-
пересчитывается в долю новой длины с учётом того, какой конец стал началом;
-
если хозяин поглощён —
host.idпереписывается на выжившую запись. -
пересчитывается материализованная проекция
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. Риски
- Проём уезжает. Главный риск:
host.tотносителен. Закрывается §8.4 и AC3 с проверкой в единицах плана, а не в долях. - Склеили то, что разведено намеренно. Пользователь мог оставить два отрезка
с зазором меньше допуска. Нижняя граница
EPS_JOINи AC5 стерегут это. - Потеря узла, который был нужен. Узел на примыкании — часть модели стен; §8.2 перечисляет причины явно, AC2 проверяет каждую.
- Недетерминированность. Выбор выжившего
idи порядок обхода не должны зависеть от времени или порядка кликов — AC6.
12. Acceptance criteria
- AC1 — прямая цепочка становится одной записью. Пять кликов по одной
прямой при неизменной толщине дают одну перегородку без узлов; концы совпадают
с крайними точками цепочки. Доказательство:
unit+smoke. - AC2 — узел с причиной остаётся. Слияния не происходит, если на общем конце
есть третья перегородка, ребро комнаты, колонна или конец черновика — четыре
отдельных случая. Случай «ребро комнаты» проверяется T-стыком к середине
стороны, а не только к её углу: тест с примыканием в середину длинного ребра
красный, если реализация ищет лишь вершины полигона. Доказательство:
unit. - AC3 — проём не двигается, в обоих представлениях. Перегородка с дверью
посередине сращивается с соседней; координаты центра, полученные через
resolvePartitionOpening, до и после совпадают,host.idуказывает на существующую запись,host.tв пределах[0,1]. Одновременно материализованныеx/y/angleтого же проёма пересчитаны и согласованы с резолвленной позицией (#132). Тест красный до реализации §8.4 — и остаётся красным, если пересчитан толькоhost.t, а проекция устарела. Доказательство:unit. - AC4 — разная толщина не сращивается. Два коллинеарных отрезка с разными
cmостаются двумя записями. Доказательство:unit. - AC5 — допуски. Отрезки, разведённые на расстояние больше
EPS_JOIN, не сращиваются; отрезки, отличающиеся на ULP-шум, — сращиваются. Доказательство:unit. - AC6 — детерминизм. Результат не зависит от порядка перегородок в массиве:
перестановка входа даёт тот же набор записей (сравнение по геометрии).
Доказательство:
unit. - AC7 — оптимизатор. «Оптимизировать планы» сращивает уже накопленные швы,
отчёт показывает их число, повторный запуск ничего не меняет
(идемпотентность). Доказательство:
unit(optimizePlans). - AC8 — ничего лишнего. Стены комнат, колонны и черновики контуров не
изменяются; количество комнат и их геометрия те же. Отдельно: завершение
цепочки не трогает перегородки вне её компоненты связности — пространство с
готовым швом в стороне после рисования новой стены сохраняет этот шов до
запуска оптимизатора (§8.6). Доказательство:
unit+ diff review. - 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. Принятые предположения (техническое, менять свободно)
- Отдельный модуль
src/wall-merge.tsвместо правки на месте: правила тогда проверяются юнитами без браузера — тот же приём, что вspace-order.ts(#220). - Выживает
idзаписи, идущей раньше в массиве. Любое правило годится, лишь бы оно было детерминированным и не зависело от времени; это — самое простое. - Конкретные значения
EPS_ANGLEиEPS_JOINподбираются при реализации; ТЗ фиксирует только их природу (доли шага сетки) и границы (§8.3). - Область слияния при завершении цепочки описана нормативом §8.6, а не здесь: это не свободное предположение, а граница между двумя решениями владельца (§4.1 и §4.2). Ревизия r2: прежняя редакция говорила «применяется ко всему пространству» — сращивало бы и старые швы в другом углу, обходя обещанный для них отчёт и отмену (находка M1 ревью r1).