From fc22d9a6c55b085dc273a70bdc8e2db22ec9fe19 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Wed, 19 Aug 2026 10:34:58 +0300 Subject: [PATCH] Specify passage placement preview Issue: #193 User-Visible: no --- docs/specs/193-passage-placement-preview.md | 345 ++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/specs/193-passage-placement-preview.md diff --git a/docs/specs/193-passage-placement-preview.md b/docs/specs/193-passage-placement-preview.md new file mode 100644 index 00000000..b71747a3 --- /dev/null +++ b/docs/specs/193-passage-placement-preview.md @@ -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.