Files
houseplan-card/docs/specs/234-chain-segment-thickness.md
T
MatyshandSergey Matyunin b2024c6626 docs(spec): state that i18n is untouched, name the zero case
The spec review found the i18n section missing outright — the analysis
comment claimed it was untouched, the document itself said nothing, and a
DoR section cannot be inferred from a comment. It now says so explicitly,
and adds that the absence of i18n files from the diff is part of the
contract rather than an accident.

The waived Low is closed too: the old wallChainSegments treated a recorded
zero as valid while the new contract requires strictly positive values, so
zero is now named in the AC1 examples instead of being derivable from the
prose.

Issue: #234
User-Visible: no
2026-08-21 19:41:37 +03:00

20 KiB
Raw Blame History

Issue #234 — Толщина отрезка цепочки не расходится между превью и записью

  • Дата: 2026-08-21
  • Тип: bug · приоритет P2 · ценность 8/10 · сложность/риск 4/10 и 5/10
  • Issue: #234
  • Ветка: issue/234-chain-segment-thickness
  • Статус ТЗ: на ревью

Канонические документы: docs/SCOPE.md, docs/WALL-THICKNESS.md, docs/CANVAS.md, docs/CONFIG-COMPATIBILITY.md, docs/TOUCH-SUPPORT.md, docs/USER-GUIDE.ru.md.

1. Сценарий и персона

Администратор дома рисует на десктопе цепочку стен инструментом «Стены», меняя поле толщины между отрезками. Цепочка завершается как независимые перегородки либо замыкается в комнату.

Иногда сохранённая толщина отрезка оказывается равной 15 см, хотя на экране во время рисования была нарисована другая. Обнаруживается это позже и случайно: при наведении на комнату контур идёт по 15 см, а инструмент «Толщина» подсвечивает 15 вместо видимых 30. Ручная правка толщины исправляет запись насовсем.

2. Что человек увидит до и после

До: нарисовано 30 см — сохранено 15 см, и об этом ничто не сообщает. Расхождение живёт в конфиге, пока пользователь не заметит его глазами.

После: сохраняется ровно то, что было показано в превью. Если запись толщины для отрезка отсутствует (черновик, сохранённый до этого исправления), отрезок получает толщину предыдущего отрезка своей цепочки — то есть то, что человек видел на экране, — а не глобальные 15 см.

Пользователь не видит новых элементов интерфейса: задача про достоверность чисел, а не про новый контракт.

3. Подтверждённый диагноз

Расхождение доказано исполнением: wallChainSegments(path, [30, 30], 15) на цепочке из трёх отрезков даёт 30 | 30 | 15, а формула превью (houseplan-card.ts:18507) на тех же данных — 30 | 30 | 30.

Причина: «толщина отрезка i» решается в пяти местах тремя разными способами, и они расходятся ровно там, где записи нет.

Место Fallback при отсутствии cms[i]
превью цепочки, houseplan-card.ts:18507 текущее поле drawCm, иначе 15
незамкнутая цепочка → перегородки, :6550 → wall-face-graph.ts:69 15
замкнутая цепочка → перегородки, :13002 15
замкнутый контур → комната, :12618, :12645 edgeCms[0] либо поле
_wallSourceCmAt, :12329-12351 15

Пятое место питает подсветку инструмента «Толщина» — тот самый способ, которым дефект и обнаруживается.

Пропуск в _draftSegmentCms возможен потому, что инвариант «_draftSegmentCms.length === _path.length - 1» нигде не выражен: точка добавляется в _path вызывающим (:7259-7260), а толщина пишется отдельным методом _persistActiveDraftSegment (:7376), который молча выходит, если _drawWallCm == null — состояние «поле толщины в этот момент пусто или вне 1…100», ровно возникающее при правке толщины на ходу. Дублирующая проверка в _canAppendRoomDraftPoint (:7130-7131) сегодня закрывает нормальный путь, но согласованность двух проверок ничем не закреплена, а восстановление черновика из persisted segments (:2618, :7237, :12721, :7364) и из батча граней (:3083, :12738) переносит уже существующий пропуск дальше.

4. Зафиксированные продуктовые решения

  1. Отсутствующая запись наследует толщину предыдущего отрезка той же цепочки; если предыдущего нет — текущее значение поля; если и его нет — DRAW_WALL_DEFAULT_CM. Решение владельца 2026-08-21: это ближе к тому, что человек видел на экране, чем глобальные 15 см, и совпадает с уже существующим поведением пути «контур → комната».
  2. Превью и запись обязаны давать одинаковый вектор толщин на любых входных данных. Это не оптимизация, а определение корректности задачи.
  3. Формат конфигурации не меняется: partitions[].cm и room_drafts[].segments[].cm остаются как есть.

5. Границы задачи

Входит

  • единая функция разрешения толщины отрезка цепочки и её применение во всех пяти местах из §3;
  • явный инвариант длины _draftSegmentCms относительно _path;
  • поведение при загрузке черновика с недостающими segments;
  • тесты, мутанты, changelog.

Не входит

  • изменение самого поля толщины и его валидации (диапазон 1…100 остаётся);
  • изменение того, как толщина применяется к общим участкам между комнатами (applyWallThicknessToNewRoom, setWallThickness) — задача про выбор значения, а не про его раскладку по интервалам;
  • миграция уже испорченных конфигов: исправление действует на новые записи и на чтение черновиков; уже сохранённые перегородки с 15 см правятся инструментом «Толщина», как сейчас. Автоматически «дочинить» их нельзя — исходная задуманная толщина не сохранена нигде.

6. Контракт поведения

Вводится чистая функция (рабочее имя chainSegmentCms) в src/wall-face-graph.ts:

chainSegmentCms(segmentCount, recorded, activeCm, defaultCm) -> number[]
  • segmentCount — число отрезков (path.length - 1, для замкнутого контура — число вершин);
  • recorded — _draftSegmentCms как есть, любой длины, с любым мусором;
  • activeCm — текущее значение поля толщины либо null;
  • defaultCm — DRAW_WALL_DEFAULT_CM.

Правило для каждого индекса i:

  1. recorded[i] — число и > 0 → берётся оно;
  2. иначе — последнее валидное значение среди recorded[0…i-1] (то есть «предыдущий отрезок»);
  3. иначе — activeCm, если он валиден;
  4. иначе — defaultCm.

Функция всегда возвращает массив ровно длины segmentCount, каждый элемент > 0. Она не знает про замыкание контура: значение закрывающего отрезка подаётся вызывающим как recorded[segmentCount - 1], если оно известно (_closingWallCm).

wallChainSegments перестаёт иметь собственный fallback: она принимает уже разрешённый массив толщин.

7. Инвариант и его нарушение

_draftSegmentCms.length обязана равняться числу отрезков _path.

  • Запись становится атомарной: добавление точки в _path и запись её толщины выполняются одним методом; отдельный вызов, способный «промолчать», устраняется. Если толщина невалидна, точка не добавляется вовсе — это уже проверяет _canAppendRoomDraftPoint, и теперь проверка не дублируется, а является единственной.
  • Чтение чинит молча, но не тихо: при загрузке черновика или батча массив приводится к нужной длине через chainSegmentCms; факт дозаполнения пишется в console.debug с id черновика. Пользователю ничего не показывается: он не причина расхождения и починить его не может.

8. Поверхности

Plan-редактор (десктоп, мышь): рисование цепочки, замыкание в комнату, превращение в перегородки, подсветка инструмента «Толщина». View и киоск не затронуты: там ничего не рисуется.

Touch editor: not exposed — рисование цепочки стен на тач-устройствах не поддерживается сегодня и этой задачей не вводится; safety floor docs/TOUCH-SUPPORT.md соблюдён по построению, поведение на тач не меняется.

9. Изменяемые файлы и i18n

  • src/wall-face-graph.ts — новая chainSegmentCms, wallChainSegments без собственного fallback;
  • src/houseplan-card.ts — пять мест из §3 плюс атомарная запись точки;
  • test/wall-face-graph.test.mjs — юниты правила;
  • test/ — юнит на согласие превью и записи (общая функция, один вход — один результат);
  • demo/smoke_wall_chain_thickness.mjs — новый смок;
  • scripts/mutation-gate.mjs — три записи (§11);
  • docs/CHANGELOG.md, docs/CHANGELOG.ru.md.

i18n: не затронут. Ни одной новой пользовательской строки не добавляется и ни одна существующая не меняется: задача исправляет значение, которое пишется в конфиг, а не текст интерфейса. Файлы src/i18n/en.json и src/i18n/ru.json в диффе отсутствуют, и их отсутствие — часть контракта: появление там правок означало бы, что скоуп разъехался.

Миграции и compatibility-полей нет: формат не меняется (§4.3), поля partitions[].cm и room_drafts[].segments[].cm сохраняют тип и смысл, docs/CONFIG-COMPATIBILITY.md править не требуется.

10. Acceptance criteria

AC Требование Доказательство
AC1 chainSegmentCms возвращает массив длины segmentCount, все элементы > 0, для любых входов, включая пустой recorded, дырки в середине, мусор (NaN, -5, null) и явный 0 — прежняя wallChainSegments считала ноль валидным, контракт §6 сдвигает границу на > 0, и это должно быть закреплено примером, а не выводиться из текста unit
AC2 Отсутствующая запись наследует предыдущий валидный отрезок; при отсутствии предыдущего — activeCm; при отсутствии и его — defaultCm unit
AC3 Превью и запись дают идентичный вектор толщин на одних входных данных — проверяется через общую функцию, а не сравнением двух формул unit
AC4 Незамкнутая цепочка из трёх отрезков, нарисованных 30 см при пропуске записи последнего, сохраняется как 30, 30, 30, а не 30, 30, 15 unit + smoke
AC5 Замыкание в комнату сохраняет то же распределение толщин, что показывало превью; общие участки с соседней комнатой сохраняют свою толщину unit
AC6 Подсветка инструмента «Толщина» (_wallSourceCmAt) показывает то же значение, что записано smoke
AC7 Черновик с segments короче points - 1 после загрузки даёт полный вектор по правилу §6 и не роняет рендер unit
AC8 Инвариант: точка не добавляется в _path, если толщина невалидна; после любой последовательности добавлений длины согласованы unit
AC9 release-артефакты: обе записи changelog в том же коммите ревью кода

11. Mutation guards

id Что ломает Что обязано покраснеть
chain-thickness-falls-back-to-default правило §6 п.2 заменяется на defaultCm AC2, AC4
chain-thickness-preview-diverges превью снова считает толщину своей формулой AC3
chain-thickness-ignores-invariant снимается приведение длины при загрузке черновика AC7

Каждый мутант обязан краснеть на своём тесте: две формулы разошлись именно потому, что ни один тест не сравнивал их между собой.

12. План автотестов

  1. Юниты chainSegmentCms — таблица входов из AC1/AC2, включая recorded длиннее segmentCount (лишнее отбрасывается).
  2. Юнит согласия: собрать вектор для превью и для записи через общий путь, сравнить.
  3. Смок demo/smoke_wall_chain_thickness.mjs: нарисовать цепочку из трёх отрезков, меняя поле толщины между кликами, завершить как перегородки, прочитать partitions[].cm из конфига и сверить с нарисованным; отдельная проверка — навести инструмент «Толщина» и сверить подсвеченное значение.
  4. Регресс: demo/smoke_draw_wall_thickness.mjs и demo/smoke_wall_thickness_transition.mjs остаются зелёными.

13. Производительность, безопасность, touch

Функция чистая, вызывается на кадр рисования на массиве длиной не больше MAX_DRAFT_POINTS; влияния на перф нет. Безопасность не затронута. Touch — см. §8.

14. Откат

Одна ревизия: вернуть прежние формулы. Данные при откате не страдают — сохранённые cm остаются валидными числами в существующем формате.

15. Риски

  1. Путь «контур → комната» уже имеет свою логику (edgeCms[source] || cm) и раскладывает толщину по атомарным интервалам. Подмена его на общую функцию может изменить поведение на общих участках между комнатами. Митигация: AC5 и регрессионные смоки; при сомнении — оставить раскладку как есть, заменив только выбор значения.
  2. Замыкающий отрезок (_closingWallCm) участвует в двух путях по-разному. Митигация: он подаётся в recorded вызывающим, функция про него не знает.
  3. Старые черновики могут содержать segments с нулями и отрицательными значениями. Митигация: правило §6 считает валидным только > 0.

16. Release-артефакты

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md — запись про сохранение нарисованной толщины;
  • docs/USER-GUIDE.ru.md — правки не требуются: поведение приводится к тому, что документация и так описывает;
  • golden не затронут: визуальное представление не меняется, менялась только запись.

17. Принятые предположения (техническое, менять свободно)

  1. Имя chainSegmentCms и место в wall-face-graph.ts — рабочее решение; допустимо перенести в logic.ts, если так короче цепочка импортов.
  2. Дозаполнение при чтении логируется в console.debug; если это сочтут шумом, можно убрать — на контракт не влияет.
  3. Приведение длины делается в момент чтения черновика, а не при сохранении: правка чужих записей на диске без действия пользователя мне кажется хуже, чем починка на входе.

Не является предположением: согласие превью и записи (§4.2, AC3) и правило наследования (§4.1, AC2) — это продуктовые решения, зафиксированные владельцем; менять их можно только через issue.