diff --git a/docs/specs/233-resize-inner-dimensions.md b/docs/specs/233-resize-inner-dimensions.md new file mode 100644 index 00000000..a3ad4729 --- /dev/null +++ b/docs/specs/233-resize-inner-dimensions.md @@ -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`.