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

23 KiB
Raw Permalink Blame History

#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-геометрия:

  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.