22 KiB
#193 — превью размещения открытого проёма
- Issue: #193
- Связано: #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-геометрия:
- прямоугольный полупрозрачный сегмент на месте будущего wall cut;
- две поперечные засечки на концах сегмента;
- существующие точка привязки и линейки без изменений.
После клика временная геометрия исчезает. Сохранённый 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.5render 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
- Чистые preview-метрики возвращают точные rect/ticks для standard wall.
- Изменение
physicalHalfWidthменяет только высоту rect и длину ticks, но не ширину и x-концы. - Renderer выбирает passage-ветку только при
candidate.type === 'passage'. renderOpeningVisibleGeometry(passage)остаётся пустым.- Все 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 сохраняются:
- Чистые numeric metrics можно разместить в
opening-placement.ts, а SVG оставить в_renderOpeningPlacementPreview(). - Имена классов в ТЗ (
passage-preview-cut,passage-preview-boundary) являются рекомендуемыми, не публичным API. - Выступ засечек
gridPitch * 0.18выбран равным радиусу текущей preview-dot: он масштабируется с планом и не вводит сантиметровый config contract. - Для двух passage golden используется одна и та же thick-wall fixture и pointer, меняется только theme; точные pixel thresholds калибруются по Linux artifact так, чтобы тест падал при отсутствии rect или любой из засечек.
- При невозможном/legacy target с нулевой физической толщиной rect имеет нулевую высоту, а две засечки всё равно показывают границы длины. Искусственная «толщина стены» не придумывается, потому что это нарушило бы главный контракт совпадения с physical metrics.