docs(spec): measure resize labels between wall faces

Issue: #233
User-Visible: no
This commit is contained in:
Matysh
2026-08-22 02:58:37 +03:00
parent dc9ced2fb4
commit 7b0747888c
+224
View File
@@ -0,0 +1,224 @@
# Issue #233 — Ресайз показывает внутренние размеры, а не осевые
- Дата: 2026-08-21
- Тип: bug · приоритет P2 · ценность 7/10 · сложность 3/10 · риск 4/10
- Issue: [#233](https://github.com/Matysh/houseplan-card/issues/233)
- Ветка: `issue/233-resize-inner-dimensions`
- Статус ТЗ: на ревью
Канонические документы: `docs/SCOPE.md`, `docs/WALL-THICKNESS.md`,
`docs/CANVAS.md`, `docs/USER-GUIDE.ru.md`, `docs/TOUCH-SUPPORT.md`,
`docs/CONFIG-COMPATIBILITY.md`.
## 1. Сценарий и персона
Администратор дома меняет размер комнаты в редакторе разметки: тянет ручку стены
либо угловую рамку масштаба. Во время перетаскивания карточка показывает подписи
— длины и площадь.
## 2. Что человек увидит до и после
**До:** длины считаются по осевым линиям, площадь — по внутреннему контуру. В
одном облачке подписей две разные конвенции: «3.00 × 4.00» по центрам стен и
площадь по полу. Ни одно из чисел нельзя приложить к рулетке, потому что
неизвестно, какое из них в какой системе.
**После:** оба числа про одно и то же — расстояние между стенами. Комната с
осевым пролётом 300 см и стенами 15 см показывает **285 см**: то, что человек
измерит рулеткой.
Новых элементов интерфейса не появляется, подписи остаются на прежних местах.
## 3. Подтверждённый диагноз
- **площадь уже внутренняя.** `_rszEdgeLabels` и `_rszScaleLabels`
(`src/houseplan-card.ts`) строят внутренний контур через `innerContourForRoom`
(`src/wall-thickness.ts`) и вычитают физические тела (`floorMinusBodies`);
- **длины — осевые.** `_rszEdgeLabels` берёт вершины полигона комнаты и зовёт
`_fmtLen(a, b)` → `segmentCm` по этим точкам. Полигон комнаты — это осевые
линии (`docs/WALL-THICKNESS.md`). `_rszScaleLabels` так же: габарит `w × h`
считается по `min/max` полигона.
**Ловушка, из-за которой наивная реализация неверна.** `insetContour`
(`wall-thickness.ts`) **не сохраняет число вершин**: на углу он выдаёт одну
точку (митра), две (бевел, коллинеарный стык, стык с нулевой толщиной) или
исходную вершину. Поэтому «взять ребро i внутреннего контура» — неверно: индексы
не совпадают с осевым полигоном. Кроме того профиль строится по **атомарному**
полигону (`roomWallProfile`), у которого вершин больше, чем у полигона комнаты:
он разрезан в местах общих границ.
## 4. Зафиксированные продуктовые решения
Все — варианты по умолчанию из issue, приняты владельцем 2026-08-21 («принимаю
все default»).
1. **Внутренними становятся все длины при ресайзе:** перетаскиваемая стена, две
смежные (`_rszEdgeLabels` показывает три) и габарит `w × h` угловой рамки.
Показывать одно измерение внутренним, а соседние осевыми — хуже текущего
состояния.
2. **Толщина 0** — внутренний размер совпадает с осевым, поведение не меняется.
3. **Проёмы и стороны, открытые в соседнюю комнату:** внутренней грани там нет,
«от стены до стены» не определено — длина считается **по осевой**, как
сейчас. Это записано явно, а не оставлено на догадку.
4. **Диагональные стены** — та же логика, расстояние между внутренними гранями;
отдельной математики не требуется (см. §6).
5. **Пометки «внутренний размер» нет.** Внутренний размер и есть то, что человек
ожидает от плана; суффикс означал бы признание двух конвенций.
## 5. Границы задачи
### Входит
- чистая функция расстояния между внутренними гранями для ребра контура;
- её применение в `_rszEdgeLabels` (три подписи длин) и `_rszScaleLabels`
(габарит `w × h`);
- тесты, мутанты, changelog, пользовательская документация.
### Не входит
- площадь: она уже внутренняя и **не меняется** этой задачей;
- раскладка толщин по интервалам (`applyWallThicknessToNewRoom`,
`setWallThickness`) — задача про отображение, а не про запись;
- подписи вне ресайза (инструмент «Толщина», тултипы комнат, статический
рендер): там своя семантика, отдельные issue при необходимости;
- какое-либо изменение конфигурации: **новых полей нет, миграции нет**.
## 6. Контракт поведения
Вводится чистая функция в `src/wall-thickness.ts`:
```
innerEdgeSpan(prev, a, b, next, oPrev, oSelf, oNext) -> number
```
где `a→b` — ребро осевого контура, `prev` и `next` — соседние вершины, а `o*` —
половинные глубины стен соответствующих рёбер (в тех же единицах, что точки).
Алгоритм — пересечение внутренних линий, а не индексы внутреннего контура:
1. для каждого из трёх рёбер строится линия, смещённая внутрь на свою половинную
глубину (внутренняя нормаль берётся у существующего `inwardNormal`);
2. внутренняя линия ребра `a→b` пересекается с внутренними линиями соседей;
3. результат — расстояние между двумя точками пересечения.
**Отступления, каждое обязано быть явным:**
- сосед параллелен (пересечения нет) либо `oSelf` и оба соседа равны нулю →
возвращается осевая длина `|b−a|`;
- пересечения дают отрицательную или нулевую длину (стены толще комнаты) →
возвращается `0`, а подпись показывает `0`, не отрицательное число;
- сосед с нулевой толщиной (проём, открытая сторона, §4.3) → на этом конце
сокращения нет.
Для угловой рамки (`_rszScaleLabels`) внутренний габарит берётся как bounding box
**внутреннего контура**, который в этом методе уже вычисляется для площади:
отдельная математика не нужна, `w = max(x) − min(x)` по `floor`.
## 7. Источник толщин для подписи
Половинные глубины берутся существующим `thicknessCmAt(walls, a, b, pitch,
coordScale)` — тем же способом, которым толщину узнаёт остальной редактор, — и
переводятся в единицы через `wallCmToUnits(cm, cellCm, gridPitch) / 2`.
Если одно ребро комнаты разрезано на атомарные участки с разной толщиной,
`thicknessCmAt` вернёт толщину участка по середине ребра. Это принятое
упрощение: подпись — одно число на ребро, и «одно число» для стены переменной
толщины не существует. Записано здесь, чтобы ревью не считало это недосмотром.
## 8. Поверхности
Редактор разметки, инструмент «Размер», десктоп и тач (перетаскивание ручки
стены на тач поддержано сегодня и не меняется). View и киоск не затронуты.
`Touch editor: supported` — ресайз на тач уже работает, задача меняет только
текст подписи; safety floor `docs/TOUCH-SUPPORT.md` соблюдён по построению.
## 9. Изменяемые файлы и i18n
- `src/wall-thickness.ts` — новая `innerEdgeSpan`;
- `src/houseplan-card.ts` — `_rszEdgeLabels`, `_rszScaleLabels`;
- `test/wall-thickness.test.mjs` — юниты функции;
- `demo/smoke_resize_inner_dimensions.mjs` — новый смок;
- `scripts/mutation-gate.mjs` — две записи (§11);
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` и `docs/USER-GUIDE.md` — одна фраза о том, что размеры
при изменении показываются внутренние.
**i18n: не затронут.** Новых строк интерфейса нет, существующие не меняются:
меняется число, а не текст. Отсутствие `src/i18n/*.json` в диффе — часть
контракта.
**Миграции и compatibility-полей нет:** конфигурация не читается и не пишется
этой задачей (`docs/CONFIG-COMPATIBILITY.md` править не требуется).
## 10. Acceptance criteria
| AC | Требование | Доказательство |
|---|---|---|
| AC1 | Прямоугольная комната, осевой пролёт 300 см, стены 15 см: подпись ребра показывает 285 см | unit |
| AC2 | Стены разной толщины по краям (15 и 30 см) сокращают ребро на 7.5 + 15 см | unit |
| AC3 | Толщина 0 на всех рёбрах: внутренняя длина равна осевой | unit |
| AC4 | Ребро с нулевой толщиной соседа (проём, открытая сторона) не сокращается с этого конца | unit |
| AC5 | Диагональное ребро: внутренняя длина равна расстоянию между точками пересечения внутренних линий, а не `|b−a| − o − o` | unit |
| AC6 | Стены толще комнаты: возвращается 0, подпись не показывает отрицательное число | unit |
| AC7 | Все три подписи `_rszEdgeLabels` внутренние; габарит `_rszScaleLabels` — bbox внутреннего контура | smoke |
| AC8 | Площадь при ресайзе **не изменилась** этой задачей — то же значение, что до правки | smoke |
| AC9 | release-артефакты: оба changelog и оба USER-GUIDE в том же коммите | ревью кода |
## 11. Mutation guards
| id | Что ломает | Что краснеет |
|---|---|---|
| `resize-labels-show-centreline` | подписи снова считают осевую длину | AC1, AC7 |
| `inner-span-ignores-neighbour-thickness` | сокращение считается только по своей толщине, соседи игнорируются | AC2 |
## 12. План автотестов
1. Юниты `innerEdgeSpan` — таблица из AC1…AC6, включая диагональ 45° и
вырожденный случай.
2. Смок `demo/smoke_resize_inner_dimensions.mjs`: комната 300×400 см со стенами
15 см, перетащить ручку стены, прочитать `_rszLive` и сверить длины (285 и
385) и неизменность площади.
3. Регресс: `demo/smoke_draw_wall_thickness.mjs`,
`demo/smoke_wall_thickness_transition.mjs` остаются зелёными.
## 13. Производительность, безопасность, touch
Функция чистая, вызывается для трёх рёбер на кадр перетаскивания; влияния на
перф нет. Безопасность не затронута. Touch — см. §8.
## 14. Откат
Одна ревизия: вернуть `_fmtLen` по осевым точкам. Данные не затронуты — задача
ничего не пишет.
## 15. Риски
1. **Расхождение «длина × длина ≠ площадь» останется** и это правильно: площадь
вычитает колонны и перегородки внутри комнаты. Риск в том, что пользователь
прочитает это как ошибку. Митигация: расхождение теперь объясняется мебелью в
комнате, а не системой измерения; в changelog это сказано словами.
2. **Атомарные участки разной толщины** дают одно число на ребро (§7) —
упрощение, названное явно.
3. **`insetContour` не сохраняет вершины** — именно поэтому контракт §6 не
использует индексы внутреннего контура. Мутант
`inner-span-ignores-neighbour-thickness` стережёт, что сокращение считается по
соседям, а не по своей стене.
## 16. Release-артефакты
Оба changelog; `docs/USER-GUIDE.ru.md` и `docs/USER-GUIDE.md` — фраза про
внутренние размеры; golden не затронут (подписи ресайза живут только во время
перетаскивания и в матрице не участвуют).
## 17. Принятые предположения (техническое, менять свободно)
1. Имя `innerEdgeSpan` и место в `wall-thickness.ts` — рабочее решение; рядом
живут `insetContour` и `innerContourForRoom`, поэтому там же.
2. Габарит угловой рамки берётся из уже вычисленного внутреннего контура, а не
пересечением линий: для bbox это эквивалентно и короче.
3. Формат подписи не меняется (`formatLength`), меняется только число.
**Не является предположением:** решения §4 (владелец) и требование §6 не
опираться на индексы внутреннего контура — это следствие проверенного поведения
`insetContour`.