Files
houseplan-card/legacy/specs/233-resize-inner-dimensions.md
Claudeandclaude[bot] df46fd1c3e docs(hygiene): ТЗ выпущенных задач без живых ссылок — в legacy/specs (#682)
Волна 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
2026-09-27 22:10:46 +00:00

20 KiB
Raw Permalink Blame History

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»).

  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
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.