Files
houseplan-card/docs/specs/329-junction-limits.md
T
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

24 KiB

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. Откат

Реверт ветки: ограничения исчезают, рендер возвращается к текущему поведению. Данные фикс не переписывает (валидация и рендер, не миграция).