Волна 5 эпика #674, перенос ТЗ (класс C). Из 240 файлов `docs/specs/` в `legacy/specs/` уехали 219: на них не ссылается ни один живой файл (код, тесты, скрипты, workflow, документы вне архива и ревью). Остались 21 ТЗ — на которые ссылаются код, ADR, ISOMETRIC, SUN, RADAR, LIGHT (`docs/specs/067`), DECOR-EDITOR, support-relay, и те, на которые ссылаются они сами; README каталога объясняет, где искать остальное. Открытых issue с файлом ТЗ среди перенесённых нет. Относительные ссылки перенесённых файлов переписаны (`../X` → `../../docs/X`, соседние оставшиеся ТЗ → `../../docs/specs/…`) — все 26 резолвятся. Попутно: битая ссылка в `089-isometric-view-stage1.md:8` на удалённый `089-isometric-view.md` — теперь команда `git show` по истории. Строка в `legacy/README.md`. Issue: #682 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
25 KiB
Issue #249 — ограниченная геометрия узла из трёх и более стен
- Дата: 2026-08-23
- Тип: bug + polish · приоритет P2
- Ценность: пользовательская 7/10 · для разработки 8/10
- Сложность/риск: 5/10 и 6/10
- Issue: #249
- Ветка:
issue/249-multiwall-junction-bevel - Статус ТЗ: редакция r2, повторное ревью зелёное
Канонические документы: docs/SCOPE.md, docs/WALL-THICKNESS.md,
docs/ARCHITECTURE.md, docs/TOUCH-SUPPORT.md, docs/TESTING.md.
1. Сценарий и персона
Администратор рисует обычную пристройку или несколько комнат, сходящихся в одной точке. У трёх стен могут отличаться толщины. В View, kiosk и Static он видит не единый кладочный узел, а узкий клин, который выступает наружу на десятки сантиметров. Контуры пола рядом визуально наезжают друг на друга, хотя сохранённые оси стен и комнаты корректны.
Это нарушает J1 из docs/SCOPE.md: план перестаёт давать достоверную
пространственную картину дома. Дефект постоянно виден, а не ограничен
редактором.
2. Воспроизведение и измеренный дефект
Канонический regression fixture минимизируется из приложенного к issue экспорта
houseplan-space-space-2026-08-22_21-33-04.json (card_version 1.67.0-beta.2, model_version 6, cell_cm = 30). В узле
(0.329166667, 0.141666667) сходятся три положительных физических интервала:
| Луч от узла | Второй конец | Толщина | Направление |
|---|---|---|---|
| A | (0.308333333, 0.237500000) |
50 см | 102.3° |
| B | (0.408333333, 0.100000000) |
50 см | −27.8° |
| C | (0.379166667, 0.191666667) |
70 см | 45.0° |
Углы между соседними лучами — 57.3°, 72.8° и 130.0°. Максимальная
полутолщина H = 4.8611 render units. Текущий объединённый wall body содержит
join-вершину на расстоянии 8.7312, то есть 1.80 × H, и рисует видимый зубец.
Причина подтверждена production-вызовами spaceModels() и
wallBodiesGeometry(): две комнаты независимо строят митры пары 50/70 см через
outsetContour()/insetContour(). Обе митры проходят общий
MITRE_LIMIT = 4, после чего boolean union сохраняет противоположные выступы.
junctionAt() и spaceMergeGeometry() этот узел не формируют.
3. Что человек увидит до и после
До: штриховка у многолучевого узла выходит клином далеко за физическую толщину стен; рядом видны наложенные контуры комнат.
После: те же сохранённые оси образуют один заполненный узел. Корректные короткие митры остаются острыми, а только чрезмерная join-вершина заменяется прямой локальной фаской. В узле нет щели, округления или выступа за установленный габарит. Обычные углы ровно из двух лучей выглядят как раньше.
4. Решения владельца
- Fallback чрезмерной митры — прямая локальная фаска. Она образует единый заполненный узел; округление не добавляется.
- В узле из
3+различных физических лучей допустимый радиус join-вершины:R = 1.25 × H, гдеH— максимальная полутолщина всех сходящихся стен. - Правило применяется независимо от числа комнат и от того, равны ли толщины.
- Узлы ровно из двух лучей сохраняют
MITRE_LIMIT = 4и существующий вид. - Исправляется одна каноническая вычисляемая геометрия для Full, Static, hidden Iso и световых барьеров. Persisted rooms/walls не переписываются.
5. Scope
Входит
- классификация физических узлов по уникальным инцидентным лучам;
- общий лимит
1.25 × max half-depthдля join-геометрии узлов степени3+; - ограниченная митра либо прямая фаска в room inset/outset и в обязательных локальных junction pieces;
- согласованная каноническая кладка, clean-floor/room-fill границы, бумага, Full/Static/hidden-Iso и Glow/sun occlusion;
- unit-набор на 3/4 луча, разные толщины, порядок, winding и
coordScale; - browser smoke и golden-сценарий на минимизированном экспорте владельца;
- обновление
docs/WALL-THICKNESS.md,docs/ARCHITECTURE.md,docs/TESTING.mdи двух changelog.
Не входит
- изменение сохранённых координат комнат,
walls, partitions или drafts; - привязка узлов к сетке и Optimize-миграция старых планов;
- изменение толщины стен либо правил shared/outer/virtual interval;
- новый редакторский инструмент, настройка коэффициента в UI или i18n;
- округлённые joins, отделочные слои и отдельный материал узла;
- изменение двухлучевых острых/тупых углов;
- исправление независимой partition-топологии, уже принадлежащей
linearWallJoinPatches().
6. Термины и классификация узла
physical interval — атомарный room-wall interval с положительной толщиной,
который не является virtual/open. Его полутолщина уже переведена в render units
существующим wallCmToUnits().
node — группа совпадающих концов физических интервалов в существующей
scale-relative геометрической погрешности (openEps(pitch, coordScale) либо
единый эквивалент подсистемы). Координаты не округляются к drawing grid.
ray — направление от node к противоположному концу interval:
- повтор одного физического shared interval от второй комнаты удаляется по каноническому interval key;
- совпадающие сонаправленные атомарные фрагменты считаются одним лучом и берут максимальную физическую полутолщину;
- противоположные направления прямой стены являются двумя разными лучами;
- zero-thickness и virtual intervals не увеличивают степень узла.
multi-wall node — node с тремя или более различными rays. Для него
H = max(ray.halfDepth), R = 1.25 × H. Если конечные значения и положительный
H получить нельзя, локальный limiter не создаёт новую геометрию и не должен
ронять весь structural pass.
join vertex — точка пересечения смещённых граней либо точка локального
junction patch, созданная именно для соединения интервалов в node. Дальние
концы самих стен не являются join vertices и радиусом R не ограничиваются.
7. Геометрический контракт
7.1 Единая карта узлов
Один pure structural pass строит детерминированную карту multi-wall nodes из
эффективных wallIntervals(). Карта не зависит от порядка rooms/walls,
направления сохранённого отрезка или winding комнаты. Она строится один раз для
текущей геометрии и передаётся всем contour/junction вычислениям; отдельные
рендереры не распознают узел повторно.
7.2 Ограничение join
Когда вершина room profile совпадает с multi-wall node:
- существующее пересечение смещённых граней остаётся митрой, если его
конечное расстояние от node
d <= R; - если пересечения нет, оно не конечное либо
d > R, join содержит две штатные offset-точки соседних граней — прямую фаску; - epsilon не расширяет область выбора митры: branch decision всегда разделён
единственной границей
R. Допускepsilonиспользуется только при проверке и сравнении уже вычисленных floating-point координат; - каждая созданная для этого node join-вершина остаётся не дальше
R + epsilonпри численной проверке результата; - локальное соединение имеет ненулевую площадь, node не попадает в hole wall body, а тела всех инцидентных rays входят в один связный кладочный компонент;
- boolean union не вправе вернуть в body отброшенную дальнюю вершину из другого независимого room ring. Все ring/piece producers получают одну карту и лимит до объединения.
Offset-точка фаски находится на расстоянии собственной полутолщины, поэтому
для валидного interval автоматически укладывается в H <= R. Круг либо
аппроксимация окружности не строятся.
7.3 Двухлучевая совместимость
Для node степени 1 или 2 сигнатуры и результат insetContour(),
outsetContour() и существующих bounded junction patches не меняются:
MITRE_LIMIT = 4 остаётся единственным лимитом. Новый коэффициент не становится
глобальной заменой MITRE_LIMIT.
7.4 Канонические потребители
Исправленная structural geometry является общей для:
- wall body/hatch в Full, kiosk и Static;
- Plan preview сохранённой геометрии;
- hidden isometric masonry;
- clean floor, room fills и room hover, использующих внутреннюю грань;
- paper/exterior shell там, где multi-wall node принадлежит внешней границе;
- Glow/sun/light source occlusion.
Существующие structural caches продолжают ключеваться полной геометрией. Курсор, hover и HA state tick не перестраивают topology. Исправление не вводит расходящиеся SVG-only и physics-only patches.
7.5 Ошибки и fallback
- Валидный multi-wall node не может молча вернуться к дальнему
MITRE_LIMIT = 4join: если точная митра не укладывается вR, результатом служит фаска. - Неконечный или вырожденный локальный candidate отбрасывается изолированно;
он не превращает успешную остальную кладку, paper или light barriers в
null/пустой план. - Существующий fail-dark контракт обязательного structural pass сохраняется: исправление не должно маскировать независимую ошибку exterior/body/opening.
8. Данные, совместимость и производительность
- Схема и
model_versionне меняются; config/layout остаются byte-for-byte. - Результат вычисляется при чтении и сразу исправляет старые планы без Save.
- Backend и WebSocket не меняются.
- Нет новых i18n, настроек, действий, жестов либо различий mouse/touch.
- Построение node map ограничено structural geometry pass; допускается
линейная либо
O(E log E)группировка. Новый полныйO(E²)обход на каждый render/state tick запрещён.
9. Acceptance criteria и доказательства
AC1. Экспорт владельца больше не даёт шип
Минимизированный fixture хранит только необходимые rooms, wall entries и
scale-параметры из экспорта. Для node (0.329166667, 0.141666667):
wallBodiesGeometry()возвращает успешную непустую геометрию;H = 4.8611, и каждая join-вершина node находится не дальше1.25 × H + epsilon;- node не лежит в hole, локальный wall body имеет ненулевую площадь и один компонент касается тел всех трёх rays;
- повторный расчёт даёт тот же нормализованный geometry/path.
Доказательство: unit в test/wall-thickness.test.mjs и новый fixture
test/fixtures/249-multiwall-junction.json.
AC2. Матрица многолучевых узлов
Те же инварианты выполняются минимум для:
- трёх стен одинаковой толщины;
- трёх стен 15/50/70 см;
- четырёх стен с равными и разными толщинами;
- перестановки rooms/walls, обратного направления intervals, обоих windings и
production
coordScale = 1000.
Ни один вариант не создаёт gap, self-intersection, null либо второй локальный
кладочный компонент.
Доказательство: table-driven unit с численным bound и сравнением нормализованной геометрии.
AC3. Два луча не меняются
Ровно две стены одинаковой толщины под углами fixture #249 и существующие
acute/obtuse contour cases возвращают прежние точки/paths и продолжают
использовать MITRE_LIMIT = 4. Новый limiter не превращает корректную митру в
фаску.
Доказательство: unit regression на insetContour()/outsetContour() и
существующий wall-thickness unit-набор без обновления его двухлучевых expected
values.
AC4. Все поверхности используют один body
На fixture #249 Full/View, Plan с сохранённым контуром, kiosk, Static, hidden Iso и light occlusion получают одну исправленную physical geometry: клина нет, а луч света не проходит через заполненный узел. Переключение темы и HA state не меняет structural path и не вызывает его пересчёт.
Доказательство: targeted browser smoke
demo/smoke_multiwall_junction.mjs с path/geometry assertions и cache parity.
AC5. Golden фиксирует видимый результат
В golden matrix добавлен отдельный crop узла из минимизированного экспорта: видны три луча, фаска и отсутствие щели. Сценарий имеет semantic assertions на число rays и ограниченный габарит, поэтому пустой или неверно кадрированный PNG не может пройти.
Доказательство: demo/golden/matrix.mjs, harness/fixture и
test/golden-matrix.test.mjs. Новый baseline принимается только по полному
Linux CI artifact командой npm run golden:accept -- --reviewed перед бетой.
AC6. Данные и смежные контракты не меняются
Расчёт не мутирует rooms/walls/open spans, не меняет schema/model version и не затрагивает independent partition junctions, openings или двухлучевые внешние углы.
Доказательство: deep-equality unit входа до/после и зелёные существующие wall/opening/junction tests.
AC7. Гейты реализации
В цикле реализации зелёные:
npm run typecheck;npm test;npm run build;- targeted
node demo/smoke_multiwall_junction.mjsпосле копирования свежего bundle в demo.
Полный smoke, golden и performance запускаются перед бетой; полный backend HA-harness не требуется, если Python/backend не меняются, но общий exact-SHA Validate остаётся обязательным.
10. Документация и changelog
docs/WALL-THICKNESS.md: степень node,R = 1.25 × H, фаска и сохранениеMITRE_LIMIT = 4для двух лучей;docs/ARCHITECTURE.md: единая node map и потребители canonical masonry;docs/TESTING.md: unit/smoke/golden evidence #249;docs/CHANGELOG.mdиdocs/CHANGELOG.ru.md: пользовательский bugfix в том же коммите, что продуктовая правка.
11. Риски и меры снижения
R1. Широкий blast radius для существующих T-стыков
Новый контракт действует не только на экспорт #249, а на каждый валидный узел
степени 3+. Сегодня визуально приемлемая митра в диапазоне (1.25 × H, 4 × H]
станет фаской. Такие T-стыки встречаются в обычных сохранённых планах, поэтому
изменение может затронуть golden-сценарии, не связанные с #249.
Митигация: targeted unit/golden не заменяют полный golden:verify. Перед
бетой обязателен полный Linux golden artifact; просматриваются все изменённые
кадры и diff, а не только новый crop #249. Необъяснимое изменение другого узла
останавливает baseline acceptance. Существующие двухлучевые expected values
дополнительно остаются неизменными по AC3.
R2. Фаска может создать щель или ложную кладку
Независимое ограничение двух room rings способно оставить pinhole, разорвать один ray от узла либо, наоборот, заполнить сектор пола лишним материалом. Расхождение особенно опасно для Glow/sun: визуально малый дефект становится ложным световым проходом или барьером.
Митигация: единая node map применяется до boolean union всеми producers; AC1/AC2 требуют ненулевой положительной связности с каждым ray, отсутствия hole и единой canonical geometry для рендера и occlusion. Отдельный SVG overlay запрещён.
R3. Порядок, winding и масштаб могут менять классификацию
Shared interval присутствует в профилях двух комнат, а атомарные отрезки могут иметь обратное направление. Ошибка дедупликации превратит двухлучевой угол в multi-wall node либо даст разные фаски при перестановке данных. Абсолютный epsilon может разойтись между normalized и production scale.
Митигация: канонический interval/ray key, scale-relative tolerance и
обязательная permutation/winding/coordScale = 1000 матрица AC2.
R4. Локальная boolean-ошибка может погасить весь structural pass
Новая ограничивающая геометрия проходит через polyclip-ts; вырожденный patch
может бросить исключение или дать zero-area polygon.
Митигация: локальный candidate проверяется до union и отбрасывается изолированно по §7.5. Существующий fail-dark контракт обязательных стадий не маскируется; regression #197 остаётся зелёной.
R5. Дополнительный topology pass может ухудшить live rendering
Повторный поиск всех совпадающих endpoints на каждом HA tick сделал бы стоимость видимого bugfix непропорциональной.
Митигация: карта строится только в cached structural pass с ограничением
O(E)/O(E log E) из §8. Targeted smoke проверяет cache parity, а обязательный
предрелизный performance smoke контролирует тяжёлый дом.
12. Откат
Откатывается pure multi-wall node classification/limit и связанные tests/docs. Данные не мигрируются, поэтому отдельный data rollback не нужен. Golden baseline откатывается только вместе с соответствующей product geometry.
13. Принятые технические предположения
- Коэффициент
1.25— именованная константа канонической wall geometry, а не настройка и не литерал в рендерерах. - Shared interval, встреченный из двух room profiles, является одним физическим лучом; иначе обычный T ошибочно классифицировался бы по числу владельцев.
- В regression fixture не переносится остальной пользовательский экспорт: сохраняется минимальная обезличенная геометрия, достаточная для численного и визуального воспроизведения.
epsilonберётся из существующей scale-relative геометрической политики и не ослабляется до шага drawing grid.- Если реализация требует отдельного bounded fill patch для положительной
связности, он остаётся частью canonical structural geometry, целиком лежит в
радиусе
Rи проходит тот же детерминизм/failure isolation; это не отдельный визуальный слой.