mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 21:58:56 +00:00
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
313 lines
25 KiB
Markdown
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).
|