Files
houseplan-card/docs/specs/197-junction-patch-fail-dark.md
T
2026-08-19 13:02:12 +03:00

35 KiB
Raw Blame History

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 не возражением на словах, а:

  1. фиксирует координатный контракт явно;
  2. добавляет ниже полный исполняемый reproducer, который сам извлекает fixture из issue и не допускает ручного преобразования walls;
  3. уточняет AC1/AC2 так, чтобы unit сначала доказывал правильную подготовку fixture и наличие patch, а затем красный null исходного кода;
  4. отделяет #201: прямой exact-key helper thicknessCmAt() действительно даёт 0 на новом atomic child, но canonical wallIntervals()/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 не является продуктовым контрактом.

Причинная цепочка:

  1. virtualJunctionPatches() создаёт один прямоугольный patch около [620.8333…, 550].
  2. Две математически совпадающие координаты приходят разными IEEE-754 числами: 612.5 и 612.5000000000001; аналогичный шум есть по Y.
  3. polyclip-ts не может завершить output ring при union(body, patch) и выбрасывает исключение.
  4. Room rings и atomic wall-edge bodies имеют per-piece try/catch, а цикл junction patches в src/wall-thickness.ts:1760–1761 — нет.
  5. Исключение достигает общего 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:

  1. локальный independent/virtual junction не может удалить валидную кладку остальных комнат;
  2. канонический результат един для рисунка, пола и световых препятствий;
  3. при невозможности обработать дополнительный patch допустима локальная консервативная деградация без записи config;
  4. основной structural boolean failure по-прежнему отличим от успешной пустой geometry и остаётся fail-dark; #197 не превращает любой сбой в оптимистичный raw-ring fallback.

5. Скоуп

В задачу входят:

  1. численная стабилизация координат, вычисленных для virtual junction patches, перед передачей в boolean engine;
  2. изоляция union каждого patch: один отказ не откатывает ранее построенные room rings, edge bodies и успешные patches;
  3. сохранение валидной основной geometry, paperGeom, depthUnits и openingIndex, когда отказал только дополнительный patch;
  4. одинаковый исправленный structural result для Plan, View/киоска, houseplan-space-card, hidden Iso/floor footprint, clean floor/area, Glow, source guard и солнца;
  5. неизменность opening cuts, exterior shell, независимых partitions/columns и порядка, в котором они входят в canonical body;
  6. существующие сохранённые планы без миграции и фоновой записи;
  7. 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

  1. Координаты patch должны быть конечными; patch с не-конечным значением либо площадью не больше действующего geometry tolerance не передаётся в union.
  2. Математически совпадающие результаты операций с общей вершиной и offsets приводятся к одной детерминированной координате с технической точностью, многократно меньшей общего geometry epsilon.
  3. Стабилизация не использует шаг пользовательской сетки: half-depth 15–33 см и диагональные mitre вправе находиться между grid nodes.
  4. Сдвиг любой вершины от исходного конечного значения не превышает выбранный numeric tolerance и не меняет видимую толщину, bounded-mitre envelope или association с исходным узлом.
  5. Результат не зависит от представления x против x ± ulp, направления segment и порядка эквивалентных wall records.

7.2. Изоляция локального отказа

  1. Каждый junction patch объединяется независимо, как room-ring и edge-body pieces в соседних canonical loops.
  2. Перед попыткой сохраняется последняя успешная body. Если union patch выбрасывает исключение, эта body остаётся результатом следующего шага.
  3. Ошибка одного patch не пропускает последующие patches, exterior clipping, shell union, opening cuts и independent extra-body union.
  4. Если до patches существует валидная непустая structural body, функция не возвращает null только из-за patch. paperGeom также не теряется.
  5. Если основная exterior/ring/opening geometry не может быть построена либо финальный обязательный pass падает, сохраняется действующий общий null и fail-dark; raw per-room rings не воскрешаются.
  6. 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. Архитектурный контракт реализации

  1. Исправление живёт в общей geometry-логике src/wall-thickness.ts, рядом с построением/union virtual junction patches, а не в отдельном renderer.
  2. Numeric normalization является чистым локальным helper либо эквивалентной операцией и применяется только к вычисляемым patch vertices.
  3. Tolerance масштабируется согласованно с coordScale, чтобы normalized и render-space вызовы описывали одну физическую форму; жёсткое округление до сантиметра, grid step или фиксированного числа видимых знаков запрещено.
  4. Per-patch fallback сохраняет body транзакционно: присваивание происходит только после успешного union.
  5. Structural cache key не меняется: стабилизация является детерминированной функцией уже входящих в fingerprint координат, cuts и толщин.
  6. Логирование, если добавляется, не содержит 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 масштабированы, persisted walls[].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 файле:

  1. добавить полный issue fixture без ручного сокращения и вызвать production signature wallBodiesGeometry();
  2. проверить patch count/finite vertices/bounds и numeric-equivalent variants;
  3. внедрить контролируемый отказ одного patch union через узкий pure helper либо другую тестируемую границу, не подменяя production algorithm;
  4. проверить продолжение после отказа и отдельно общий structural fail-dark;
  5. проверить input immutability, repeatability и permutation invariance;
  6. прогнать существующие wall junction/thickness/opening/floor/light regressions.

Минимум один новый тест обязан уметь падать: возврат старого незащищённого цикла должен снова дать null на полном fixture. Ревьюер проверяет эту мутацию или эквивалентное доказательство.

Targeted browser smoke

node demo/smoke_junction_patch_resilience.mjs либо эквивалентный явно названный scenario:

  1. загружает поставляемый production bundle и fixture issue;
  2. снимает signatures masonry/paper/floor в Plan, View, static и hidden Iso;
  3. проверяет dark/light theme и HA light-state update без исчезновения paths;
  4. проверяет Glow/source guard и доступный sun-occluder contract;
  5. подтверждает отсутствие 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. Принятые технические предположения — можно менять без продуктового ревью

  1. Рекомендуемый numeric step — величина порядка coordScale × 10⁻¹² либо эквивалентная relative/ULP-нормализация, доказанная AC2; точное имя helper и коэффициент не являются продуктовым контрактом.
  2. Нормализуются только junction patch vertices непосредственно перед boolean boundary; persisted rooms/walls/cuts и основные room profiles не округляются.
  3. Patch с не-конечными координатами или ничтожной площадью считается локально непригодным и проходит тот же isolated fallback.
  4. Если нормализованный patch всё равно вызывает исключение, он пропускается; последующие patches и обязательные passes продолжаются.
  5. Для текущего полного fixture пропуск и успешный стабилизированный union после final exterior clipping дают одинаковую площадь; тест всё равно проверяет успешную нормализацию, чтобы catch не был единственным лечением.
  6. Инъекция отказа в unit может быть реализована через небольшой pure helper, test-only boolean adapter или эквивалент без публичного runtime API.
  7. Имена smoke/golden scenarios и раскладка test fixture свободны, если связь с AC и возможность красного прогона сохраняются.
  8. Нет открытых продуктовых вопросов; смежные решения вынесены в #198 и #199.