Files
houseplan-card/docs/specs/150-wall-thickness-transition.md
T
2026-08-18 17:36:54 +03:00

434 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Issue #150 — Точная геометрия коллинеарного перепада толщины стен
- **Issue:** https://github.com/Matysh/houseplan-card/issues/150
- **Редакция:** r2 после `SPEC-REVIEW-150-r1`; статус задачи определяется только
метками issue
- **Тип / приоритет:** bug / P2
- **Оценка:** пользовательская ценность 8/10; ценность для разработки 8/10;
сложность 7/10, риск 8/10
- **Область:** atomic wall intervals, внешний фасад, wall body/paper/clean floor,
Plan, View/киоск, static renderer, hidden Iso, Glow и солнце
- **Модель данных:** без изменений и миграции
- **Связано:** `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`,
`docs/CANVAS.md`, `docs/ISOMETRIC.md`; #123 и #141
## 1. Сценарий и продуктовый контекст
**Персона:** администратор дома, исправляющий архитектуру плана в desktop Plan
Editor.
**Поверхность и момент:** после Split исходной комнаты с нулевыми стенами
пользователь выбирает внешнюю стену одной дочерней комнаты, задаёт `10 см` и
нажимает «Применить ко всем стенам комнаты».
**До → после, без терминов реализации:** сейчас после Split толщина выбранной
комнаты сохраняется правильно, но у точки разделителя не заканчивается и
визуально продолжается по внешним стенам соседней комнаты, для которых задано
`0`. После исправления выбранные участки имеют полные 10 см до точки
разделителя, соседняя комната остаётся без толщины, а граница видна как точная
ступень.
Первоначальное описание «примерно 5 вместо 10» уточнено после ревью. На
скриншоте внутренняя половина внешней стены действительно занимает 5 см, потому
что действующий контракт стены — `centreline ±½`; внешняя половина лежит на
белом поле и читается по штриховке. Полная глубина выбранного участка равна
10 см. Реальный дефект скриншота — отсутствие границы `10 → 0`: штриховка
10-сантиметрового body продолжается снаружи вдоль нулевого фасада соседа.
Задача поддерживает J4 и J6 из `docs/SCOPE.md`: штатное редактирование должно
создавать измеримо правдивую и стабильно читаемую архитектуру.
## 2. Красное воспроизведение на `dev` и подтверждённая причина
На конфигурационном уровне команда уже работает правильно:
- все допустимые atomic intervals выбранной комнаты получают `10 см`;
- общий разделитель получает `10 см` как одна физическая стена;
- внешние стены соседней комнаты остаются `0`.
Повторная проверка после `SPEC-REVIEW-150-r1` выполнена на текущем `dev`
`a05aa5d` двумя путями: через фактические обработчики Plan Editor
`_splitClick()` → `_wallThickClick()` → `_wallThickApply(true)` и через
исполняемые `setWallThicknessForRoom()` → `wallIntervals()` →
`wallBodiesGeometry()` с production-параметром `coordScale = 1000`.
Красный fixture: прямоугольник `[100,100]–[900,900]`, вертикальный Split в
`x = 500`, слева `10 см`, справа `0`, разделитель `10 см`. Данные дают:
- левый верхний interval: `cm = 10`;
- правый верхний interval: `cm = 0`;
- divider: `cm = 10`;
- ожидаемая полная глубина: `8.333333` render units.
Но текущий masonry path содержит верхнюю грань
`M 95.833… 95.833… L 900 95.833…`: глубина 10 см протянута через весь правый
нулевой interval до `x = 900`. Поэтому probe на правом участке, например
`[700, 96]`, ошибочно находится внутри masonry, хотя обязан быть вне его.
Сечение выбранного левого участка при этом полное — `8.333333` units; тест,
проверяющий только толстую сторону, дефект не обнаруживает.
Подтверждённая причина находится в `exteriorBoundaryProfile()`. Коэффициент
`t` точки разбиения нормирован на диапазон `0…1`, но сравнивается с `eps` в
render units. При обычных `pitch = 1/240` и `coordScale = 1000` используемый
`eps` равен примерно `0.6667`; проверки `t > eps && t < 1 - eps` становятся
невыполнимыми, а дедупликация `t` тем же допуском также размерностно неверна.
Breakpoint `x = 500` после boolean union не материализуется, и весь
коллинеарный edge получает depth того interval, на который попал его midpoint.
Воспроизведение обязано использовать production scale; проверка с
`coordScale = 1` не ловит ошибку.
Это не дефект сохранения команды «Применить ко всем», не изменение семантики
`centreline ±½` и не исправляется дублированием wall key, CSS stroke или
визуальной накладкой только в Plan.
## 3. Решения владельца
Владелец 15.08.2026 принял defaults Q1–Q4. Каноническая запись:
https://github.com/Matysh/houseplan-card/issues/150#issuecomment-5301107134
1. Выбранная комната получает полные 10 см на внешних участках вплоть до
разделителя; общий разделитель — 10 см; остальные внешние стены соседней
комнаты — 0.
2. На общем внешнем фасаде переход точный и ступенчатый на endpoint
разделителя, без сужения, растягивания на соседа или taper.
3. Инвариант действует для любых соседних коллинеарных интервалов разной
толщины (`10 → 0`, `10 → 20` и т. п.).
4. Исправляется общая каноническая геометрия Plan, View/киоска, static, hidden
Iso/floor footprint и препятствий Glow/солнца; существующие конфиги
исправляются без миграции.
## 4. Скоуп
В задачу входят:
1. все пары соседних collinear exterior atomic intervals с неравной допустимой
толщиной, включая один нулевой;
2. точная граница перепада в сохранённом atomic endpoint;
3. сценарий endpoint разделителя после Split и аналогичная топология,
независимо от направления, winding, room id и порядка комнат;
4. полная толщина каждого участка, корректные inward/outward faces и clean
floor по обе стороны перехода;
5. корректное соединение с общей стеной/разделителем, заканчивающейся в той же
точке;
6. единая masonry/paper geometry для всех render/light consumers;
7. уже сохранённые планы без config rewrite;
8. unit, browser smoke, visual golden, документация и RU/EN changelog.
## 5. Не входит в задачу
- изменение UX или семантики «Применить ко всем стенам комнаты»;
- изменение выбора atomic interval, диапазона толщины или wall key format;
- автоматическое выравнивание толщины соседних комнат;
- плавный bevel/taper между коллинеарными участками;
- новая пользовательская настройка вида перехода;
- изменение Split, Merge, Resize или Boundary как редакторских операций;
- исправление произвольных near-miss, X-crossing или независимых junctions,
которые относятся к #141;
- изменение фасадного инварианта corner Split из #123;
- миграция, schema version, backend, import/export или materialisation на read;
- отдельная wall model для Iso или света;
- изменение opening semantics, кроме обязательного отсутствия регрессии на
opening, расположенном рядом с перепадом.
## 6. Каноническая математическая семантика
### 6.1. Atomic intervals
Максимальная collinear boundary разбивается во всех структурных точках:
- endpoints исходных room edges и дочерних edges после Split;
- endpoints точных `WallEntry.a/b` и совместимых legacy breaks;
- начало/конец `open_spans` и physical opening coverage, когда они создают
действующую атомарную границу;
- junctions и точки изменения effective thickness.
Каждый получившийся solid interval имеет постоянную effective thickness `cm_i`
и half-depth
```text
h_i = wallCmToUnits(cm_i, cell_cm, grid_pitch) / 2
```
Zero interval (`cm_i = 0`) остаётся границей пола, но не создаёт masonry body.
Ни порядок комнат, ни midpoint compacted key не могут удалить точный breakpoint.
### 6.2. Полная толщина участка
Для любой точки внутри solid interval, расположенной дальше geometry epsilon
от его endpoints и openings, сечение wall body по нормали к centreline обязано
иметь:
- полную глубину `2 × h_i`, соответствующую `cm_i`;
- внутреннюю грань на `h_i` от centreline;
- внешнюю грань на `h_i` от centreline;
- отсутствие удвоения или потери alpha/hatch из-за перекрывающихся room rings.
Допуск используется только для численной устойчивости и не может объяснять
видимое сужение 10 см до 5 см.
### 6.3. Жёсткая ступень `h1 → h2`
Пусть два соседних collinear intervals встречаются в точке `P`, имеют
half-depth `h1` и `h2`, `h1 != h2`.
1. До `P` обе faces находятся ровно на `±h1`; после `P` — на `±h2`.
2. Разность глубин закрывается в `P` сегментами, перпендикулярными centreline.
3. На ненулевой длине вокруг `P` нет линейной интерполяции, mitre вдоль прямой,
taper, усреднения, overshoot или распространения большего `h` на соседа.
4. Breakpoint в результирующей geometry совпадает с `P` в пределах общего
geometry epsilon.
5. Правило симметрично для `0 → h`, `h → 0`, `h1 → h2`, обратного направления
boundary и любого winding.
6. Boolean cleanup может объединить совпадающие рёбра, но не имеет права
удалить поперечные faces ступени или сдвинуть их от `P`.
### 6.4. Endpoint разделителя
Если в `P` к фасаду примыкает shared wall после Split:
- shared wall сохраняет полную назначенную толщину и одну physical identity;
- её тело заканчивается/соединяется с внутренней частью фасада без щели;
- она не выступает наружу и не изменяет exterior face фасада, как требует
контракт #123;
- внешний interval выбранной комнаты сохраняет `h_selected` до `P`;
- соседний внешний interval начинает собственный `h_neighbor` непосредственно
после `P`;
- соединение не оставляет triangular notch, half-depth strip, двойной hatch или
ложный passage для света.
Для сценария issue это означает `10 см exterior → P → 0 см exterior`, при этом
shared divider в `P` имеет `10 см` и остаётся внутри facade envelope.
### 6.5. Clean floor и paper
- paper footprint растёт наружу по локальному `h_i` и повторяет ту же ступень;
- clean floor каждой комнаты отступает внутрь по её effective local half-depth;
- выбранная комната не получает клин пола между 10-сантиметровой стеной и
разделителем;
- соседняя комната с `0` сохраняет floor до centreline своего внешнего участка;
- displayed area вычисляется из исправленного clean floor без изменения
centreline room polygon или stored area data;
- courtyard/nested geometry сохраняет действующий evenodd/topology contract.
## 7. Архитектурный контракт реализации
Конкретный helper и boolean primitive выбирает автор, но обязательны границы:
1. `wallIntervals()`/эквивалент остаётся каноническим источником interval
endpoints и effective `cm`.
2. Exterior profile обязан материализовать каждый thickness breakpoint до
offset/union и сохранить две независимые face depths в этой точке. Обычный
polygon vertex с одним offset, не способный выразить discontinuity, не
является достаточным представлением.
3. Допустимые решения — interval slabs/quads с точными butt faces, профиль с
duplicated transition vertices/explicit step faces или эквивалентная
детерминированная конструкция. Рисование corrected CSS stroke поверх
ошибочного body запрещено.
4. Настоящие non-collinear фасадные углы продолжают использовать действующий
bounded mitre/bevel (`MITRE_LIMIT`); collinear thickness step не является
углом для mitre.
5. Shared/internal bodies сначала ограничиваются interior side exterior
envelope, затем объединяются с точным shell — инвариант #123 не ослабляется.
6. Один результат `wallBodiesGeometry()`/эквивалента порождает masonry path,
paper path, clean-floor relation и light/sun barriers. Static и full card
не имеют отдельных корректирующих веток.
7. Iso потребляет тот же canonical masonry/floor footprint и экструдирует уже
правильный stepped outline; самостоятельное сглаживание ступени запрещено.
8. Порядок комнат, stable id rename, winding и направление интервала не меняют
геометрическое множество результата.
9. Новая топология входит в существующий structural fingerprint/cache. HA
state tick, hover и activity effects не запускают boolean rebuild.
10. При boolean failure действует существующий fail-closed contract: нельзя
воскресить raw per-room rings, известные half-depth/facade дефектом.
## 8. Openings, виртуальные границы и junction regression contract
Задача не меняет типы проёмов, но исправленная geometry обязана сохранять:
- opening association с фактическим atomic interval;
- full-depth cut и jamb по локальной толщине каждой покрытой части;
- при crossing thickness transition — точный stepped tunnel без cut соседнего
interval и без neutral-paper leak;
- window/door/gate light semantics из `docs/WALL-THICKNESS.md`;
- virtual `open_span` как реальный break, а не мост для переноса большей
толщины;
- примыкания независимых partitions/drafts/columns по контракту #141;
- corner Split exterior envelope по контракту #123.
Если opening не может быть однозначно связан после исправления, действует
существующий fail-dark/fail-closed путь; новая nearest-wall эвристика не
вводится.
## 9. Поверхности-потребители
Одинаковое геометрическое множество требуется для:
1. Plan Editor, включая hover/selection Thickness поверх фактического body;
2. View и kiosk full renderer;
3. `houseplan-space-card`/static renderer;
4. hidden Iso wall volume, top face и floor slab footprint;
5. room fill и clean-floor clipping;
6. displayed clean area;
7. Glow/spill occluders и placement checks;
8. sun barriers/wedges;
9. paper/content frame, когда внешняя половина стены расширяет план.
Разница только из-за presentation policy (например, hatch suppression при
малой экранной толщине) допустима; physical outline и проходы должны совпадать.
## 10. Модель данных, compatibility и миграция
Формат остаётся прежним:
```ts
interface WallEntry {
key: string;
cm: number;
a?: number[];
b?: number[];
}
```
- новые поля, aliases и schema version не добавляются;
- точные `a/b` и legacy midpoint-only keys читаются по действующему contract;
- normalisation/materialisation выполняются только при явном редактировании,
как сейчас;
- открытие/рендер старого плана не вызывает save и не переписывает config;
- backend validation, import/export и wire payload не меняются;
- rollback возвращает прежний renderer без потери данных, хотя визуальный
дефект снова проявится.
## 11. UX, i18n, accessibility и touch
Controls, dialogs, tool sequence, labels и focus не меняются. Новые locale keys
не требуются.
Plan Editor остаётся desktop-first. **Touch editor: best effort / intentionally
degraded.** Safety floor обязателен: тот же сохранённый config на touch не может
получить другую физическую геометрию из-за pointer type. View и kiosk fully
supported и обязаны показывать тот же полный stepped body.
`prefers-reduced-motion` не затрагивается. Исправление geometry не добавляет
animation и не использует цвет как единственное доказательство толщины.
## 12. Acceptance criteria
1. **[unit]** Production-scale fixture из §2 сохраняет data profile `10 см` на
внешних стенах выбранной комнаты и shared divider, `0` на внешних стенах
соседа; тест явно передаёт `coordScale = 1000`.
2. **[unit]** Нормальное сечение каждого 10-сантиметрового участка до endpoint
имеет полные 10 см: 5 см наружу и 5 см внутрь, в численном пересчёте render
units.
3. **[unit + golden]** На endpoint разделителя фасад меняется `10 → 0` точной
поперечной ступенью; probes на нулевой стороне находятся вне masonry, нет
taper, переноса 10 см на соседа, зуба или щели.
4. **[unit]** Матрица `0 ↔ 10`, `10 ↔ 20`, `1 ↔ 100` и равных толщин корректна
в обоих направлениях, при обратном winding и перестановке/переименовании
комнат.
5. **[unit]** Shared divider сохраняет полную толщину, соединяется внутри и не
расширяет фасад наружу.
6. **[unit]** Paper, clean floor и displayed area следуют локальным depths и
точной ступени без клина/утечки пола.
7. **[smoke + golden]** Plan, View/kiosk, static и hidden Iso имеют одинаковый
wall/floor outline; Glow и солнце блокируются ровно нарисованным body.
8. **[unit + smoke]** Openings около/через transition используют локальные
depths и не создают ложных cuts или двойной fill.
9. **[unit + smoke]** Регрессии #123 (corner Split facade) и #141 (independent
junctions) остаются закрытыми; nested/courtyard/open-span fixtures не
меняются ошибочно.
10. **[unit + ревью кода]** Старый config исправляется вычисляемо без
save/migration и остаётся совместимым с rollback.
11. **[unit + ревью кода]** Structural geometry кешируется; ordinary HA tick не
повторяет topology.
12. **[unit + ревью кода]** Boolean failure остаётся fail-closed и не
воскрешает raw per-room rings.
## 13. Проверки и доказательства
### Unit — данные и точная геометрия
- красный production-scale fixture из §2: Split rectangle, all walls `0`,
all-room `10` для одной child room; assert effective intervals, shared wall
ownership, полную толстую сторону и отсутствие masonry probes на нулевой
стороне;
- отдельная нормализованная вариация с `coordScale = 1` не заменяет fixture с
`coordScale = 1000`;
- cross-section/point containment на interior участках доказывает exact
`±h_i`, а не только сравнивает bbox/скриншот;
- transition faces совпадают с divider endpoint и перпендикулярны centreline;
- parameter matrix `0/1/10/20/100`, оба направления, horizontal/vertical/45°;
- room order/id/winding invariance через symmetric-difference area;
- equal neighbouring depths не создают лишнюю seam/step;
- shared divider: no exterior difference сверх локального exterior profile;
- paper geometry и clean-floor areas по обе стороны;
- opening до, после и пересекающий transition;
- #123 corner Split, #141 virtual/independent junction, open spans,
nested/courtyard и boolean failure regressions;
- full/static/Iso consumers получают один fingerprinted canonical result.
### Browser smoke
1. В Plan повторить пять шагов issue и визуально/инструментом проверить каждую
стену выбранной и соседней комнаты.
2. Переключить Plan → View → kiosk/static fixture → hidden Iso без изменения
silhouette/step.
3. Проверить Undo/Redo all-room thickness: одна команда возвращает прежний
профиль, повтор применяет исправленную geometry.
4. Добавить opening рядом с transition и проверить cut/fill/Glow.
5. Изменить HA light state: topology fingerprint и masonry path не меняются.
### Golden и performance
- golden исходного issue крупным планом в Plan и View;
- static и hidden Iso того же fixture;
- `10 → 20`, 45° transition, opening near transition, light/sun state;
- diff review проверяет именно ступень и полную толщину, а не массовое
обновление anti-aliasing;
- performance evidence подтверждает отсутствие geometry rebuild на HA ticks и
отсутствие существенной регрессии canonical pass на representative plan.
Golden, smoke и performance запускаются в release gate перед бетой; цикл
реализации — `typecheck`, `unit`, `build` по процессу. Полный HA harness
каноничен в Linux CI.
## 14. Release-артефакты
В том же user-visible коммите реализации обязательны:
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- `docs/WALL-THICKNESS.md` — явный контракт collinear discontinuity;
- `docs/ARCHITECTURE.md` — canonical interval/step representation и consumers;
- при необходимости `docs/ISOMETRIC.md` и `docs/CANVAS.md`, если комментарии о
footprint требуют уточнения;
- `docs/USER-GUIDE.ru.md` — только если там описан отличавшийся результат
all-room thickness; новых controls нет;
- unit fixture, smoke scenario и принятые golden/baselines;
- `docs/TESTING.md`, если меняется или добавляется release scenario.
Новых i18n, backend/security artifacts не требуется. Если реализация потребует
формат данных или отдельную пользовательскую настройку, задача возвращается
владельцу до кода.
## 15. Риски и rollback
Основные риски: новый внешний зуб вместо half-depth, потеря floor hole,
расхождение full/static/Iso, ошибочный opening cut и рост boolean cost. Они
закрываются symmetric-difference, cross-section, consumer parity и performance
проверками раздела 13.
Rollback — возврат geometry-кода и документации без миграции. Сохранённые
`walls` не изменяются и остаются читаемыми обеими версиями.
## 16. Принятые технические предположения
1. Breakpoint уже доступен из точных atomic intervals; исправлению не нужен
новый persisted vertex или WallEntry field.
2. Ступень существует на обеих faces wall body. При `h2 = 0` обе faces второго
участка сходятся на centreline ровно в `P`, а не заранее.
3. Поперечная face в transition является частью physical boundary, но не новым
самостоятельным wall object и не target инструмента Thickness.
4. Opening, пересекающий transition, остаётся допустимым и режет union реальных
локальных depths; запрет такого opening не является исправлением.
5. Existing structural cache key на `_cfgEpoch`/complete geometry расширяется
только если текущий fingerprint не содержит всех endpoints/thicknesses.