mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
407 lines
31 KiB
Markdown
407 lines
31 KiB
Markdown
# Issue #238 — Размеры проёма до внутренних физических границ
|
||
|
||
- Дата: 2026-08-22
|
||
- Тип: feature · приоритет P2 · пользовательская ценность 8/10
|
||
- Сложность 7/10 · риск 7/10 · обычный трек
|
||
- Issue: [#238](https://github.com/Matysh/houseplan-card/issues/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` расширяется либо заменяется эквивалентной структурой, в которой
|
||
каждая подпись несёт геометрию измерения:
|
||
|
||
```ts
|
||
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 называет стены/перегородки, а размер до колонны —
|
||
отдельный пользовательский контракт.
|