docs(spec): define finite multi-wall ray contract

Issue: #271
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-23 21:06:00 +03:00
parent d68e876f78
commit 154af2692f
+324
View File
@@ -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. Новых продуктовых вопросов нет: конечное физическое тело обязано соблюдать
конечный источник.