Files
houseplan-card/docs/specs/238-opening-inner-distances.md
T
2026-08-22 12:24:49 +03:00

31 KiB
Raw Blame History

Issue #238 — Размеры проёма до внутренних физических границ

  • Дата: 2026-08-22
  • Тип: feature · приоритет P2 · пользовательская ценность 8/10
  • Сложность 7/10 · риск 7/10 · обычный трек
  • Issue: #238
  • Ветка: issue/238-opening-inner-distances

Канонические документы: docs/SCOPE.md, docs/CANVAS.md, docs/WALL-THICKNESS.md, docs/TOUCH-SUPPORT.md, docs/CONFIG-COMPATIBILITY.md, docs/USER-GUIDE.ru.md.

1. Сценарий и персона

Администратор дома в desktop Plan editor выбирает один из типов проёма и ведёт указателем вдоль стены. До клика он выбирает положение двери, окна, ворот или открытого прохода по фактическому отступу внутри помещения, который можно затем проверить рулеткой.

Задача поддерживает J4 и J6 из docs/SCOPE.md: построить правдоподобный план без внешнего CAD и сохранять его точным при дальнейших изменениях.

2. Что человек увидит до и после

До: две подписи показывают расстояния от концов будущего проёма до концов выбранного осевого отрезка стены. Толщина примыкающих стен не учитывается, а на общей стене показывается только одна пара чисел.

После: возле превью видны тонкие размерные линии с засечками и подписями до ближайших внутренних физических границ. У наружной/односторонней стены это две линии, у общей стены двух комнат — четыре, по две со стороны каждой комнаты. У независимой перегородки линии заканчиваются на ближайших физических гранях примыкающих стен; если такой грани нет, остаётся расстояние до торца самой перегородки.

3. Подтверждённый диагноз

Текущая геометрия существует в двух местах:

  • resolveOpeningPlacementResult() в src/opening-placement.ts считает две подписи до концов выбранного атомарного placement-target;
  • _resolveOpeningPlacement() в src/houseplan-card.ts заменяет их результатом openingShoulders(), чтобы исторически мерить до концов собственного ребра комнаты, а не до атомарного разрыва;
  • OpMeasure хранит только точку и текст подписи. Геометрии измеряемого отрезка в модели нет, поэтому renderer не может показать, к каким границам относится число;
  • openingShoulders() работает по осевому room.poly, не видит толщины, независимые partitions и обе стороны общей стены;
  • _opMeasureView уже соединяет placement-preview и overlay, поэтому новый контракт можно добавить без второго hover-resolver и без изменения click/save.

Нужная физическая база уже есть: wallIntervals() сохраняет владельца комнаты и половину толщины атомарного участка, roomWallProfile() / innerContourForRoom() строят внутренние грани комнаты, partitionBody() строит тело независимой перегородки. #233 установил ту же продуктовую конвенцию для resize: пользовательские размеры плана считаются между физическими гранями, а не между осевыми.

4. Термины и система координат

  • Ось хоста — линия выбранного placement-target.
  • Левый/правый косяк — два конца видимой длины preview, то есть candidate.center ± axis * candidate.renderedLength / 2. Jamb safety margin независимой перегородки не добавляется к показываемому расстоянию.
  • Сторона комнаты — внутренняя грань хоста, принадлежащая конкретной комнате. На общей стене стороны разные и считаются независимо.
  • Внутренняя цель — конец внутренней грани комнаты либо первая физическая грань другой стены/перегородки, встреченная вдоль оси хоста.
  • Legacy-цель — соответствующий конец выбранного placement-target.

Расстояние всегда измеряется вдоль оси хоста, от соответствующего косяка в левую или правую сторону. Это не кратчайшее евклидово расстояние до произвольной точки контура: подпись отвечает на вопрос «сколько свободного места осталось вдоль этой стены».

5. Границы задачи

5.1 Входит

  1. Новая чистая геометрия целей размера для preview размещения нового проёма.
  2. Комнатный режим: две подписи для одной комнаты и четыре для общей стены двух комнат.
  3. Режим независимой перегородки: физические грани примыкающих стен и перегородок с учётом их толщины.
  4. Fallback к концам placement-target, когда подходящей физической границы нет.
  5. SVG-размерные линии, засечки и существующие HTML-подписи поверх плана.
  6. Одинаковое поведение дверей, окон, ворот и открытых проходов, metric и imperial форматирование.
  7. Pure unit tests, целевой browser smoke, mutation guards, документация и release-артефакты.

5.2 Не входит

  • перемещение уже сохранённого проёма: его две legacy-подписи и centre magnet остаются как сейчас;
  • изменение snap, выбора хоста, centre magnet, jamb margin, допустимой длины, валидации, click/save, Undo/Redo или схемы конфигурации;
  • постоянные размеры в View/kiosk и задача #52;
  • размеры при resize комнаты (#233);
  • размеры до колонн, мебели, decor, устройств или фоновой картинки;
  • создание новых настроек видимости размерных подсказок.

6. Контракт выбора режима

Новая чистая функция получает уже разрешённый OpeningPlacementCandidate и не повторяет pointer hit-test/snap. По оси candidate она находит физические wallIntervals, которые:

  1. не являются open/virtual;
  2. коллинеарны оси хоста в пределах действующего геометрического epsilon;
  3. покрывают центр и оба косяка preview;
  4. имеют непустой roomId.

Уникальные roomId — владельцы комнатной стороны:

  • один владелец → комнатный режим, две размерные линии;
  • два владельца → общая стена, четыре линии;
  • владельцев нет → режим независимой перегородки, две линии;
  • больше двух или противоречивая/неполная геометрия → fail-closed fallback к двум legacy-целям; порядок rooms/config не может менять результат.

Совпадающий независимый partition поверх room wall считается комнатным хостом, если room interval доказуемо покрывает preview. Наличие candidate.host само по себе не должно лишать пользователя внутренних размеров комнаты.

7. Комнатный режим

Для каждого владельца независимо строится тот же атомарный профиль и внутренний контур, которыми innerContourForRoom() определяет чистый пол.

7.1 Внутренняя грань хоста

  1. В атомарном профиле находится физический участок, покрывающий центр candidate и коллинеарный его оси.
  2. Его внутренняя нормаль и offset задают линию внутренней грани хоста.
  3. На внутреннем контуре выбирается максимальный связный коллинеарный run этой грани, который содержит проекцию центра preview. Случайный другой коллинеарный участок в вогнутой комнате не присоединяется.
  4. Два конца run — внутренние цели. Они уже учитывают толщину соседних стен, диагональные mitre/bevel/flat-cap и атомарные перепады толщины.

От левого и правого косяков строятся точки на этой же внутренней грани. Длина равна проекции от косяка до соответствующего конца run. Отрицательный результат клампится в ноль: правила допустимости проёма не меняются, а UI никогда не показывает отрицательный «остаток».

7.2 Общая стена

Обе комнаты считаются независимо по §7.1. Получаются четыре записи:

  • левый и правый остаток по внутренней грани комнаты A;
  • левый и правый остаток по внутренней грани комнаты B.

Нельзя взять один осевой span и продублировать его четыре раза. Комнаты могут иметь разные толщины хоста и примыкающих стен, разные углы и разные конечные границы; четыре значения законно различаются.

Если хотя бы для одного заявленного room-owner невозможно доказать связный внутренний run, комнатный набор целиком заменяется двумя legacy-размерами. UI не смешивает в одном preview физические и осевые числа без объяснения.

8. Независимая перегородка

Когда комнатных владельцев нет, для каждого косяка выпускается луч вдоль оси хоста к соответствующему торцу candidate target.

Кандидаты границы:

  • физические тела room walls;
  • тела других сохранённых partitions с действующей толщиной;
  • действующие wall cuts/openings учитываются: пустой проём не выдаётся за физическую грань;
  • текущий host partition, колонки, decor/furniture/devices и virtual/open spans исключаются.

Первая точка пересечения луча с границей тела — цель размера. Поэтому на перпендикулярном T-стыке число заканчивается на ближней грани кладки, а не на оси присоединённой стены. На косом стыке используется фактическое пересечение полигона тела с лучом; вычитание thickness / 2 без угловой геометрии запрещено.

Поиск ограничен от косяка до торца самого host segment. Стена за пустым промежутком или за торцом не считается «примыкающей». Коллинеарное наложение, которое не даёт единственной поперечной физической грани, также игнорируется.

Fallback применяется по направлению: если слева найдена физическая грань, а справа нет, слева показывается физический размер, справа — расстояние до правого торца хоста. Если целей нет с обеих сторон, результат пиксельно и численно эквивалентен текущим двум подписям placement-target.

9. Модель размерной подсказки и renderer

OpMeasure расширяется либо заменяется эквивалентной структурой, в которой каждая подпись несёт геометрию измерения:

interface OpeningDimension {
  from: [number, number];
  to: [number, number];
  label: [number, number];
  distance: number;
  roomId?: string;
  roomSide?: -1 | 1;
  source: 'room-face' | 'connected-face' | 'host-end';
}

Имена/раскладка типов технические и могут измениться, но renderer обязан получать from/to, а не восстанавливать их из текста.

Для каждой записи рисуются:

  1. одна тонкая сплошная размерная линия from → to;
  2. короткие засечки в обеих конечных точках;
  3. существующая тёмная pill-подпись в середине измеряемого отрезка;
  4. на общей стене подпись дополнительно сдвигается в CSS-пикселях в сторону своей комнаты, чтобы две почти совпавшие внутренние грани не наложили текст.

Линии используют var(--hp-open)/действующий accent preview, фиксированную экранную толщину через vector-effect: non-scaling-stroke, работают в светлой и тёмной теме, имеют pointer-events: none и aria-hidden="true". Никакого нового hover/click по самим размерам нет: все 2/4 линии видны одновременно.

Нулевой размер сохраняет подпись 0 и обе конечные засечки в одной точке. Порядок DOM детерминирован: canonical left/right, затем стабильный roomId.

Существующий centre tick и magnet относятся к осевому placement-target и не переносятся на внутренний span. Поэтому после центрирования четыре физических расстояния не обязаны стать равными.

10. Один resolver для hover и click

Размеры вычисляются после единственного resolveOpeningPlacementResult() и прикрепляются к тому же immutable candidate, который видят _renderOpeningPlacementPreview() и _openingClick().

  • pointermove не пишет config/layout/history;
  • hover не становится авторитетнее click: click без предшествующего hover по- прежнему заново разрешает candidate;
  • значения, линии и preview обновляются одним render-кадром;
  • закрытие/смена инструмента/zoom очищают и числа, и линии вместе с candidate;
  • размерная геометрия не участвует в hit-test и не меняет сохраняемые x/y/angle.

11. Производительность и кеш

На каждом pointermove запрещено заново строить wallBodiesGeometry, выполнять polyclip union/difference всего плана или пересобирать внутренние контуры всех комнат.

Статический dimension-context кешируется по тому же набору причин инвалидции, что placement/wall geometry: space id, _cfgEpoch, wall index/open cuts, cell_cm и gridPitch. Он содержит:

  • room-owner intervals и готовые внутренние face-runs;
  • ограниченные boundary-body segments для independent mode;
  • стабильные ids/order, но не pointer/candidate.

На pointermove остаются только поиск владельцев, проекции и пересечения луча с закешированными сегментами. Изменение rooms/walls/partitions/openings обязано инвалидировать context в тот же epoch, что и placement preview.

Новый performance budget не вводится; ревью кода проверяет отсутствие frame-local boolean geometry по call graph. Предрелизный performance_smoke остаётся общим release-gate.

12. Данные, migration, compatibility и i18n

  • Новых persisted fields нет.
  • spaces[].rooms, walls, partitions, open_spans и openings не переписываются.
  • Миграции и compatibility-поля отсутствуют; docs/CONFIG-COMPATIBILITY.md не меняется.
  • Новых UI-строк нет. Формат чисел остаётся formatLength() с текущим metric/imperial правилом, поэтому src/i18n/en.json и ru.json не меняются.
  • View, kiosk, houseplan-space-card и backend не затронуты.

13. Touch, accessibility и безопасность

Plan editor остаётся desktop-first/best-effort на touch. Задача не добавляет жестов и не меняет safety floor: overlay pointer-transparent, поэтому pan, pinch, pointercancel и click по стене работают как раньше. View/kiosk не затронуты и остаются полностью inert относительно editor chrome.

Безопасность, HA services и entity bindings не затронуты.

14. Изменяемые файлы и модули

Ожидаемый минимум:

  • src/opening-dimensions.ts — новый pure resolver/context (допустимо другое узкое имя, но не разрастание houseplan-card.ts);
  • src/opening-placement.ts — типы measure/candidate, без изменения snap/save;
  • src/houseplan-card.ts — кеш, построение dimension-context и render wiring;
  • src/styles.ts — dimension line/tick и раскладка 2/4 labels;
  • src/wall-thickness.ts и/или src/physical-geometry.ts — только если нужен экспорт уже существующей физической primitive, без смены masonry semantics;
  • test/opening-dimensions.test.mjs — pure geometry;
  • demo/smoke_opening_inner_distances.mjs — живой Plan editor;
  • scripts/mutation-gate.mjs — guards §16;
  • docs/USER-GUIDE.ru.md, docs/USER-GUIDE.md, docs/CHANGELOG.md, docs/CHANGELOG.ru.md;
  • golden scenario/index/baseline — только по правилам §17.

Backend, manifests, i18n и schema не меняются.

15. Acceptance criteria

AC Требование Доказательство
AC1 У прямоугольной комнаты с осью стены 400 u, проёмом 80 u по центру и примыкающими стенами глубиной 20 u подписи показывают по 150 u до внутренних граней, а не legacy 160 u unit
AC2 Разные толщины/углы примыкающих стен дают цели в точках реального mitre/bevel; диагональ считается пересечением внутренних линий, не вычитанием двух half-width unit
AC3 На общей стене двух комнат resolver возвращает ровно четыре размера, два на каждой внутренней стороне; разные прилегающие стены дают четыре независимо проверяемых значения unit + smoke
AC4 Вогнутая комната не присоединяет отдельный коллинеарный участок: выбирается связный face-run, содержащий preview unit
AC5 Независимая перегородка в T/косом стыке мерит до ближней грани тела другой стены; её толщина и угол меняют число, ось другой стены целью не является unit
AC6 У независимой перегородки физическая цель только с одной стороны: там используется грань, с другой — торец host; без целей обе подписи равны legacy candidate.measure unit
AC7 Непримыкающая стена за пустым промежутком/торцом и колонка не становятся целью unit
AC8 Preview рисует 2 линии/4 засечки для одной комнаты и 4 линии/8 засечек для shared wall; каждая подпись стоит на своей линии, общий overlay pointer-inert и aria-hidden smoke
AC9 Pointermove меняет числа и from/to вместе с preview; click, save, x/y/angle/length, jamb validation и serial-placement preset остаются прежними smoke
AC10 Centre magnet/tick остаётся осевым; Shift не выключает его, а физические размеры не используются для snap smoke + ревью кода
AC11 Drag уже сохранённого проёма сохраняет две прежние подписи и не получает новые 2/4 линии smoke
AC12 Metric и imperial используют существующий formatLength; новых i18n-ключей и persisted fields нет unit + ревью кода
AC13 Светлая и тёмная темы различимы: линии/засечки имеют accent preview, labels не перекрываются на shared wall и визуально указывают измеряемый отрезок golden + ревью артефакта
AC14 На pointermove не вызываются plan-wide polyclip/boolean builders; static context переиспользуется до изменения geometry epoch unit со счётчиком/ревью кода
AC15 Оба changelog и оба USER-GUIDE обновлены в том же user-visible коммите provenance + ревью кода

16. Mutation guards

id Мутация Что должно покраснеть
opening-dimensions-use-axis-ends комнатная цель подменяется концом осевого ребра AC1/AC2 unit
opening-dimensions-collapse-shared-side две комнаты дедуплицируются в одну пару AC3 unit/smoke
opening-dimensions-use-crossing-axis independent target заканчивается на оси другой стены вместо ближней грани AC5 unit
opening-dimension-overlay-hidden renderer перестаёт рисовать линии/засечки, оставляя только числа AC8 smoke

17. План автотестов и визуальная проверка

  1. test/opening-dimensions.test.mjs: таблица AC1–AC7, включая rectangle, shared wall, concave/disconnected collinear run, T, acute-angle partition, one-sided fallback и column-negative.
  2. demo/smoke_opening_inner_distances.mjs: реальные pointer moves на room wall, shared wall и partition; DOM geometry/labels, click/save и cleanup.
  3. Регрессии: demo/smoke_opening_measure.mjs, demo/smoke_opening_preview.mjs, demo/smoke_partition_opening_jamb.mjs (фактическое имя последнего сверить с inventory).
  4. Golden: существующая сцена placement-preview либо новая минимальная сцена должна покрыть 2/4 линии в light/dark. golden:verify обязан отличить ровно заявленный overlay.
  5. Baseline не принимается разработчиком и не принимается ради зелёного гейта. Если визуальный diff требует нового эталона, принимающий шаг выполняется только npm run golden:accept -- --reviewed по полному Linux-артефакту с реальными Release: и Baseline-Reviewed: trailers; версия и ссылка не угадываются заранее.

В implementation loop по процессу выполняются только npx tsc --noEmit, npm test, npm run build и сверка трёх bundle. Browser/golden/performance — предрелизный либо reviewer gate согласно PROCESS.md/AGENTS.md.

18. Риски

  1. Четыре label на тонкой общей стене. Митигация: линии физически лежат на разных inner faces, текст получает CSS-pixel normal offset по room side.
  2. Ложная уверенность на повреждённой геометрии. Митигация: комнатный набор fail-closed целиком возвращается к двум legacy-размерам, а не смешивает несовместимые конвенции.
  3. Расхождение preview/click. Митигация: один immutable candidate; helper не принимает raw pointer и не выполняет второй snap.
  4. Pointermove/performance. Митигация: статический context по geometry epoch, только линейные пересечения на кадр.
  5. Острые углы/толстая кладка. Митигация: пересечение с реальной гранью, clamp >= 0, unit cases AC2/AC5.

19. Откат

Данные не меняются. Откат — удалить richer dimension resolver/overlay и вернуть в _resolveOpeningPlacement() две подписи openingShoulders() / candidate.measure. Сохранённые планы остаются совместимыми побайтно.

20. Release-артефакты

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md со ссылкой на #238;
  • docs/USER-GUIDE.md и docs/USER-GUIDE.ru.md: placement-preview показывает расстояния до внутренних физических граней, shared wall — четыре;
  • docs/TESTING.md: целевой smoke и ручная проверка 2/4 линий;
  • golden light/dark по §17, если матрица фиксирует изменённую сцену;
  • новых публичных скриншотов README/HACS не требуется: editor transient overlay не является основной документационной сценой;
  • отдельные security/performance артефакты не требуются, общий prerelease gate остаётся обязательным.

21. Принятые предположения

Блокирующих продуктовых вопросов нет; нижеследующее непосредственно уточняет формулировки issue и может быть оспорено владельцем до S4:

  1. «По бокам от preview» означает расстояние вдоль стены от левого/правого косяка, а не кратчайшее расстояние до произвольной границы комнаты.
  2. «Примыкающая» означает физически пересекающая ось/тело host в пределах его отрезка; ближайшая отдельная стена через пустой зазор целью не является.
  3. Все 2/4 размера видны одновременно с собственными линиями и засечками; дополнительного hover по label нет.
  4. Для независимой перегородки fallback разрешается отдельно слева/справа; для недоказуемой комнатной стороны весь набор откатывается к двум legacy-числам, чтобы не смешивать осевые и внутренние значения.
  5. Колонки исключены: issue называет стены/перегородки, а размер до колонны — отдельный пользовательский контракт.