Files
houseplan-card/docs/specs/193-passage-placement-preview.md
T
2026-08-19 10:44:57 +03:00

350 lines
23 KiB
Markdown
Raw 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.
# #193 — превью размещения открытого проёма
- Issue: [#193](https://github.com/Matysh/houseplan-card/issues/193)
- Связано: [#157](https://github.com/Matysh/houseplan-card/issues/157)
- Приоритет: P2
- Ветка: `issue/193-passage-preview`
- Статус документа: первая редакция, готова к ревью ТЗ
- Основание: описание владельца и аналитика от 2026-08-19
## 1. Пользовательский сценарий
Персона — home admin. В desktop Plan editor он выбирает инструмент «Открытый
проём» и ведёт указателем вдоль стены, чтобы до клика выбрать место будущего
прохода.
Сейчас администратор видит точку привязки и размерные линейки, но не видит длину
участка стены, который исчезнет. Дверь, окно и ворота показывают свой символ, а
единственный визуальный результат passage появляется только после клика.
## 2. Что человек увидит
До изменения инструмент показывает только точку и размеры; после изменения
поверх стены заранее виден полупрозрачный участок будущего разрыва с двумя
засечками его границ.
## 3. Проблема и ожидаемый результат
`_renderOpeningPlacementPreview()` уже строит единый preview по разрешённому
placement-candidate. Для `passage` общий `renderOpeningVisibleGeometry()`
намеренно возвращает пустой SVG, потому что сохранённый открытый проём является
отрицательным пространством и не имеет архитектурного символа. Правильное
решение #157 в слое постоянного рендера делает placement-инструмент слепым.
После #193 только при размещении нового `passage` поверх стены рисуется временная
editor-геометрия:
1. прямоугольный полупрозрачный сегмент на месте будущего wall cut;
2. две поперечные засечки на концах сегмента;
3. существующие точка привязки и линейки без изменений.
После клика временная геометрия исчезает. Сохранённый passage по-прежнему виден
только как реальный разрыв кладки и пол внутри него.
## 4. Подтверждённая техническая база
- `_openingPreview` и `_openingClick()` используют один
`OpeningPlacementCandidate`; hover не требует повторного snap.
- Candidate содержит `x`, `y`, `angle`, `renderedLength` и
`target.physicalHalfWidth`.
- `physicalHalfWidth` вычисляется `openingPlacementTargets()` из тех же
`wallIntervals` и partition intervals, которыми определяется выбранное
физическое тело стены. Для совпадающих room-owned копий берётся максимальная
реальная половина толщины.
- Сохранение записывает длину из того же candidate; после сохранения masonry
строит passage cut из канонической opening geometry.
- Placement preview уже находится после wall bodies в SVG-порядке, поэтому
новая геометрия будет видна поверх кладки без нового слоя.
- `.opening-preview` и `.opening-preview-dot` уже имеют `pointer-events: none`,
а группа помечена `aria-hidden="true"`.
- `_opMeasureView` уже отдаёт `candidate.measure`, поэтому линейки не требуют
изменений.
- Drag существующего opening — отдельный pipeline. Он live-обновляет сохранённую
opening geometry и masonry cut; placement-preview там не используется.
- Golden-матрица содержит door placement-preview на толстой стене, но harness
сейчас валидирует только типы с `.op-leaf` и не принимает `passage`.
## 5. Scope
### 5.1 Геометрия placement-preview
Для `candidate.type === 'passage'` вместо общего архитектурного символа строится
специальная preview-геометрия в уже существующей transform-группе:
- локальный центр: `(0, 0)`;
- ширина прямоугольника: ровно `candidate.renderedLength`;
- высота: ровно `candidate.target.physicalHalfWidth * 2`;
- локальный `x`: `-candidate.renderedLength / 2`;
- локальный `y`: `-candidate.target.physicalHalfWidth`;
- поворот и перенос: существующий group transform из `candidate.angle/x/y`;
- засечки расположены при `x = ±candidate.renderedLength / 2` и идут
перпендикулярно стене;
- каждая засечка проходит через всю фактическую толщину и выступает за обе
границы стены на `gridPitch * 0.18`.
Числа не вычисляются повторно из preset или сохранённого конфига. Разрешён
маленький чистый helper метрик в `opening-placement.ts`, если он одновременно
используется рендером и unit-тестом.
### 5.2 Визуальный контракт
- сегмент использует текущий `--wall-fill` с fallback текущего wall renderer;
- эффективная opacity сегмента — `0.35`;
- засечки используют `var(--hp-open, #ff9800)` и толщину `2.5` render units,
совпадающую с существующими jamb marks opening renderer;
- passage preview не наследует общую opacity `0.5` второй раз: итоговая
прозрачность сегмента должна остаться `0.35`, а не `0.175`;
- геометрия не анимируется;
- точка, линейки, cursor и toolbar state не меняются;
- light/dark theme получают свои существующие wall/open CSS variables без
отдельной палитры и без новых пользовательских настроек.
### 5.3 Проверки и документация
- unit-тест точных локальных метрик для обычной и нестандартной толщины стены;
- source/negative contract, что passage-ветка изолирована от других типов;
- обновление `demo/smoke_opening_preview.mjs`;
- type-aware поддержка passage в golden harness;
- отдельные passage placement golden-сценарии на толстой стене в dark и light;
- обновление `docs/TESTING.md`, RU/EN user guide и обоих changelog.
## 6. Non-scope
- постоянный символ, рамка, створка, дуга или засечки у сохранённого passage;
- изменение фактической masonry/tunnel/light geometry #157;
- изменение placement, snap, center magnet, ruler labels или default 90 см;
- drag-overlay для существующего passage: во время drag реальный wall cut уже
перемещается live;
- изменение preview двери, окна или ворот;
- новые настройки цвета, opacity или размера засечек;
- backend, schema, config, migration, import/export и compatibility fields;
- новый i18n-текст;
- публичность скрытой изометрии;
- расширение touch-гарантий Plan editor.
**Touch editor:** best effort / intentionally degraded. Preview наследует
существующий статус Plan editor: это presentation-only hover-эффект, который не
расширяет touch-гарантии.
## 7. Контракт поведения
### 7.1 Появление
Preview существует только когда одновременно выполнены условия:
- активен существующий opening placement tool;
- выбран preset `passage`;
- `_openingPreview` разрешил валидный candidate на физической стене или
partition;
- указатель не находится над существующим opening и не открыт properties dialog.
Вне валидной стены preview отсутствует по нынешнему fail-closed поведению.
### 7.2 Hover и клик
Один resolved candidate является authority для preview и следующего клика.
Отрисованный центр, угол и длина не могут расходиться с данными, которые затем
попадут в properties dialog. После клика `_openingHoverCandidate` и `_cursorPt`
очищаются существующим кодом, поэтому cut-preview, засечки, точка и линейки
исчезают вместе.
### 7.3 После сохранения
Preview-only классы не появляются внутри `.opening[data-kind="passage"]`.
`renderOpeningVisibleGeometry({type:'passage'})` продолжает возвращать пустой
результат. Пользователь видит канонический wall cut, а не продублированный
полупрозрачный прямоугольник.
### 7.4 Остальные типы
`window`, `door` и `gate` продолжают вызывать только
`renderOpeningVisibleGeometry(visibleSpec)`. Их DOM signature, opacity,
геометрия и existing golden остаются неизменными; passage-классы в их preview
отсутствуют.
### 7.5 События и доступность
Группа остаётся `aria-hidden="true"` и `pointer-events="none"`; preview не
получает role/tabindex/handlers. Дочерние rect/line также явно не становятся
hit-test targets. Существующий click, hover, pan и ruler pipeline не меняется.
## 8. UX-состояния
| Состояние | Результат |
|---|---|
| Passage над валидной room wall | сегмент реальной длины и толщины, две засечки, точка, линейки |
| Passage над валидной partition | тот же контракт с толщиной выбранной partition |
| Passage вне стены / над open span / над column | preview отсутствует по текущим правилам |
| Passage над существующим opening | preview отсутствует, клик редактирует существующий объект |
| Properties dialog открыт | preview отсутствует |
| Сохранённый passage в Plan/View/Static/iso | никакого нового символа; только существующий физический разрыв |
| Door/window/gate placement | прежний preview без passage-сегмента и passage-засечек |
## 9. Модель данных и миграция
Модель данных не меняется. Новых ключей конфига, runtime-state, local storage,
backend schema и migration step нет. Preview вычисляется только для текущего
кадра из уже существующего transient candidate.
## 10. i18n
Новых строк нет. Название `passage`, подсказки инструмента и ruler formatting
остаются из #157 и общего opening pipeline. RU/EN JSON не меняются.
## 11. Критерии приёмки
### AC1 — точная геометрия passage preview
При валидном passage-candidate preview содержит один cut-сегмент и ровно две
boundary-засечки. Ширина сегмента равна `renderedLength`, высота равна
`2 * target.physicalHalfWidth`, центр и угол равны candidate; засечки стоят на
обоих концах и выступают на `gridPitch * 0.18` с каждой стороны.
**Доказательство:** unit точных чисел минимум для двух wall thickness + browser
smoke по SVG attributes + dark/light golden ручного вида.
### AC2 — preview и сохранение используют один candidate
Клик после hover открывает dialog с теми же `x/y/angle/length`, которые были у
отрисованного preview. После сохранения длина канонической записи соответствует
этому candidate, а preview-only rect/lines отсутствуют в committed opening.
**Доказательство:** `smoke_opening_preview.mjs`, продолжающий существующую
проверку `saveMatchesResolver` и добавляющий signature passage-preview до/после.
### AC3 — сохранённый passage не получает символ
Общий renderer passage по-прежнему не выдаёт jamb/leaf/arc/glass/frame и не
выдаёт новые preview-only элементы. Plan/View/Static/iso semantics #157 не
изменяются.
**Доказательство:** существующий `opening-symbol.test.mjs` + расширенный
source contract + smoke committed DOM.
### AC4 — другие типы не меняются
Door, window и gate preview не проходят через passage-геометрию, сохраняют свои
existing visible signatures и не содержат passage preview classes.
**Доказательство:** negative unit/source contract + существующие и расширенные
smoke assertions + неизменный door golden.
### AC5 — overlay не меняет взаимодействие и линейки
Passage preview, его rect/lines и точка не участвуют в hit-test и accessibility
tree. `_opMeasureView` продолжает показывать обе ruler labels и center guide по
тем же данным; pointer click проходит в существующий placement handler.
**Доказательство:** source/unit contract атрибутов + browser smoke двух ruler
labels, `pointer-events: none`, `aria-hidden`, успешного click/save.
### AC6 — визуальная тема и фактическая толщина
В dark и light segment берёт theme wall fill и сохраняет эффективную opacity
`0.35`; засечки используют `--hp-open`. На нестандартной толстой стене segment
совпадает с физической шириной тела, а засечки видны за обоими краями.
**Доказательство:** computed-style smoke + две type-aware golden-сцены на одной
толстой wall fixture; semantic pixel guard обязан доказать changed pixels внутри
фактического `.wallbody-fill`, а не только рядом со стеной.
## 12. План автотестов
### Unit/source
1. Чистые preview-метрики возвращают точные rect/ticks для standard wall.
2. Изменение `physicalHalfWidth` меняет только высоту rect и длину ticks, но не
ширину и x-концы.
3. Renderer выбирает passage-ветку только при `candidate.type === 'passage'`.
4. `renderOpeningVisibleGeometry(passage)` остаётся пустым.
5. Все preview-only элементы остаются внутри non-interactive aria-hidden group.
Тесты должны быть falsifiable: на `origin/dev` до реализации как минимум unit
геометрии и smoke DOM обязаны краснеть из-за отсутствующей preview-геометрии.
### Smoke
`demo/smoke_opening_preview.mjs` проверяет:
- наличие rect + двух ticks у passage hover;
- SVG metrics против resolved candidate;
- computed fill/opacity/stroke variables;
- сохранение dot и ruler labels;
- отсутствие passage preview classes у window/door/gate;
- исчезновение preview при dialog/save/leaving tool;
- отсутствие preview-only symbol у committed passage.
### Golden
- расширить allowlist `openingPreview.type` литералом `passage`;
- type-specific semantic selector: `.op-leaf` для прежних типов,
`.passage-preview-cut` + две `.passage-preview-boundary` для passage;
- добавить `opening-placement-passage-thick-wall-dark` и
`opening-placement-passage-thick-wall-light` на `golden-geometry`;
- обе сцены используют semantic `openingPreviewPixels`: preview обязан менять
достаточное число пикселей и красить пиксели внутри реальной wall fill;
- существующий door scenario и его baseline не меняются;
- baseline acceptance не входит в implementation loop и выполняется только по
release-процессу после Linux review artifact.
### Реализационные гейты
В цикле реализации: `npm run typecheck`, `npm test`, `npm run build`.
Перед `S7-code-review`: затронутый smoke и целевые golden capture/verify по
актуальному процессу. Полный golden/smoke/performance остаётся pre-beta gate.
## 13. Риски и меры
| Риск | Мера |
|---|---|
| Preview длиной отличается от будущего cut | использовать только `candidate.renderedLength`, не preset/dialog |
| Нестандартная partition/wall толщина игнорируется | использовать только `target.physicalHalfWidth` |
| Opacity применяется дважды | отдельная passage group/style с проверкой computed effective value |
| Геометрия оказывается под стеной | сохранить текущую позицию preview layer после wall bodies + semantic inside-wall pixels |
| Passage случайно получает постоянный символ | preview-only branch остаётся у caller, общий renderer passage остаётся пустым |
| Door/window/gate меняют DOM | явная type branch и negative contracts |
| Golden существует, но не доказывает содержимое | type-specific selector и changed-inside-wall semantic assertion |
| Preview начинает ловить pointer | group и children non-interactive, smoke click/save |
Производительность: максимум три простых SVG-элемента только во время валидного
hover. Новых geometry passes, observers, subscriptions, cache keys и animation
нет; отдельный performance profile не требуется.
## 14. Откат
Откат удаляет passage-only preview branch/helper/styles, новые assertions и
golden-сценарии. Модель данных и сохранённые планы не затронуты; после отката
возвращается прежний слепой preview с точкой и линейками, а #157 продолжает
работать без migration/downgrade действий.
## 15. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в user-visible implementation
commit;
- RU/EN user guide: размещение passage заранее показывает ширину будущего
разрыва;
- `docs/TESTING.md`: unit/smoke/golden proof;
- синхронные `dist`, integration frontend и demo bundle;
- golden candidates только для двух новых passage preview scenarios; baseline
acceptance — позднее по release runbook;
- issue остаётся открытой до выпуска беты.
## 16. Принято предположительно, поменять свободно
Ниже технические, а не продуктовые решения; ревьюер может изменить их без
вопроса владельцу, если AC сохраняются:
1. Чистые numeric metrics можно разместить в `opening-placement.ts`, а SVG
оставить в `_renderOpeningPlacementPreview()`.
2. Имена классов в ТЗ (`passage-preview-cut`,
`passage-preview-boundary`) являются рекомендуемыми, не публичным API.
3. Выступ засечек `gridPitch * 0.18` выбран равным радиусу текущей preview-dot:
он масштабируется с планом и не вводит сантиметровый config contract.
4. Для двух passage golden используется одна и та же thick-wall fixture и
pointer, меняется только theme; точные pixel thresholds калибруются по Linux
artifact так, чтобы тест падал при отсутствии rect или любой из засечек.
5. При невозможном/legacy target с нулевой физической толщиной rect имеет нулевую
высоту, а две засечки всё равно показывают границы длины. Искусственная
«толщина стены» не придумывается, потому что это нарушило бы главный контракт
совпадения с physical metrics.