Files
houseplan-card/docs/specs/233-resize-inner-dimensions.md
T
Matysh bc8c368db9 docs(spec): keep a passage full length, read thickness atomically
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
2026-08-22 03:20:06 +03:00

20 KiB
Raw 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.