Files
houseplan-card/docs/specs/229-merge-collinear-partitions.md
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

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)

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