diff --git a/docs/specs/271-finite-multiwall-rays.md b/docs/specs/271-finite-multiwall-rays.md new file mode 100644 index 00000000..8554630c --- /dev/null +++ b/docs/specs/271-finite-multiwall-rays.md @@ -0,0 +1,324 @@ +# Issue #271 — конечная длина лучей multi-wall узла + +- Дата: 2026-08-23 +- Тип: bug · приоритет P1 +- Оценка: пользовательская ценность 10/10 · ценность для разработки 9/10 · + сложность 7/10 · риск 9/10 +- Issue: [#271](https://github.com/Matysh/houseplan-card/issues/271) +- Ветка: `issue/271-finite-multiwall-rays` +- Статус ТЗ: готово к ревью + +Канонические документы: `docs/SCOPE.md`, `docs/ARCHITECTURE.md`, +`docs/WALL-THICKNESS.md`, `docs/CONFIG-COMPATIBILITY.md`, +`docs/USER-GUIDE.ru.md` и `docs/TESTING.md`. + +Связанные, но не дублирующие задачи: +[#249](https://github.com/Matysh/houseplan-card/issues/249), +[#261](https://github.com/Matysh/houseplan-card/issues/261), +[#270](https://github.com/Matysh/houseplan-card/issues/270) и +[#273](https://github.com/Matysh/houseplan-card/issues/273). + +## 1. Сценарий и персона + +Администратор нажимает Optimize для реального плана и затем открывает Plan или +View. У короткой ступени контура появляется длинный штрихованный торец без +соответствующей осевой линии и конечного узла. В другом месте ложная кладка +становится окклюдером и даёт тёмное пятно возле корректно вырезанной двери. + +Семья видит архитектуру, которой нет в сохранённом плане. Это нарушает J1: +план обязан правдиво показывать дом. Одновременно нарушен J6: одна каноническая +геометрия должна обслуживать masonry, paper, floor, Iso и световые барьеры. + +## 2. Подтверждённое воспроизведение + +Два приватных plan-only экспорта владельца из `v1.67.0-beta.5` после Optimize +прогнаны через production-цепочку `spaceModels()` → +`prepareSpacePhysicalGeometryInputs()` → `wallIntervals()` → +`buildMultiWallNodeMap()` → `wallBodiesGeometry()`. + +| Экспорт / node, render units | Реальная длина ray | Текущий rebuild | +|---|---:|---:| +| 1 / `(420.833, 437.500)` | `12.500` | `110.000` | +| 1 / `(887.500, 345.833)` | `1.381904` | `96.667` | +| 2 / `(2404.167, 1245.833)` | `20.833` | `500.000` | +| 2 / `(2404.167, 2308.333)` | `20.833` | `500.000` | +| 2 / `(-354.167, 2087.500)` | `200.000` | `666.667` | +| 2 / `(-354.167, 2287.500)` | `200.000` | `333.333` | + +Во втором экспорте все 14 opening slots проверены по центру и по 41 точке вдоль +оси. Final masonry в них отсутствует. Поэтому пятно в `2-1.png` не требует +отдельного opening-cut бага: его источник — лишняя geometry/occlusion около +проёма. + +Первый экспорт также содержит две явные `wall_columns`. Они являются +сохранёнными пользовательскими телами и не удаляются этой задачей. Regression +фиксирует только площадь, которую multi-wall reconstruction добавляет дальше +конца room-wall interval. + +Приватные экспорты не входят в репозиторий. Для тестов из них выделяются +минимальные обезличенные room/wall/opening fixtures с теми же длинами и +отношениями толщин. + +## 3. Подтверждённая причина + +`buildMultiWallNodeMap()` получает конечные `WallInterval.a/b`, но после +дедупликации сохраняет в `MultiWallNodeRay` только: + +```ts +{ u, halfDepth } +``` + +Фактическая длина `hypot(b - a)` теряется. `bevelMultiWallBody()` затем +реконструирует каждый ray прямоугольником с общей длиной: + +```ts +radius = MITRE_LIMIT * node.halfDepth; +extent = radius * 2; +``` + +При `MITRE_LIMIT = 4` это `8 × H`, где `H` — максимальная half-depth всего +узла, а не длина конкретного ray. Mask ограничивает rebuild только квадратом +вокруг node, но не реальным endpoint. Поэтому короткий ray становится длиннее, +хотя `wallIntervals`, осевой overlay и сохранённый room polygon остаются +короткими. Final masonry затем закономерно используется как общий occluder. + +## 4. Что человек увидит до и после + +**До:** короткая ступень или micro-segment может выглядеть как длинная стена; +у неё нет оси и конечного snap-node, а её тень может лечь в дверной проём. + +**После:** masonry, hatch, outline, paper и тени заканчиваются там же, где +заканчивается реальный входящий wall interval. Сам T/X-стык остаётся замкнутым, +а bounded bevel #249 не возвращает длинный mitre-spike. + +Исправление работает при чтении плана; Save и повторный Optimize не требуются. +Новых настроек и сообщений нет. + +## 5. Scope + +### Входит + +- сохранение конечной длины каждого canonical ray в multi-wall node map; +- детерминированная дедупликация сонаправленных физических intervals; +- ограничение local body/paper reconstruction фактическими конечными rays; +- согласование room masonry, final masonry, paper, clean floor, Static/Iso и + light/sun barriers; +- короткие rays рядом с opening, но без изменения opening association/cut; +- pure unit, minimized real-topology fixture, mutation, targeted smoke и + semantic golden evidence; +- документация и оба changelog. + +### Не входит + +- удаление или изменение явных `wall_columns`, partitions и room drafts; +- optimizer-cleanup micro-thickness island #273; +- изменение `MITRE_LIMIT = 4`, `MULTI_WALL_JOIN_LIMIT = 1.25` или формы + чрезмерного bevel #249; +- общий фикс белых запертых полостей #272; +- изменение room coordinates, snapping, wall keys, opening symbols/placement; +- schema/model-version/backend/i18n/UI изменения. + +## 6. Канонический контракт finite ray + +### 6.1 Длина + +Каждый положительный конечный `WallInterval` создаёт directed endpoint ray с +`u`, `halfDepth` и `length > epsilon`. `length` измеряется в тех же render units, +что `point`, до любого local rebuild. Node map остаётся immutable projection и +не мутирует intervals. + +Локальная полоса ray существует только на параметре `t ∈ [0, length]` плюс +scale-relative boolean epsilon на границе. Ни mask, ни join radius не дают +права продолжить её за `length`. + +### 6.2 Co-directional duplicates + +Shared interval может прийти от двух room owners. Полные физические дубликаты +одной оси/endpoint схлопываются как сейчас. Если в одном направлении есть +несколько коллинеарных intervals: + +- `halfDepth` берётся как максимальная эффективная положительная толщина; +- finite support является union реально существующих `[0, length]`, а не + автоматически бесконечным ray; +- для непрерывных co-directional supports от одного endpoint достаточно + максимального `length`; +- зазор нельзя перекрыть только потому, что более короткий/толстый duplicate + присутствует рядом; +- сортировка, room order, interval direction и duplicate count не меняют + результат. + +Точная структура (`length` либо bounded spans) остаётся техническим решением, +если этот физический контракт доказан тестами. + +### 6.3 Local reconstruction + +`bevelMultiWallBody()` может перестраивать только intersection finite ray +strips с действующей node mask и bounded paper/exterior envelope. Pairwise +join material у математического node сохраняется, но никакой rectangle не +пересекает плоскость конечного торца ray наружу. + +Если другой независимый ray/body законно занимает точку за этим торцом, final +union остаётся заполненным им. Тест finite ray сравнивает provenance/local +piece либо fixture без другого владельца, а не требует пустоты там, где есть +реальная пересекающаяся стена. + +### 6.4 Paper, floor и physics + +`roomGeom` и `paperGeom` не получают фасад/торец за finite endpoint. Clean floor +не теряет площадь из-за выдуманной masonry. Final `geom`, hidden Iso и +light/sun occluders используют тот же bounded result. Запрещён render-only или +shadow-only workaround. + +### 6.5 Failure isolation + +Невалидная/nonfinite length исключает только кандидат ray/node и не гасит +остальной план. Обязательные structural boolean failures продолжают fail-dark +по действующему контракту #197/#199. + +## 7. Compatibility, UX, security и performance + +- Persisted config/layout/model version не меняются; вход не мутируется. +- Legacy key-only walls и canonical endpoint walls используют одну длину из + resolved `WallInterval`. +- Optimize не нужен для runtime-исправления и не удаляет independent bodies. +- Новых controls, keyboard/touch/focus/ARIA и locale keys нет. +- Новых HA calls, permissions, URL/HTML и security surfaces нет. +- Node map строится в существующем structural pass. State/theme/hover ticks не + пересчитывают topology. Запрещён новый глобальный `O(E²)` проход; bounded + supports вычисляются во время уже существующей endpoint aggregation. + +## 8. Acceptance criteria и доказательства + +### AC1. Node map не теряет конечную длину + +Table-driven unit строит nodes с rays длиной `20.833`, `200` и длиннее `8H`. +Публичный результат хранит finite support; duplicate owners, reversed +intervals и permutations дают deep-equal map. Input arrays неизменны. + +**Доказательство:** `test/wall-thickness.test.mjs`. + +### AC2. Короткий ray не достраивается + +Minimized node `(2404.167, 1245.833)` с vertical ray `20.833` и `H = 62.5` +не содержит local/final masonry в устойчивом probe за endpoint, хотя старый +`extent = 500` его заполняет. Внутри `[0, 20.833]` полоса и node core остаются. +То же проверяется для `12.5 → не 110` при `cell_cm: 5`. + +**Доказательство:** geometry unit с point/area coverage, не SVG string. + +### AC3. Реальные длинные rays и join #249 не обрезаны + +Ray длиннее local mask сохраняет material до границы mask; T/X node связан со +всеми incident rays. Existing excessive-wedge probe #249 остаётся пустым, +join-radius не увеличивается, zero/open ray не материализуется. + +**Доказательство:** существующие #249/#261 units плюс finite-length matrix. + +### AC4. Проём остаётся настоящим проёмом без ложной тени + +Fixture с коротким perpendicular ray рядом с door подтверждает: + +- весь opening slot пуст в final masonry; +- finite ray не входит в slot/local probe, если его endpoint до slot; +- light/sun occluder не содержит добавленной за endpoint площади; +- opening tunnel/symbol contract не меняется. + +**Доказательство:** geometry unit и targeted browser smoke с реальным SVG/ +shadow probe. + +### AC5. Все поверхности используют finite result + +Plan, View, kiosk, Static и hidden Iso не показывают stump за endpoint; +room paper/fill/hover и clean floor не вырезают там ложную стену; Glow/sun +barriers не создают пятно. Theme/HA state update сохраняют structural +fingerprint и cache reuse. + +**Доказательство:** targeted production-bundle smoke. + +### AC6. Семантический visual gate + +Golden scene содержит короткую осевую ступень и local crop. До pixel diff +harness проверяет, что два probes за finite endpoint пусты в wall/paper и что +probe внутри ray заполнен. Общая доля изменённых пикселей не является +единственным gate. Linux artifact просматривается до baseline acceptance. + +### AC7. Мутант ловит исходную регрессию + +Mutation entry отбрасывает finite support или возвращает rectangle длиной +`8H`. AC2 либо AC4 обязаны краснеть. Мутант выполняется, а не только +регистрируется. + +### AC8. Приватность, данные и детерминизм + +Полные `1.json`/`2.json` и пользовательские названия не коммитятся. Fixture +содержит только минимальную анонимную topology. Render не меняет config; +повторный расчёт и повторный Optimize детерминированы. + +### AC9. Локальные гейты реализации + +- `npm run typecheck`; +- `npm test`; +- `npm run build` и byte-identical shipped bundles; +- `node scripts/check-docs.mjs`; +- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` и все выбранные + targeted smokes; +- целевой mutation; +- целевые semantic golden scenes. + +Полные golden/smoke/performance и Linux HA harness остаются prerelease gates. + +## 9. Ожидаемые файлы + +Product code: + +- `src/wall-thickness.ts`. + +Доказательства: + +- `test/wall-thickness.test.mjs` и обезличенная fixture при необходимости; +- targeted smoke для finite multi-wall ray/occluder; +- `demo/golden/matrix.mjs`, `demo/golden/harness.mjs` и matrix tests; +- `scripts/mutation-gate.mjs`, smoke/mutation registries. + +Документация: + +- `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, `docs/TESTING.md`; +- `docs/USER-GUIDE.md` и `docs/USER-GUIDE.ru.md`; +- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`. + +## 10. Release-артефакты + +Implementation-коммит класса A имеет trailers `Issue: #271` и +`User-Visible: yes`; оба changelog входят в тот же коммит. Изменившиеся +golden/docs screenshots принимаются только из полного Linux CI artifact после +визуального review штатными `*:accept -- --reviewed` командами. Перед beta +обязательны полный golden, smoke, performance и exact-SHA Validate. + +## 11. Риски и меры + +| Риск | Мера | +|---|---| +| Короткая толщина одного duplicate обрежет длинную реальную стену | §6.2 и duplicate/permutation matrix. | +| Вернётся mitre-spike #249 | Existing radius/excessive-wedge negative tests. | +| Починится Plan, но останется тень | AC4/AC5 проверяют общий occluder. | +| Внешний probe законно занят другой стеной | Minimized provenance-aware fixture и отдельные local/final assertions. | +| Boolean epsilon создаст щель у endpoint | Probes с запасом и area-connected node assertions. | + +## 12. Rollback + +Откатывается finite-ray projection/reconstruction вместе с semantic tests, +smoke/golden и документацией. Данных и миграций нет. Partial rollback только +тестов запрещён: он снова сделает регрессию невидимой. + +## 13. Принятые технические предположения + +1. Внутреннее представление finite support (`length`, interval либо clipped + polygon) выбирает автор реализации; продуктовый контракт задан §6. +2. Тёмное пятно `2-1.png` не получает отдельную задачу: opening slot доказанно + пуст, а лишняя geometry уже является общим light barrier. +3. Явные `wall_columns` первого экспорта остаются данными плана. Их автоматическое + удаление было бы отдельным продуктовым решением и не маскирует этот bug. +4. Minimized fixtures достаточно для репозитория; приватные экспорты остаются + локальным cross-check перед handoff. +5. Новых продуктовых вопросов нет: конечное физическое тело обязано соблюдать + конечный источник.