mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -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.
|
||||
Reference in New Issue
Block a user