# 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` - Статус ТЗ: на ревью (заход r2) Канонические документы: `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. результат — расстояние между двумя точками пересечения. **Правило нуля идёт первым и перекрывает всё остальное (находка H1 ревью r1):** если **сама измеряемая сторона** имеет нулевую толщину — это проём или сторона, открытая в соседнюю комнату, — возвращается осевая длина `|b−a|` целиком, без сокращения на соседей. Причина не в удобстве, а в согласованности с площадью. У проёма нет внутренней грани, от которой измеряют, поэтому подпись показывает полную протяжённость открытой стороны. Ровно так же ведёт себя `insetContour` на стыке нулевой и ненулевой толщины: там flat cap (`#172`), и открытая сторона не укорачивается. Без этого правила длина и площадь снова разошлись бы — только на другой границе, и это был бы тот же дефект, ради которого затевалась задача. **Остальные отступления, каждое обязано быть явным:** - сосед параллелен (пересечения нет) либо `oSelf` и оба соседа равны нулю → возвращается осевая длина `|b−a|`; - пересечения дают отрицательную или нулевую длину (стены толще комнаты) → возвращается `0`, а подпись показывает `0`, не отрицательное число; - сосед с нулевой толщиной (проём, открытая сторона, §4.3) → на этом конце сокращения нет. Для угловой рамки (`_rszScaleLabels`) внутренний габарит берётся как bounding box **внутреннего контура**, который в этом методе уже вычисляется для площади: отдельная математика не нужна, `w = max(x) − min(x)` по `floor`. ## 7. Источник толщин для подписи **`thicknessCmAt` по ребру комнаты не подходит, и это установлено тестом** (находка H2 ревью r1). `test/wall-thickness.test.mjs` «thicknessCmAt exact-parent fallback does not leak from partial or unrelated spans» показывает: запрос по полному ребру против частично заданной толщины возвращает **0**, а не толщину участка. То есть на ребре со сплит-толщиной подписи молча перестали бы сокращаться, и мы получили бы новый вид того же расхождения. Источник половинных глубин — **атомарный профиль** `roomWallProfile(rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale)`, тот самый, из которого `innerContourForRoom` строит контур для площади. Он возвращает атомарный полигон, `cms` и уже готовые `offsets` (половинные глубины в единицах, с учётом `kinds`: у виртуальных и открытых участков там ноль). Длина и площадь одного ребра резолвятся одним механизмом — это и есть смысл задачи. Отображение «ребро комнаты → атомарные участки»: атомарный полигон получен разрезанием того же контура, поэтому участки ребра `a→b` — это последовательные атомарные рёбра, лежащие на отрезке `a→b`. Из них берутся: - **сокращение у конца `a`** — по атомарному соседу, входящему в вершину `a`; - **сокращение у конца `b`** — по атомарному соседу, выходящему из вершины `b`; - **`oSelf`** — по атомарному участку, содержащему середину `a→b`. Ребро со участками разной толщины даёт одно число: расстояние между внутренними гранями стен на его концах. Средний участок другой толщины на это расстояние не влияет — и это верно физически, а не упрощение. Если весь участок середины нулевой, срабатывает правило нуля из §6. ## 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 | | AC6a | Измеряемая сторона — проём (`oSelf = 0`) при соседях-стенах 15 см: подпись показывает полную осевую длину, а не сокращённую (H1) | unit | | AC6b | Ребро со сплит-толщиной (часть 20 см, часть без записи) резолвится через атомарный профиль: сокращение на концах есть, нуля из `thicknessCmAt` не возникает (H2) | 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 | | `inner-span-shortens-a-passage` | правило нуля из §6 снято: проём начинает сокращаться на соседей | AC6a | | `inner-span-reads-whole-edge-thickness` | источник толщин подменяется на `thicknessCmAt` по ребру комнаты | AC6b | ## 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`.