Волна 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
35 KiB
Issue #197 — один junction-патч не гасит кладку всего плана
- Issue: https://github.com/Matysh/houseplan-card/issues/197
- Редакция: r2 после
docs/reviews/SPEC-REVIEW-197-r1.md; статус определяется только метками issue - Тип / приоритет: bug / P2
- Оценка: пользовательская ценность 9/10; ценность для разработки 9/10; сложность 6/10; риск 8/10
- Область: каноническая геометрия стен, Plan, View/киоск, static card, hidden Iso, clean floor/paper, Glow и солнце
- Модель данных: без изменений и миграции
- Связано: #141, #150, #198, #199, #201,
docs/WALL-THICKNESS.md,docs/ARCHITECTURE.md,docs/TOUCH-SUPPORT.md
0. Ответ на ревью r1
High-1 принят в части воспроизводимости: первая редакция не проговорила
критически важный контракт координат WallEntry.a/b, поэтому независимый
повтор оказался не тем production-вызовом, который выполнял автор.
В коде ревью комнаты, cuts и walls[].a/b были заранее умножены на 1000.
Это двойное масштабирование wall endpoints: WallEntry.a/b являются
persisted config coordinates, а entrySpan(w, coordScale) в
src/wall-thickness.ts:124–129 сам умножает их на coordScale. Production
renderer масштабирует room polygons и open cuts, но передаёт space.walls
неизменными. При корректном вызове исходный fixture по-прежнему даёт один patch
и wallBodiesGeometry() === null на 19e92e0.
R2 закрывает High не возражением на словах, а:
- фиксирует координатный контракт явно;
- добавляет ниже полный исполняемый reproducer, который сам извлекает fixture из issue и не допускает ручного преобразования walls;
- уточняет AC1/AC2 так, чтобы unit сначала доказывал правильную подготовку
fixture и наличие patch, а затем красный
nullисходного кода; - отделяет #201: прямой exact-key helper
thicknessCmAt()действительно даёт0на новом atomic child, но canonicalwallIntervals()/intervalCmAt()на production input дают20 см; поэтому #201 не подавляет patch и остаётся отдельной задачей для собственной аналитики вызовов.
1. Сценарий и персона
Персона: домашний администратор, который уже построил подробный план и периодически обслуживает его через «Общие настройки → Оптимизировать планы».
Поверхность и момент: после оптимизации или обычного открытия ранее сохранённого много-комнатного плана человек переходит в View либо Plan. В одном виртуальном T-стыке сходятся реальные стены соседних комнат с ненулевой толщиной.
Задача поддерживает J1, J4 и J6 из docs/SCOPE.md: план должен правдиво
показывать пространственную структуру, штатно обслуживаться через GUI и не
терять физическую геометрию от одного локального численного отказа. View,
киоск и static card — блокирующие поверхности; Plan editor остаётся
desktop-first, но не может создавать или показывать иную физику.
2. Что человек увидит до и после
До: из-за одного стыка во всём пространстве исчезает штрихованная кладка; между отступившими заливками комнат остаются белые полосы полной или половинной толщины, как будто все стены одновременно стали прозрачными.
После: весь план продолжает показывать стены. Проблемный T-стык строится обычно; даже если его дополнительный соединительный фрагмент численно необрабатываем, отказ ограничивается этим фрагментом и не удаляет остальную кладку, бумагу и физические препятствия.
3. Подтверждённая проблема и причина
Дефект воспроизведён на origin/dev 19e92e0 с анонимизированным fixture из
issue и production-параметрами:
- 8 комнат, 25 wall-записей, 3
open_spans; - только
rooms[].polyиopen_spansпереведены в render coordinates черезNORM_W = 1000;walls[].a/bоставлены в persisted config coordinates; pitch = GRID_STEP_N,cell_cm = 5,gridPitch = GRID_PITCH,coordScale = 1000;wallIntervals()успешно разрешает значения15/20/22/28/29/33 см;wallBodiesGeometry(...)возвращаетnull.
walls[].a/b нельзя предварительно умножать на NORM_W: entrySpan() делает
это внутри по переданному coordScale. Следующий reproducer является
каноническим для проверки r2 после обычной сборки test-build:
import { execFileSync } from 'node:child_process';
import { wallBodiesGeometry, wallIntervals } from './test-build/wall-thickness.js';
import { resolveOpenCuts } from './test-build/open-spans.js';
import { GRID_PITCH, GRID_STEP_N, NORM_W } from './test-build/space-geometry.js';
const issue = JSON.parse(execFileSync('gh', [
'issue', 'view', '197', '--repo', 'Matysh/houseplan-card', '--json', 'body',
], { encoding: 'utf8' }));
const raw = issue.body.match(/```json\s*([\s\S]*?)```/)[1];
const fixture = JSON.parse(raw);
const rooms = fixture.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const walls = structuredClone(fixture.walls); // config coords: НЕ умножать
const openCuts = resolveOpenCuts(
rooms, fixture.open_spans, NORM_W, GRID_PITCH * 0.02,
);
const intervals = wallIntervals(
rooms, walls, openCuts, GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W,
);
const geometry = wallBodiesGeometry(
rooms, walls, openCuts, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH,
NORM_W, [],
);
console.log({
counts: [rooms.length, walls.length, openCuts.length], // [8, 25, 3]
nodeCm: intervals
.filter((iv) => Math.abs(iv.a[1] - 550) < 1e-6
&& Math.abs(iv.b[1] - 550) < 1e-6)
.map((iv) => iv.cm), // содержит 20
failed: geometry === null, // true на 19e92e0
});
Для прямого контроля patch-list автор исполнил ту же compiled module с временно
экспортированной без изменения тела virtualJunctionPatches(); результат — один
patch:
[[[620.8333333333334,550],[612.5,550],
[612.5000000000001,541.6666666666665],
[620.8333333333334,541.6666666666666]]]
В реализации test seam должен сделать этот pre-union результат проверяемым без source rewriting; точная публичность helper не является продуктовым контрактом.
Причинная цепочка:
virtualJunctionPatches()создаёт один прямоугольный patch около[620.8333…, 550].- Две математически совпадающие координаты приходят разными IEEE-754 числами:
612.5и612.5000000000001; аналогичный шум есть по Y. polyclip-tsне может завершить output ring приunion(body, patch)и выбрасывает исключение.- Room rings и atomic wall-edge bodies имеют per-piece
try/catch, а цикл junction patches вsrc/wall-thickness.ts:1760–1761— нет. - Исключение достигает общего
catch,wallBodiesGeometry()возвращаетnull, и все потребители закономерно включают общий fail-dark.
Дополнительные исполняемые проверки отделяют причину от корреляций:
- удаление 15-см микро-интервала оставляет результат
null; - удаление open cuts, которое отключает virtual-junction pass, даёт валидную geometry;
- округление только координат patch с технической точностью от
10⁻⁹до10⁻¹²render unit даёт валидную geometry; итоговая площадь после canonical clipping совпадает с вариантом, где единственный отказавший patch локально пропущен.
Проверка замечания r1 о толщине также выполнена на корректном production input:
wallIntervals() и intervalCmAt() возвращают 20 см на обоих atomic children
горизонтальной стены узла. thicknessCmAt() — более узкий direct-key helper —
не наследует parent entry для child key и возвращает 0, однако
virtualJunctionPatches() его не вызывает: она получает уже разрешённые
wallIntervals(). Поэтому Medium #201 не является причиной нулевого patch-list
в корректном воспроизведении #197.
Это не дефект #150: atomic thickness profile и exterior transition исправны. Короткий вне-сеточный интервал — отдельная находка #198. Общая проверка результата Optimize перед записью — отдельный защитный барьер #199.
4. Зафиксированное продуктовое решение
Открытых продуктовых вопросов нет. Действующий контракт уже задан
docs/WALL-THICKNESS.md и ТЗ #141:
- локальный independent/virtual junction не может удалить валидную кладку остальных комнат;
- канонический результат един для рисунка, пола и световых препятствий;
- при невозможности обработать дополнительный patch допустима локальная консервативная деградация без записи config;
- основной structural boolean failure по-прежнему отличим от успешной пустой geometry и остаётся fail-dark; #197 не превращает любой сбой в оптимистичный raw-ring fallback.
5. Скоуп
В задачу входят:
- численная стабилизация координат, вычисленных для virtual junction patches, перед передачей в boolean engine;
- изоляция
unionкаждого patch: один отказ не откатывает ранее построенные room rings, edge bodies и успешные patches; - сохранение валидной основной geometry,
paperGeom,depthUnitsиopeningIndex, когда отказал только дополнительный patch; - одинаковый исправленный structural result для Plan, View/киоска,
houseplan-space-card, hidden Iso/floor footprint, clean floor/area, Glow, source guard и солнца; - неизменность opening cuts, exterior shell, независимых partitions/columns и порядка, в котором они входят в canonical body;
- существующие сохранённые планы без миграции и фоновой записи;
- unit regression на полном fixture, матрица численных вариантов, targeted production-bundle browser smoke, visual regression и release-документация.
6. Не входит в задачу
- удаление, слияние или изменение минимальной длины wall interval — #198;
- geometry self-check в Optimize preview/apply/undo — #199;
- изменение алгоритма
normalizeWallIntervals(),degradeWalls()илиalignAllToGrid(); - общая замена
polyclip-tsили настройка его глобальной точности; - новый persisted junction/node, schema version или migration;
- привязка offset/mitre-вершин к пользовательской сетке;
- изменение толщины,
MITRE_LIMIT, типов стыка или правил #141; - исправление произвольного основного exterior/ring/opening boolean failure;
- новые controls, предупреждения, диагностический toast или настройка fallback;
- публикация скрытой изометрии.
7. Контракт поведения
7.1. Численная стабилизация patch
- Координаты patch должны быть конечными; patch с не-конечным значением либо площадью не больше действующего geometry tolerance не передаётся в union.
- Математически совпадающие результаты операций с общей вершиной и offsets приводятся к одной детерминированной координате с технической точностью, многократно меньшей общего geometry epsilon.
- Стабилизация не использует шаг пользовательской сетки: half-depth 15–33 см и диагональные mitre вправе находиться между grid nodes.
- Сдвиг любой вершины от исходного конечного значения не превышает выбранный numeric tolerance и не меняет видимую толщину, bounded-mitre envelope или association с исходным узлом.
- Результат не зависит от представления
xпротивx ± ulp, направления segment и порядка эквивалентных wall records.
7.2. Изоляция локального отказа
- Каждый junction patch объединяется независимо, как room-ring и edge-body pieces в соседних canonical loops.
- Перед попыткой сохраняется последняя успешная
body. Если union patch выбрасывает исключение, этаbodyостаётся результатом следующего шага. - Ошибка одного patch не пропускает последующие patches, exterior clipping, shell union, opening cuts и independent extra-body union.
- Если до patches существует валидная непустая structural body, функция не
возвращает
nullтолько из-за patch.paperGeomтакже не теряется. - Если основная exterior/ring/opening geometry не может быть построена либо
финальный обязательный pass падает, сохраняется действующий общий
nullи fail-dark; raw per-room rings не воскрешаются. - Fallback ничего не записывает, не меняет входные массивы и не создаёт различающуюся физику между render consumers.
7.3. Канонические потребители
Один возвращённый wallBodiesGeometry()/structural cache определяет:
- masonry path полного Plan/View и киоска;
- masonry path static card;
- hidden Iso footprint и wall faces;
- clean-floor subtraction, room fill и displayed area;
- paper footprint;
- Glow barriers, source-inside-body guard и spill;
- солнечные препятствия.
Ни один consumer не получает отдельный SVG-only обход либо собственную нормализацию patch. Presentation-различия вроде hatch suppression при малой экранной толщине остаются допустимыми; физическое множество совпадает.
7.4. Совместимость существующей геометрии
- Исправный virtual T продолжает получать прежний bounded patch.
- Room L/T/nested joins, corner Split #123, thickness transition #150 и zero-divider #172 не меняют внешний outline.
- Openings по-прежнему режут room masonry после junction pass; совпавший independent body не режется room opening.
- Partitions, drafts и columns не увеличивают Stage floor footprint.
- Перестановка комнат/стен может менять внутренний порядок boolean operations, но не геометрическое множество и не способность функции завершиться.
8. Архитектурный контракт реализации
- Исправление живёт в общей geometry-логике
src/wall-thickness.ts, рядом с построением/union virtual junction patches, а не в отдельном renderer. - Numeric normalization является чистым локальным helper либо эквивалентной операцией и применяется только к вычисляемым patch vertices.
- Tolerance масштабируется согласованно с
coordScale, чтобы normalized и render-space вызовы описывали одну физическую форму; жёсткое округление до сантиметра, grid step или фиксированного числа видимых знаков запрещено. - Per-patch fallback сохраняет
bodyтранзакционно: присваивание происходит только после успешногоunion. - Structural cache key не меняется: стабилизация является детерминированной функцией уже входящих в fingerprint координат, cuts и толщин.
- Логирование, если добавляется, не содержит config/названий комнат и не спамит каждый HA state tick. Новый публичный diagnostic API не требуется.
Предполагаемые файлы:
src/wall-thickness.ts;test/wall-thickness.test.mjs;demo/smoke_junction_patch_resilience.mjsлибо узкое расширение существующего wall-thickness smoke с однозначной связью с #197;- visual fixture в golden matrix, если текущая wall-junction сцена не доказывает сохранение полного много-комнатного плана;
docs/WALL-THICKNESS.md,docs/ARCHITECTURE.md,docs/USER-GUIDE.ru.md,docs/TESTING.md,docs/STATUS.md;docs/CHANGELOG.md,docs/CHANGELOG.ru.md;- три синхронные поставляемые копии bundle.
9. Модель данных, compatibility и миграция
Форматы RoomCfg, WallEntry, open_spans, openings, partitions и columns не
меняются. Новых ключей, aliases и version markers нет.
- старые и уже оптимизированные планы исправляются вычисляемо при чтении;
- render, preview и cache warm-up не выполняют config/layout/storage write;
- Optimize не запускается автоматически и его отчёт не меняется;
- import/export и backend validation не меняются;
- откат возвращает прежнее поведение без преобразования данных;
- future/unknown fields не затрагиваются.
10. UX, i18n, accessibility и touch
Новых controls, текстов, focus/keyboard semantics, ARIA, animation и locale keys нет. RU/EN i18n не меняется.
Plan editor остаётся desktop-first. Touch editor — best effort / intentionally degraded, но один сохранённый plan не может получить другую masonry geometry из-за pointer type. View, kiosk и static card полностью поддерживаются и обязаны оставаться читаемыми.
Светлая/тёмная тема, forced colours, prefers-reduced-motion, hatch color и wall
opacity не меняют fallback. Цвет не используется как единственное доказательство:
unit проверяет геометрическое множество, browser smoke — реально отрисованный
path на двух темах.
11. Критерии приёмки
- AC1 (
unit): полный анонимизированный fixture из issue готовится ровно по reproducer §3: room/cut coordinates масштабированы, persistedwalls[].a/bостаются нормализованными; получаются 8 rooms, 25 walls, 3 cuts и 20-см horizontal atomic intervals. На исходномdevэтот вызов даётnull, после исправления — ненулевой объект с непустымиgeomиpaperGeom. - AC2 (
unit): test seam подтверждает ровно один pre-union patch с четырьмя вершинами из §3; вариантыx,x ± ulpи стабилизированный эквивалент дают одно geometric set в пределах numeric tolerance и не меняют bounded envelope. - AC3 (
unit): принудительный throw на первом из нескольких patch unions сохраняет последнюю успешную body, позволяет обработать следующий patch и не меняетpaperGeom; основной обязательный boolean throw по-прежнему даётnull. - AC4 (
unit): удаление и слияние микро-интервала не используются как лечение: regression проходит с исходными 25 wall records; входныеrooms,walls,openCuts,openingsиextraBodiesпосле вызова побайтно эквивалентны исходным. - AC5 (
unit): перестановка room/wall records, обратное направление эквивалентных segments и повторный вызов дают одно множество без исключения; geometry сравнивается symmetric difference/area, а не строковым порядком rings. - AC6 (
unit): существующие матрицы #123/#141/#150/#172, nested/courtyard, openings, open spans и independent extras остаются зелёными; валидные обычные junction fixtures геометрически не меняются за пределами numeric tolerance. - AC7 (
smoke): targeted production-bundle smoke загружает полный fixture и подтверждает непустой masonry path в Plan, View, kiosk/static и hidden Iso, одинаковый structural fingerprint и отсутствие write после render. - AC8 (
smoke): тот же smoke проверяет clean floor/paper и Glow/source/sun consumers: кладка не исчезает при переключении HA state, светлой/тёмной темы и режима отображения; topology не пересчитывается от обычного state tick. - AC9 (
golden): детерминированная сцена полного плана показывает кладку и T-стык в Plan и View минимум в dark theme. Baseline принимается только из просмотренного полного Linux artifact черезgolden:accept -- --reviewed. - AC10 (
ревью кода): отсутствуют renderer-specific fallback, grid snapping физических offsets, schema/backend/i18n изменения и исправления #198/#199; локальный catch не скрывает основной structural failure. - AC11 (
typecheck+unit+build+smoke): локальный gate зелёный, целевой smoke выполнен до S7, три bundle-копии после build побайтно одинаковы.
12. План автотестов и гейтов
Unit
В test/wall-thickness.test.mjs либо отдельном узком pure-geometry файле:
- добавить полный issue fixture без ручного сокращения и вызвать production
signature
wallBodiesGeometry(); - проверить patch count/finite vertices/bounds и numeric-equivalent variants;
- внедрить контролируемый отказ одного patch union через узкий pure helper либо другую тестируемую границу, не подменяя production algorithm;
- проверить продолжение после отказа и отдельно общий structural fail-dark;
- проверить input immutability, repeatability и permutation invariance;
- прогнать существующие wall junction/thickness/opening/floor/light regressions.
Минимум один новый тест обязан уметь падать: возврат старого незащищённого цикла
должен снова дать null на полном fixture. Ревьюер проверяет эту мутацию или
эквивалентное доказательство.
Targeted browser smoke
node demo/smoke_junction_patch_resilience.mjs либо эквивалентный явно названный
scenario:
- загружает поставляемый production bundle и fixture issue;
- снимает signatures masonry/paper/floor в Plan, View, static и hidden Iso;
- проверяет dark/light theme и HA light-state update без исчезновения paths;
- проверяет Glow/source guard и доступный sun-occluder contract;
- подтверждает отсутствие config/layout write.
Этот targeted smoke выполняется локально перед S7-code-review. Полный набор
smoke не запускается в implementation loop.
Golden и pre-release
Golden-сцена может расширить существующий wall-junction scenario, если полный
fixture и глобальное сохранение кладки читаются однозначно. Локальное принятие
baseline ради зелёной ветки запрещено. Full smoke, golden capture/verify,
performance и Linux Validate выполняются перед бетой на точном SHA по
PROCESS.md.
Обязательный implementation loop
npm run typecheck
npm test
npm run build
сравнение трёх bundle-копий
node demo/smoke_junction_patch_resilience.mjs
Python/HA harness не нужен для локального gate: backend не меняется; полный Linux harness остаётся release gate.
13. Производительность и безопасность
Numeric normalization и один локальный try/catch не меняют асимптотику.
Количество patches, boolean passes и structural cache invalidation остаются
прежними. HA states, theme, hover и animation tick не входят в geometry key.
Отдельный budget не добавляется. Перед бетой существующие large-house и Full Performance должны пройти без ослабления порогов; точный fixture #197 добавляется в performance только если измерение покажет отдельный значимый профиль.
Security/privacy влияние отсутствует: нет HTML/CSS ввода, сетевых запросов, HA services, новых permissions или пользовательских данных. Полный fixture уже анонимизирован; diagnostic output не должен печатать исходный config.
14. Риски и снижение
| Риск | Вероятность / ущерб | Снижение |
|---|---|---|
| Слишком грубая нормализация изменит толщину или mitre | средняя / высокий | tolerance значительно меньше geometry epsilon; exact bounds и symmetric-difference tests |
| Grid rounding сломает диагонали и half-depth | средняя / высокий | явный запрет grid snapping; matrix разных coordScale и толщин |
| Catch скроет основной отказ и вернёт опасную geometry | средняя / высокий | catch только вокруг одного optional patch; отдельный core-failure test |
| Plan исправится, а light/Iso продолжат использовать другой body | низкая / высокий | один canonical result и consumer smoke AC7/AC8 |
| Перестановка records снова вызовет polyclip failure | средняя / высокий | permutation + ulp matrix на полном fixture |
| Golden примет массовый anti-aliasing diff | низкая / средний | узкая сцена, просмотр полного Linux artifact, без локального auto-accept |
| Попутно изменится optimizer | низкая / высокий | явный non-scope и отдельные #198/#199 |
15. Откат
Откат — revert одного user-visible implementation commit вместе с тестами, документацией, changelog и bundle-копиями. Persisted schema и данные не меняются, поэтому migration/cleanup не нужны. После отката возвращается риск полного исчезновения кладки на исходном fixture.
Feature flag не добавляется: это восстановление обязательного fail-closed контракта общей физической геометрии, а не экспериментальная функция.
16. Release-артефакты
Изменение пользовательское. Implementation-коммит имеет User-Visible: yes и
в том же коммите обновляет:
docs/CHANGELOG.mdиdocs/CHANGELOG.ru.mdсо ссылкой на #197;docs/WALL-THICKNESS.md— per-piece junction failure и numeric normalization;docs/ARCHITECTURE.md— граница optional patch против core structural failure;docs/USER-GUIDE.ru.md— диагностика исчезнувшей кладки после Optimize;docs/TESTING.md— unit/smoke/golden coverage #197;docs/STATUS.md— фактическая реализованная release-линия;- unit fixture, targeted smoke и golden scenario/candidate;
dist/houseplan-card.js,custom_components/houseplan/frontend/houseplan-card.js,demo/srv/assets/houseplan-card.jsпосле build.
Screenshots вне golden не требуются. Backend, migration, i18n, отдельный security report и отдельный performance budget не требуются. Issue закрывается только после включения в опубликованную бету.
17. Принятые технические предположения — можно менять без продуктового ревью
- Рекомендуемый numeric step — величина порядка
coordScale × 10⁻¹²либо эквивалентная relative/ULP-нормализация, доказанная AC2; точное имя helper и коэффициент не являются продуктовым контрактом. - Нормализуются только junction patch vertices непосредственно перед boolean boundary; persisted rooms/walls/cuts и основные room profiles не округляются.
- Patch с не-конечными координатами или ничтожной площадью считается локально непригодным и проходит тот же isolated fallback.
- Если нормализованный patch всё равно вызывает исключение, он пропускается; последующие patches и обязательные passes продолжаются.
- Для текущего полного fixture пропуск и успешный стабилизированный union после final exterior clipping дают одинаковую площадь; тест всё равно проверяет успешную нормализацию, чтобы catch не был единственным лечением.
- Инъекция отказа в unit может быть реализована через небольшой pure helper, test-only boolean adapter или эквивалент без публичного runtime API.
- Имена smoke/golden scenarios и раскладка test fixture свободны, если связь с AC и возможность красного прогона сохраняются.
- Нет открытых продуктовых вопросов; смежные решения вынесены в #198 и #199.