Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
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.