mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
docs: spec for merging collinear wall segments
Written on the owner's decisions of 2026-08-21: merge as the chain is finished, sweep already-drawn plans from "Optimise plans", and keep merging even when an opening sits on the seam. The part that is easy to miss is that last one. An opening stores its position as a fraction of its host's length, so merging two partitions changes the length under it and moves the door unless the fraction is recomputed. AC3 therefore checks the door's coordinates in plan units, not that a field was rewritten. Issue: #229 User-Visible: no
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# 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:6558`) режет цепочку по числу поставленных
|
||||
точек и добавляет **по одной независимой перегородке на сегмент**:
|
||||
|
||||
```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`).
|
||||
- Вызов в «Оптимизировать планы» с новым счётчиком в отчёте.
|
||||
- Пересчёт `host.t` проёмов, висящих на сращиваемых перегородках.
|
||||
- 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. Что считается причиной оставить узел
|
||||
|
||||
На общем конце сходится что-то ещё:
|
||||
|
||||
- третья перегородка (любой толщины, любого направления);
|
||||
- ребро комнаты;
|
||||
- колонна;
|
||||
- конец сохранённого черновика контура.
|
||||
|
||||
Пересечение (не касание концами) узла не создаёт: перегородки в модели
|
||||
независимы и пересекаться могут без общей вершины — такой случай не является
|
||||
стыком и в слиянии не участвует.
|
||||
|
||||
### 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` переписывается на выжившую запись.
|
||||
|
||||
**Инвариант:** координаты центра проёма в единицах плана до и после слияния
|
||||
совпадают с точностью до `EPS_JOIN`. Именно это проверяет AC3 — не «поле
|
||||
пересчитано», а «дверь физически не сдвинулась».
|
||||
|
||||
### 8.5. Границы применения
|
||||
|
||||
- при завершении цепочки сращиваются только записи **этого пространства**;
|
||||
- слияние применяется многократно до стабилизации: три отрезка подряд дают одну
|
||||
запись, а не две;
|
||||
- порядок обхода не влияет на результат (проверяется перестановочным тестом,
|
||||
как в #218).
|
||||
|
||||
## 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 — узел с причиной остаётся.** Слияния не происходит, если на общем конце
|
||||
есть третья перегородка, ребро комнаты, колонна или конец черновика — четыре
|
||||
отдельных случая. **Доказательство:** `unit`.
|
||||
3. **AC3 — проём не двигается.** Перегородка с дверью посередине сращивается с
|
||||
соседней; координаты центра проёма в единицах плана до и после совпадают,
|
||||
`host.id` указывает на существующую запись, `host.t` в пределах `[0,1]`.
|
||||
Тест красный до реализации §8.4. **Доказательство:** `unit`.
|
||||
4. **AC4 — разная толщина не сращивается.** Два коллинеарных отрезка с разными
|
||||
`cm` остаются двумя записями. **Доказательство:** `unit`.
|
||||
5. **AC5 — допуски.** Отрезки, разведённые на расстояние больше `EPS_JOIN`, не
|
||||
сращиваются; отрезки, отличающиеся на ULP-шум, — сращиваются.
|
||||
**Доказательство:** `unit`.
|
||||
6. **AC6 — детерминизм.** Результат не зависит от порядка перегородок в массиве:
|
||||
перестановка входа даёт тот же набор записей (сравнение по геометрии).
|
||||
**Доказательство:** `unit`.
|
||||
7. **AC7 — оптимизатор.** «Оптимизировать планы» сращивает уже накопленные швы,
|
||||
отчёт показывает их число, повторный запуск ничего не меняет
|
||||
(идемпотентность). **Доказательство:** `unit` (`optimizePlans`).
|
||||
8. **AC8 — ничего лишнего.** Стены комнат, колонны и черновики контуров не
|
||||
изменяются; количество комнат и их геометрия те же.
|
||||
**Доказательство:** `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 |
|
||||
| `partition-merge-keeps-relative-t` | не пересчитывать `host.t` | юнит AC3 |
|
||||
|
||||
## 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. **Слияние при завершении цепочки применяется ко всему пространству**, а не
|
||||
только к новым записям: цепочка могла примкнуть к нарисованному ранее отрезку,
|
||||
и шов на стыке — тот же самый случай.
|
||||
@@ -61,6 +61,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
|
||||
| [#219](https://github.com/Matysh/houseplan-card/issues/219) Единая палитра замков и glyph на оранжевых подложках | [219-lock-orange-palette.md](219-lock-orange-palette.md) |
|
||||
| [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) |
|
||||
| [#220](https://github.com/Matysh/houseplan-card/issues/220) Порядок пространств перетаскиванием вкладок | [220-space-tab-reorder.md](220-space-tab-reorder.md) |
|
||||
| [#229](https://github.com/Matysh/houseplan-card/issues/229) Сращивание коллинеарных отрезков стен | [229-merge-collinear-partitions.md](229-merge-collinear-partitions.md) |
|
||||
| [#226](https://github.com/Matysh/houseplan-card/issues/226) Entity-marker не дублируется родительским HA-устройством | [226-entity-parent-dedup.md](226-entity-parent-dedup.md) |
|
||||
| [#223](https://github.com/Matysh/houseplan-card/issues/223) Optimize канонизирует координаты без floating-point шума | [223-optimize-coordinate-canonicalization.md](223-optimize-coordinate-canonicalization.md) |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user