Files
houseplan-card/docs/specs/329-junction-limits.md
Codex 5a2dd333d2 fix: plan/optimize passes the junction gate; import stays free by design (#333)
The owner's decision (2026-08-28): optimize is one of the two commands a
client can use to write arbitrary geometry, so it validates its candidate
against the stored document exactly as config/set does — inheritance counted
per rule (repairing a legacy plan with violations still passes; #329 AC10
already proves an honest optimization adds none, so the gate is a no-op for
legitimate flows), while a crafted payload is refused with the stable
junction_limit_<rule> code the except list has been ready for since #329.
The call lives inside the existing executor function, and a successful
optimize refreshes rt.junction_baseline with the candidate's counts so the
next config/set inherits from the cache (#330 §4.2 symmetry).

Import and backup restore stay OUTSIDE the gate on purpose — #329 §3
promises a restore is never blocked. The module docstring stops promising
more than the code does, and spec #329 §5 records the perimeter and the
trade-off explicitly: a crafted import can persist violations, but they are
inherited, never legalised as new ones.

HA tests pin AC1 (crafted spike refused, stored config and rev
byte-unchanged), AC2 (echo-optimize of a stored plan that already carries a
violation passes) and AC3 (the follow-up config/set takes its baseline from
the cache — observed through a recording wrapper). The
junction-limit-optimize-unguarded mutant turns AC1 red through the
backend-test-guard convention.

Issue: #333
User-Visible: no
2026-08-28 08:55:39 +03:00

261 lines
24 KiB
Markdown
Raw Permalink 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 #329 — ограничения стыков стен и честная острая вершина
Статус: ревизия 7 (r1 код-ревью: H1 — обе стороны на бэкенде судятся после одной миграции, M1 — мёртвый код фаски удалён, M2/M3 — гайды, M4 — AC10 доказан тестами; r1: M1/M2/L1; r2: M1 — AC7; поправки владельца 2026-08-27: §4 — острая вершина без фаски и без зазубрин на гранях; П3 меряет СТЕНУ, а не атом — кейс компенсации перепада толщин). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
пять ограничений приняты; проверки действуют на записи, легаси читается как
есть; для остаточных острых вершин легаси-планов рендер закрывает остриё
честной фаской вместо «трезубца».
## 1. Сценарий и пользовательский результат
Сегодня комната-треугольник с вершиной ≈9.9° (стены 15 см) рендерится
«трезубцем»: три пика и два V-выреза (репродукция в issue, фикстура
`houseplan-space-test2-2026-08-27_16-14-48.json`, комната `rmtbq3k5e-0`).
Причина класса: при остром угле внутренние грани стен смыкаются на
`полутолщина/tan(θ/2)` от вершины (86 см при 10°/15 см) — весь участок выше
физически перекрыт телами обеих стен, и объединение тел с вершинными
правилами #309/#310 распадается.
После задачи: (а) нарисовать такое больше нельзя — запись отклоняется с
точным тостом; (б) уже существующие такие планы рендерятся прилично — остриё
закрыто одной фаской, без пиков и вырезов.
## 2. Нормативные ограничения (решения владельца)
Все проверки выполняются над кандидатом структурной записи (фронт — в общем
барьере перед записью; бэкенд — семантическая дельта-валидация §5). Нарушение
= отказ всей записи, план не изменён. Канал обратной связи — штатный канал
каждой поверхности (M1 spec-ревью r1):
- **рисование/завершение контура, черновики, независимые стены, merge/
split** — тост с конкретным правилом и значением (текущий канал отказов
рисования);
- **Resize** — существующий контракт ручек (USER-GUIDE «Изменение размеров»):
движение упирается в последнюю допустимую позицию; если безопасного шага
нет, ручка приглушена и объясняет запрет по hover/focus/нажатию — текст
объяснения называет нарушенное правило П1–П5; тост не используется;
- **инструмент «Толщина»** — отказ значением в трее по образцу существующего
отказа нуля для независимой стены (#313): значение не применяется, причина
показывается тостом с названием правила (П3/П5).
- **П1. Минимальный угол:** угол между СОСЕДНИМИ по азимуту стенами одного
узла ≥ **15°**. Считается по осям сегментов, для каждого узла кандидата.
- **П2. Валентность узла:** в одном узле сходятся ≤ **6** стен (контурные
атомы, независимые стены и перегородки считаются вместе).
- **П3. Минимальная длина стены:** длина ≥ **max(20 см, толщина
сегмента)**. Для нулевых стен (#306) — ≥ 20 см. Меряется НЕ отдельный атом
каталога, а вся стена: максимальный коллинеарный (±1°) прогон соседних
атомов ОДНОЙ толщины, склеенных через общие узлы. Иначе правило запрещало
бы законный приём — короткий доборный участок, компенсирующий перепад
толщин соседних стен (владелец, 2026-08-27, фикстура
`houseplan-space-2-2026-08-27_18-24-23.json`: атомы 5 см = (30−20)/2 и
14 см, каждый — продолжение стены той же толщины в 349 см). Такой атом
короче 20 см законен ровно потому, что стена, частью которой он является,
длиннее 20 см.
- **П4. Минимальная дистанция:** несмежные узлы — не ближе **5 см**; узел —
не ближе 5 см к стене, которой он не принадлежит (касание конца о середину
чужой стены остаётся T-стыком и валидно — правило про «почти-касание» без
инцидентности). Порог абсолютный, в сантиметрах, и НЕ зависит от
`cell_cm` пространства.
- **Точность границ (#331):** ключ узла квантуется к 1e-7 формулой
канонизации (`sign·floor(|v|·1e7+0.5)/1e7`, −0 → 0); пары точек ближе
2e-7 (сырые координаты) — один узел, а не «почти-касание»; длина П3 меряется по
коллинеарной (±1° к БАЗЕ прогона) компоненте связности одной толщины.
- **П5. Просвет комнаты:** после вычета тел стен внутренняя область комнаты
непуста и её площадь ≥ **25 см²**. Порог абсолютный и не зависит от
`cell_cm`.
## 3. Граница применения
- Проверяются только записи, ИЗМЕНЯЮЩИЕ соответствующую геометрию: рисование
и завершение контура, черновики, независимые стены/перегородки, Resize,
инструмент «Толщина» (толщина участвует в П3/П5), merge/split.
- Существующие данные наследуются: запись, не трогающая нарушающий элемент,
проходит; миграция (#306/#316), импорт и восстановление из бэкапа не
блокируются никогда.
- Optimize (выравнивание/канонизация) не обязан чинить легаси-нарушения и не
имеет права создавать новые (пост-условие прогона).
## 4. Рендер остаточных острых вершин (легаси)
**Поправка владельца (чат, 2026-08-27):** плоского среза вершины быть не
должно — у остриё обычная нормальная острая вершина.
Для корня «трезубца» доказано исполнением: у вершины ≈9.85° внешний контур
падал на плоскую фаску (лимит mitre 4·h против выноса 87 см), а внутренний
контур складывался «бабочкой» — вырезы рождались вычитанием этой складки.
Контракт:
- вырожденная вершина (угол < 15° и точка смыкания внутренних граней внутри
обеих стен) даёт **одну точку внешнего контура ровно в вершине плана**:
ни двухточечной фаски, ни иглы mitre, уходящей на десятки сантиметров за
стены, которые её образуют;
- внутренний контур сходится в СВОЮ вершину (одна точка mitre) вместо
двухточечной фаски, поэтому «бабочки» не возникает вовсе: вычитать нечего,
V-вырезов нет, кладка выше сплошная, штриховка непрерывна;
- на внешних гранях между внутренней и внешней вершиной не остаётся ступенек
(вторая поправка владельца): кольцо комнаты — ровно треугольник, каждая
сторона длиннее полутолщины, микро-вершин и «щепок» нет;
- обычные и просто острые пары (#310, вершина вне зоны перекрытия) не
меняются: их полный mitre сохраняется.
## 5. Бэкенд
Семантическая дельта-валидация по образцу существующих (например, jamb
margin), модуль `custom_components/houseplan/junction_limits.py`. Считается
так же, как на фронте: нарушения по ПРАВИЛУ, а не по носителю (структурная
запись переатомизирует сегменты и меняет их id), унаследованные проходят,
новое — отказ. Ошибка — `JunctionLimitError` со стабильным кодом
`junction_limit_<rule>`, отдаётся тем же `connection.send_error`, что и
остальные семантические ошибки записи конфига.
Обе стороны сравнения (`previous` и `candidate`) проходят через
`commit_wall_segment_model` до подсчёта. Без этого легаси-документ без
каталога отвечал «нарушений нет» независимо от геометрии, и первая же
структурная правка после обновления карточки читала унаследованное
нарушение как новое — отказ несвязанной правке, прямое нарушение §3
(находка H1 код-ревью r1).
Зеркалятся П1–П4. П5 (просвет комнаты) сознательно оставлен только на
клиенте: это утверждение о ПОСТРОЕННЫХ телах стен, и повторение конвейера
mitre/inset на Python было бы второй реализацией геометрии, расхождение
которой опаснее самого правила. Документ, нарушающий только П5, некрасив, но
не повреждён. Паритет П1–П4 закреплён тестом
`tests_backend/test_junction_limits.py::test_parity_with_the_frontend_checks`:
одни и те же фикстуры прогоняются через TS-функции и через питоновские, и
вердикт обязан совпасть.
Периметр гейта (#333, решение владельца 2026-08-28): `config/set` и
`plan/optimize` — обе команды, которыми клиент может записать произвольную
геометрию, валидируются одинаково (наследование по правилу: ремонт
легаси-плана с нарушениями проходит, добавить новое нельзя; честная
оптимизация не добавляет нарушений по AC10, поэтому для легитимных потоков
гейт — no-op). Импорт и восстановление из бэкапа — сознательно ВНЕ гейта:
§3 обещает, что restore не блокируется никогда; крафтовый импорт может
внести нарушения, но они наследуются, а не легализуются как новые.
## 6. Затронутые поверхности и артефакты
- `src/wall-thickness.ts` / `src/wall-segment-model.ts` — геометрия проверок
П1–П5 (чистые функции) и фаска §4 в построении тел/вершинных правил.
- `src/houseplan-card.ts` — вызовы проверок в общем барьере записи, тосты.
- `src/i18n/en.json`, `src/i18n/ru.json` — по ключу на правило:
`junction.limit_angle`, `junction.limit_valence`, `junction.limit_length`,
`junction.limit_distance`, `junction.limit_clearance` (с параметрами
значений) + заголовок отказа.
- `custom_components/houseplan/junction_limits.py` — дельта-правила §5,
вызов в `websocket_api.py` рядом с остальными семантическими валидаторами;
`tests_backend/test_junction_limits.py` — границы правил, принятие
унаследованного, отказ нового и паритет с фронтом.
- Тесты фронта: юниты на каждую формулу П1–П5 (границы: 14.9°/15°, 6/7 стен,
19/20 см, 4/5 см, пустой/непустой просвет), мутанты в гейте на П1 и §4;
смоки каналов отказа: текст объяснения ручки Resize и текст тоста
«Толщины» (AC7a/AC7b).
- Смок: репро-фикстура #329 — новая попытка нарисовать «шпиль» отклоняется;
легаси-конфиг рендерится по §4.
- Golden: новая сцена `sharp-apex-legacy-dark` по фикстуре issue (рендер §4);
существующие сцены не должны измениться — `golden:verify` обязан
подтвердить (в матрице нет вершин острее 15° — проверить прогоном).
- `docs/USER-GUIDE.md`/`.ru.md` — раздел об ограничениях рисования (таблица
П1–П5); CHANGELOG RU+EN (`User-Visible: yes`).
- `docs/CONFIG-COMPATIBILITY.md` — абзац: ограничения действуют на записи,
унаследованные данные валидны на чтение/миграцию.
- Новых полей конфига нет — `config-field-registry` не меняется.
## 7. Производительность и touch
Проверки П1–П4 — O(узлы+сегменты) на запись, вне горячего пути рендера. П5
использует уже вычисляемые физические тела (кэш) на кандидате записи —
допускается только на commit, не на каждый жест курсора. Touch-контракт не
затрагивается (правки — не жесты).
## 8. AC
- **AC1 (П1).** Завершение контура с вершиной 14° отклоняется, тост называет
минимум 15° и узел; 16° — проходит. Юнит на границе 15.0°.
- **AC2 (П2).** Седьмая стена в узел — отказ; шесть — проходит.
- **AC3 (П3).** Сегмент 19 см — отказ; 20 см — проходит; сегмент 25 см при
толщине 30 см — отказ (длина < толщины).
- **AC3b (П3, компенсация перепада толщин).** Атом 5 см, коллинеарный
продолжению стены той же толщины, проходит: правило меряет прогон, а не
атом. Проверяется юнитом на фикстуре владельца — 0 нарушений П3.
- **AC4 (П4).** Узел в 4 см от несмежного узла или чужой стены — отказ; 5 см
— проходит; T-стык (конец на чужой стене) — проходит.
- **AC5a (сквозной сценарий фикстуры).** Повторение «шпиля» из фикстуры
issue рисованием отклоняется первым же нарушенным правилом (П1, вершина
≈9.9° < 15°) — сквозной смок поверх AC1.
- **AC5b (П5 независим от П1).** Равносторонний треугольник со стороной
60 см и стенами 33 см: все углы 60° ≥ 15°, но inradius ≈ 17.3 см меньше
полутолщины 16.5 см плюс минимального просвета — внутренняя область после
вычета тел < 25 см², запись отклоняется по П5. Тот же треугольник со
стороной 120 см (inradius ≈ 34.6 см) проходит. Юнит с этими числами.
- **AC6 (§4).** Легаси-фикстура issue рендерится без «трезубца»: у вершины
ровно одна точка внешнего контура, стоящая в вершине плана; внутренний
контур — треугольник (ровно 3 точки, не «бабочка»); внешнее кольцо тела —
ровно 3 различные вершины без ступенек и микро-сегментов; golden-сцена +
юниты на контурах и на кольце.
- **AC7a (Resize, канал §2).** Resize, приводящий к нарушению любого из
П1–П5, упирается в последнюю ДОПУСТИМУЮ позицию: нарушающий шаг не
превьюится и не коммитится, а отказ ровно один раз за жест называет
нарушенное правило тем же каналом, которым Resize уже объясняет
отклонённый предпросмотр (`resize.preview_failed` → `resize.limit_stopped`).
Смок ведёт настоящий pointer-жест по ручке: сетка 2 см, две комнаты в 10 см
друг от друга, тяга на 6 см (оставила бы 4 см между чужими узлами, П4)
останавливает стену на 6 см и показывает ровно один тост с «5 см».
> Ревизия 6, отступление от первоначальной формулировки («ручка приглушена,
> тост не показывается, план байт-неизменен»). Приглушение ручки считается
> статически в `_rszResolution`, до жеста, а нарушение стыка зависит от
> КОНКРЕТНОГО шага — статически его не разрешить, не перебирая все позиции.
> Молчаливый отказ при этом прямо противоречит уже действующему контракту
> #293/#295 («reject не молчит»), закреплённому мутантом
> `resize-preview-reject-silent`. И байт-неизменность плана неверна как
> общее требование: разрешённая часть жеста — это нормальная правка,
> которую пользователь и просил; неизменным план остаётся только когда
> допустимого шага нет вовсе.
>
> Замер собственных ограничителей Resize (2026-08-27): комнату не дают
> сузить ниже 30 см (две толщины по 15 см), поэтому П3 (20 см) и П5 через
> сужение комнаты недостижимы — гейт там страхует, а не работает. Реально
> достижимо П4 на мелкой сетке, этот случай и закреплён смоком.
- **AC7b («Толщина», канал §2).** Значение толщины, нарушающее П3/П5, не
применяется; показывается тост с названием правила (образец отказа нуля
#313). План байт-неизменен. Смок проверяет текст тоста.
- **AC8.** Миграция/импорт/restore фикстуры с нарушениями проходят.
- **AC9 (§5).** Бэкенд: эхо-запись унаследованного нарушения проходит;
запись, добавляющая новое нарушение, отклоняется кодом
`junction_limit_<rule>`; вердикты П1–П4 совпадают с фронтом на общих
фикстурах (тест паритета).
- **AC10.** Идемпотентность: повторный commit валидного конфига байт-иден-
тичен; Optimize на легаси-фикстуре не создаёт новых нарушений (AC-пост-
условие §3). Доказывается двумя юнитами: (1) фикстура владельца
`329-sharp-apex.json` в легаси-хранении — унаследованное нарушение угла
есть до Оптимизации и ни по одному правилу счёт не растёт после;
(2) граничный кейс П4 — две комнаты ровно в 5 см, где привязка к решётке
могла бы утащить узел под порог, после Оптимизации по-прежнему чисты.
Оба считают нарушения ТЕМ ЖЕ способом, что барьер записи: обе стороны
сперва через `commitWallSegmentModel`.
## 9. Принято предположительно (поменять свободно)
- Угол П1 считается между соседними по азимуту осями в узле (не все пары).
- Порог просвета 25 см² и способ его вычисления по кэшу физических тел.
- Дельта-семантика §5: «элемент дельты» = сегмент/узел/комната, чьи данные
изменились относительно previous.
- Имена i18n-ключей и коды ошибок бэкенда.
- Фаска §4 перпендикулярна биссектрисе в точке смыкания внутренних граней.
## 10. Риски
- Ложные отказы на живых планах у границы правил (особенно П4 при плотной
геометрии) — смягчается точными тостами и тем, что унаследованное не
блокируется.
- П5 на больших пространствах — стоимость union; смягчается кэшем тел и
проверкой только на commit.
- §4 меняет пиксели вырожденных вершин — по построению матрицы golden таких
сцен нет (verify обязан подтвердить), новая сцена фиксирует контракт.
## 11. Откат
Реверт ветки: ограничения исчезают, рендер возвращается к текущему
поведению. Данные фикс не переписывает (валидация и рендер, не миграция).