Волна 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 #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. Зафиксированные продуктовые решения
- Отсутствующая запись наследует толщину предыдущего отрезка той же
цепочки; если предыдущего нет — текущее значение поля; если и его нет —
DRAW_WALL_DEFAULT_CM. Решение владельца 2026-08-21: это ближе к тому, что человек видел на экране, чем глобальные 15 см, и совпадает с уже существующим поведением пути «контур → комната». - Превью и запись обязаны давать одинаковый вектор толщин на любых входных данных. Это не оптимизация, а определение корректности задачи.
- Формат конфигурации не меняется:
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:
recorded[i]— число и> 0→ берётся оно;- иначе — последнее валидное значение среди
recorded[0…i-1](то есть «предыдущий отрезок»); - иначе —
activeCm, если он валиден; - иначе —
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. План автотестов
- Юниты
chainSegmentCms— таблица входов из AC1/AC2, включаяrecordedдлиннееsegmentCount(лишнее отбрасывается). - Юнит согласия: собрать вектор для превью и для записи через общий путь, сравнить.
- Смок
demo/smoke_wall_chain_thickness.mjs: нарисовать цепочку из трёх отрезков, меняя поле толщины между кликами, завершить как перегородки, прочитатьpartitions[].cmиз конфига и сверить с нарисованным; отдельная проверка — навести инструмент «Толщина» и сверить подсвеченное значение. - Регресс:
demo/smoke_draw_wall_thickness.mjsиdemo/smoke_wall_thickness_transition.mjsостаются зелёными.
13. Производительность, безопасность, touch
Функция чистая, вызывается на кадр рисования на массиве длиной не больше
MAX_DRAFT_POINTS; влияния на перф нет. Безопасность не затронута. Touch — см.
§8.
14. Откат
Одна ревизия: вернуть прежние формулы. Данные при откате не страдают —
сохранённые cm остаются валидными числами в существующем формате.
15. Риски
- Путь «контур → комната» уже имеет свою логику (
edgeCms[source] || cm) и раскладывает толщину по атомарным интервалам. Подмена его на общую функцию может изменить поведение на общих участках между комнатами. Митигация: AC5 и регрессионные смоки; при сомнении — оставить раскладку как есть, заменив только выбор значения. - Замыкающий отрезок (
_closingWallCm) участвует в двух путях по-разному. Митигация: он подаётся вrecordedвызывающим, функция про него не знает. - Старые черновики могут содержать
segmentsс нулями и отрицательными значениями. Митигация: правило §6 считает валидным только> 0.
16. Release-артефакты
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.md— запись про сохранение нарисованной толщины;docs/USER-GUIDE.ru.md— правки не требуются: поведение приводится к тому, что документация и так описывает;- golden не затронут: визуальное представление не меняется, менялась только запись.
17. Принятые предположения (техническое, менять свободно)
- Имя
chainSegmentCmsи место вwall-face-graph.ts— рабочее решение; допустимо перенести вlogic.ts, если так короче цепочка импортов. - Дозаполнение при чтении логируется в
console.debug; если это сочтут шумом, можно убрать — на контракт не влияет. - Приведение длины делается в момент чтения черновика, а не при сохранении: правка чужих записей на диске без действия пользователя мне кажется хуже, чем починка на входе.
Не является предположением: согласие превью и записи (§4.2, AC3) и правило наследования (§4.1, AC2) — это продуктовые решения, зафиксированные владельцем; менять их можно только через issue.