Spec review r1 returned two blocking findings and both were right. A measured side that is itself a passage would have been shortened by its neighbouring walls, while insetContour — the very function the area label already uses — treats that joint as a flat cap and shortens nothing. Length and area would have diverged again, at a different boundary, which is the defect this task exists to remove. The zero rule now comes first and returns the full centreline length for an open side. The thickness source was wrong as a matter of fact, not of taste: an existing test shows thicknessCmAt returns 0 for a whole-edge query against a partially set thickness, so a split-thickness edge would have silently stopped shortening. Half-depths now come from roomWallProfile, the atomic profile that innerContourForRoom already uses for the area, so one edge is resolved by one mechanism. Two acceptance criteria and two mutation guards added for the closed findings. Issue: #233 User-Visible: no
20 KiB
Issue #233 — Ресайз показывает внутренние размеры, а не осевые
- Дата: 2026-08-21
- Тип: bug · приоритет P2 · ценность 7/10 · сложность 3/10 · риск 4/10
- Issue: #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»).
- Внутренними становятся все длины при ресайзе: перетаскиваемая стена, две
смежные (
_rszEdgeLabelsпоказывает три) и габаритw × hугловой рамки. Показывать одно измерение внутренним, а соседние осевыми — хуже текущего состояния. - Толщина 0 — внутренний размер совпадает с осевым, поведение не меняется.
- Проёмы и стороны, открытые в соседнюю комнату: внутренней грани там нет, «от стены до стены» не определено — длина считается по осевой, как сейчас. Это записано явно, а не оставлено на догадку.
- Диагональные стены — та же логика, расстояние между внутренними гранями; отдельной математики не требуется (см. §6).
- Пометки «внутренний размер» нет. Внутренний размер и есть то, что человек ожидает от плана; суффикс означал бы признание двух конвенций.
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* —
половинные глубины стен соответствующих рёбер (в тех же единицах, что точки).
Алгоритм — пересечение внутренних линий, а не индексы внутреннего контура:
- для каждого из трёх рёбер строится линия, смещённая внутрь на свою половинную
глубину (внутренняя нормаль берётся у существующего
inwardNormal); - внутренняя линия ребра
a→bпересекается с внутренними линиями соседей; - результат — расстояние между двумя точками пересечения.
Правило нуля идёт первым и перекрывает всё остальное (находка 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 |
| 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. План автотестов
- Юниты
innerEdgeSpan— таблица из AC1…AC6, включая диагональ 45° и вырожденный случай. - Смок
demo/smoke_resize_inner_dimensions.mjs: комната 300×400 см со стенами 15 см, перетащить ручку стены, прочитать_rszLiveи сверить длины (285 и 385) и неизменность площади. - Регресс:
demo/smoke_draw_wall_thickness.mjs,demo/smoke_wall_thickness_transition.mjsостаются зелёными.
13. Производительность, безопасность, touch
Функция чистая, вызывается для трёх рёбер на кадр перетаскивания; влияния на перф нет. Безопасность не затронута. Touch — см. §8.
14. Откат
Одна ревизия: вернуть _fmtLen по осевым точкам. Данные не затронуты — задача
ничего не пишет.
15. Риски
- Расхождение «длина × длина ≠ площадь» останется и это правильно: площадь вычитает колонны и перегородки внутри комнаты. Риск в том, что пользователь прочитает это как ошибку. Митигация: расхождение теперь объясняется мебелью в комнате, а не системой измерения; в changelog это сказано словами.
- Атомарные участки разной толщины дают одно число на ребро — расстояние между гранями стен на его концах (§7). Это физически верно, а не упрощение: участок другой толщины в середине на это расстояние не влияет.
insetContourне сохраняет вершины — именно поэтому контракт §6 не использует индексы внутреннего контура. Мутантinner-span-ignores-neighbour-thicknessстережёт, что сокращение считается по соседям, а не по своей стене.
16. Release-артефакты
Оба changelog; docs/USER-GUIDE.ru.md и docs/USER-GUIDE.md — фраза про
внутренние размеры; golden не затронут (подписи ресайза живут только во время
перетаскивания и в матрице не участвуют).
17. Принятые предположения (техническое, менять свободно)
- Имя
innerEdgeSpanи место вwall-thickness.ts— рабочее решение; рядом живутinsetContourиinnerContourForRoom, поэтому там же. - Габарит угловой рамки берётся из уже вычисленного внутреннего контура, а не пересечением линий: для bbox это эквивалентно и короче.
- Формат подписи не меняется (
formatLength), меняется только число.
Не является предположением: решения §4 (владелец) и требование §6 не
опираться на индексы внутреннего контура — это следствие проверенного поведения
insetContour.