Specify passage placement preview

Issue: #193
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-08-19 10:34:58 +03:00
parent fbbaed22de
commit fc22d9a6c5
+345
View File
@@ -0,0 +1,345 @@
# #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.
## 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.