Files
houseplan-card/docs/specs/075-076-opening-placement-flow.md
T
Matysh 9e74051652 Release v1.62.0-beta.8 candidate
Issue: #75
Issue: #76
Issue: #95
User-Visible: yes
2026-08-12 19:18:54 +03:00

35 KiB
Raw Blame History

Единый поток размещения проёмов: выбор типа и архитектурный preview

Связанные задачи: #75, #76.

Статус ТЗ: реализовано и локально отревьюено; ожидает prerelease/CI gate. ТЗ прошло ревью против текущих placement, wall-thickness, opening renderer, command stack и общей editor sub-panel. Открытых продуктовых решений перед реализацией нет.

1. Контекст и проблема

Сейчас кнопка «Проём» сразу включает единый инструмент размещения, а тип объекта выбирается только после клика по стене. Hover-preview при этом представляет собой условную пунктирную линию фиксированной длины и центральную точку. Такой поток не даёт до клика понять, будет создано окно, дверь или ворота, и не показывает реальную архитектурную геометрию объекта.

Отдельный воспроизводимый дефект: на стенах с толщиной текущий preview может полностью или частично скрываться телом стены либо не появляться при наведении на видимую часть стены, удалённую от её оси. Пользователь кликает практически вслепую.

Задачи #75 и #76 являются одним пользовательским сценарием и выпускаются вместе:

  1. пользователь открывает меню «Проём»;
  2. выбирает окно, дверь или ворота;
  3. видит на стене полный полупрозрачный символ выбранного типа;
  4. кликает по валидной позиции;
  5. при необходимости уточняет свойства в существующем диалоге;
  6. сохраняет объект и может продолжить серийное размещение.

2. Цели

  1. Сделать тип будущего проёма явным до начала размещения.
  2. Показывать до клика форму, ширину, ориентацию и сторону открывания будущего объекта.
  3. Полностью устранить невидимость preview на толстых физических стенах.
  4. Гарантировать единый детерминированный resolver для hover, click и начального состояния диалога без зависимости click от обязательного предварительного hover.
  5. Переиспользовать существующую editor sub-panel и геометрию сохранённых openings.
  6. Не менять модель данных, backend API, migrations и семантику сохранённых проёмов.

3. Не входит в задачу

  • новые типы и разновидности окон, дверей или ворот;
  • размещение проёмов в перегородках и колоннах;
  • изменение существующих правил fit, overlap и минимальной допустимой длины: эта задача не вводит новый запрет на выступающий за границы сегмента проём и новую проверку пересечения проёмов;
  • live-preview изменений за открытым модальным диалогом;
  • preview выреза кладки, заливки тоннеля, Glow, солнечных лучей или площади до Save;
  • сохранение последнего типа/размера в config, layout, backend или localStorage;
  • полноценный touch-first hover UX;
  • изменение рисунка уже сохранённых openings;
  • новый command stack: история появляется только после успешного Save по текущим правилам.

4. Нормативный пользовательский поток

4.1. До выбора типа

  • Основная кнопка «Проём» является launcher существующей общей суб-панели.
  • Нажатие launcher только открывает или закрывает меню и само по себе не меняет _tool, не вызывает _cancelPath(), не создаёт preview и не пишет данные.
  • Пока пользователь в текущем входе в Plan editor явно не выбрал тип, новый opening placement не активен.
  • Надпись основной кнопки всегда остаётся «Проём».

4.2. Выбор типа

В суб-панели находятся ровно три действия:

Кнопка OpeningCfg.type Начальная ширина Базовая геометрия
Окно window 120 см существующий символ окна
Дверь door 90 см одна створка и существующая дуга
Ворота gate 300 см две створки, открытые наружу на 10°

После выбора:

  1. применяется существующий контракт переключения инструмента: незавершённая операция предыдущего инструмента очищается/сохраняется как draft ровно так же, как при выборе любого другого инструмента; новая логика не делает скрытый auto-save;
  2. создаётся единый session-only preset проёма;
  3. активируется _tool = 'opening';
  4. суб-панель закрывается;
  5. placement ждёт первый новый pointer move и затем показывает preview.

Начальные flip_h и flip_v равны false. Выбор типа не создаёт config mutation, history command или save request.

4.3. Hover и клик

  • Валидный hover показывает полный символ выбранного типа и вспомогательные измерения.
  • Hover и click вызывают один pure resolver с одинаковыми geometry inputs, preset и tie-break правилами.
  • Click всегда является авторитетным: он разрешает candidate в координатах самого click. Если кэшированный hover-candidate имеет те же pointer coordinates (в принятом epsilon), geometry/config revision и preset revision, разрешено повторно использовать именно его; иначе candidate пересчитывается. Наличие предварительного hover не требуется.
  • Полученный click-candidate открывает существующий диалог создания.
  • После клика preview и pointer hints исчезают; за диалогом не остаётся ghost.
  • Если между последним pointer move и click геометрия/config revision изменилась, candidate пересчитывается. Невалидный результат не открывает диалог и использует существующее локализованное сообщение об ошибке. Старый preview не считается разрешением на создание.

4.4. Диалог, Save и Cancel

  • Диалог нового объекта открывается с типом, шириной, позицией, углом и flip-флагами показанного preview.
  • В диалоге по-прежнему можно изменить тип, ширину и flip-настройки. Live-preview за диалогом не появляется.
  • Изменения в диалоге относятся только к текущему объекту. Toolbar preset остаётся тем, который пользователь явно выбрал в суб-панели.
  • Save, кнопка «Отмена», крестик диалога и Escape закрывают диалог по существующему контракту; только Save записывает объект/history command.
  • После Save или любого отменяющего закрытия placement остаётся активным с исходным toolbar preset, позволяя последовательно размещать объекты одного типа.
  • После закрытия диалога новый preview появляется только после следующего pointer move; устаревший candidate не восстанавливается автоматически.
  • Чтобы изменить тип серии, пользователь повторно открывает «Проём» и выбирает другой пункт.

4.5. Существующие openings

  • Наведение на существующий opening не показывает второй preview.
  • Клик по существующему opening имеет приоритет и сразу открывает его редактирование.
  • Редактирование существующего opening не открывает суб-панель и не меняет toolbar preset.
  • Существующие type, размеры, flip, contact и lock не мигрируют и не нормализуются.

5. Состояния интерфейса

Состояние Launcher Суб-панель Preview Клик по валидной стене
Другой инструмент неактивен закрыта нет работает текущий инструмент
Меню открыто, тип не выбран открыт три типа нет outside-click только закрывает меню
Placement активен активен закрыта по валидному hover открывает диалог создания
Меню поверх placement активен открыта текущий preset сохранён outside-dismiss не создаёт opening
Диалог создания открыт активен blocked нет canvas недоступен
Редактирование existing без изменения blocked нет canvas недоступен
Смена редактора/пространства сброшен закрыта нет placement прекращён

Открытие меню поверх другого инструмента не отменяет его draft. Отмена происходит только после явного выбора одного из типов проёма.

6. UI-only preset и candidate

6.1. Preset

Рекомендуемый минимальный тип:

type OpeningPlacementPreset = {
  type: 'window' | 'door' | 'gate';
  lengthCm: number;
  flipH: boolean;
  flipV: boolean;
  revision: number;
};
  • Preset существует только в памяти экземпляра карточки.
  • revision меняется при выборе типа и не допускает использования старого candidate.
  • Дефолты определяет один resolver/таблица. Нельзя независимо держать door/90, window/120, gate/300 в toolbar, preview и диалоге.
  • Выход из Plan editor сбрасывает placement. Возвращение не включает opening tool.

6.2. Candidate

Hover и click используют одну структуру, например:

type OpeningPlacementCandidate = {
  presetRevision: number;
  geometryRevision: number;
  pointer: [number, number];
  type: 'window' | 'door' | 'gate';
  lengthCm: number;
  flipH: boolean;
  flipV: boolean;
  x: number;
  y: number;
  angle: number;
  renderedLength: number;
  target: {
    segmentKey: string;
    axisA: [number, number];
    axisB: [number, number];
    physicalHalfWidth: number;
    sourceOrder: number;
  };
  renderSpec: OpeningVisibleGeometrySpec;
  measure: OpMeasure | null;
};

Имена полей не нормативны. target — временная identity выбранного производного сегмента только для текущего geometry revision. Она не сохраняется в OpeningCfg: у текущей модели проём остаётся привязан абсолютными x/y/angle, а постоянного wall id или edge index в данных нет. physicalHalfWidth берётся из локально разрешённого wall profile, а не из одной «толщины стены»: на одной оси могут встречаться разные интервалы толщины.

Нормативно наличие одного resolved result, из которого строятся preview и _openingDialog. Resolver чист относительно config/layout: не пишет данные, не запускает save, не создаёт command и не заполняет committed opening/tunnel caches. renderSpec содержит уже разрешённую видимую геометрию; preview не должен независимо повторять выбор грани стены и получить другую сторону на общем сегменте.

7. Валидность и hit-testing

Preview существует только там, где тот же click разрешает создать opening.

7.1. Допустимые поверхности

  • физические стены контуров комнат;
  • внешние и общие физические стены;
  • стены нулевой и ненулевой толщины, включая максимальную допустимую настройками;
  • горизонтальные, вертикальные, 45° и произвольные диагональные сегменты.

7.2. Недопустимые поверхности

  • виртуальная граница/open span;
  • перегородка;
  • квадратная или круглая колонна;
  • hit-area существующего opening: он имеет приоритет и открывает редактирование;
  • область вдали от подходящей стены.

Текущая реализация не запрещает частичный выход широкого проёма за концы выбранного сегмента и не выполняет общую overlap-проверку. Resolver этой задачи обязан сохранить это поведение: отсутствие shoulder measurements не делает candidate невалидным, ширина не уменьшается и тип не подменяется. Новые fit/overlap-правила требуют отдельного ТЗ.

7.3. Толстые стены

Видимое тело толстой стены является частью hit-area. Для каждого производного физического segment hit-envelope равен максимуму из существующего placement tolerance и local physical half-width + pointer tolerance. Расстояние от pointer до оси не должно отбрасывать candidate, пока pointer находится в этом envelope. Envelope не расширяется на соседние виртуальные интервалы, перегородки или колонны.

Resolver проецирует pointer на каноническую ось физической стены, затем применяет существующие along-wall grid snap, centre magnet и endpoint-clamp rules. Это не расширяет набор допустимых объектов: перегородки и колонны не становятся стенами комнат.

На углах, общих стенах и пересечении hit-envelopes выбор не зависит от SVG paint order. Проекция должна попадать в ограниченный segment с существующим endpoint tolerance. Среди допустимых целей сначала сравнивается дистанция до ограниченного segment, затем перпендикулярная дистанция до его оси, затем канонический segment key и стабильный sourceOrder производных room edges. Точно совпадающие общие границы дедуплицируются по каноническому segment key до выбора candidate.

Правило закрывает оба источника исходного дефекта:

  1. preview не вычислялся над внешней/внутренней частью широкого wall body;
  2. вычисленный preview рисовался под кладкой и визуально исчезал.

7.4. Детерминированность

  • В углу/стыке выбирается один и тот же segment для hover и click.
  • Над existing opening hit существующего объекта имеет приоритет.
  • При одинаковых pointer/preset/geometry inputs hover и click возвращают эквивалентный resolved candidate; click без hover работает самостоятельно.
  • Ворота 300 см не подменяются дверью и не уменьшаются, в том числе на коротком сегменте.

8. Архитектурный preview

8.1. Общий renderer

Нельзя создавать отдельную упрощённую дверь рядом с committed renderer. Видимая геометрия собирается общим pure helper из подготовленного render spec:

  • committed — обычные visible paths плюс отдельные interaction/live wrappers;
  • preview — те же jambs, leaf/leafs, arc, orientation и face offsets без interaction.

Preview использует состояние только что созданного opening без привязанной сущности: дверь и ворота показываются открытыми, окно — закрытым, как сразу после Save. Он не читает HA state и не создаёт фиктивные contact/lock bindings.

Committed-only .op-hit, contact/lock badges, live state, data attributes и обработчики остаются снаружи общего visible-geometry helper.

8.2. Внешний вид

  • Preview-группа имеет класс .opening-preview.
  • Итоговая opacity всей архитектурной группы — 0.5.
  • Цвет — editor accent/существующий --hp-open, читаемый в обеих темах.
  • Opacity задаётся группе, а не каждому path отдельно.
  • Группа рисуется поверх тела физической стены, включая штриховку, но ниже measurement badges и editor hints.
  • Нет blur, filter, mix-blend-mode и догоняющей анимации.
  • Preview немедленно следует за pointer и масштабируется вместе с архитектурой.
  • pointer-events: none, aria-hidden="true", без tabindex, .op-hit, data-hp и data-id сохранённого объекта.

8.3. Толстая стена

Preview показывает те же каноническую ось, грань, face offset, глубину jambs, створки и дуги, что committed symbol после Save. Тело стены не перекрывает ни базовую линию, ни створку, ни jambs preview.

Для общей стены сохраняется существующее детерминированное правило выбора стороны из opening wall association. Задача не переопределяет понятие «наружу» для общей стены: важно, чтобы preview и committed renderer выбрали одну сторону на одном geometry revision.

Preview не вырезает кладку и не создаёт tunnel fill. Он осознанно лежит поверх ещё цельного тела стены до Save.

8.4. Вспомогательные элементы

Без понижения контраста сохраняются:

  • центральная snap-точка;
  • две линейки/shoulder badges до краёв выбранной room edge;
  • perpendicular magnet tick;
  • текущие formatter метрических и imperial единиц.

Они используют тот же candidate, но находятся вне группы opacity 0.5.

9. Суб-панель «Проём»

  • Использовать EditorToolbarGroup и существующий EditorSecondaryController.
  • В DOM остаётся один .editor-secondary-host.
  • Не создавать новый popover, dialog или дополнительную строку toolbar.
  • Открытие/закрытие не меняет высоту editor chrome и stage.
  • Launcher использует aria-expanded/aria-controls общего компонента.
  • Активный preset отражается через aria-pressed и общий active style.
  • ArrowDown на launcher открывает меню; внутри группы работают ArrowLeft/ ArrowRight, Home/End и нативные Enter/Space. Escape обрабатывается общим верхнеуровневым keyboard-контрактом и закрывает только меню.
  • Escape восстанавливает фокус на launcher. Outside-dismiss закрывает меню, не крадёт фокус у целевого элемента и не пропускает тот же click на canvas. Оба действия не меняют preset и не создают opening.
  • Выбор пункта закрывает группу до вызова action. После keyboard-выбора focus не должен оставаться на удалённом DOM-узле: он возвращается на launcher; pointer-выбор не перехватывает последующее наведение на stage.
  • Смена/закрытие редактора имеет приоритет над dismiss меню.
  • Все пункты имеют полноценные touch targets; touch-редакторы остаются best effort.

10. Lifecycle и очистка

Candidate, preview и hints полностью очищаются при:

  • click и открытии диалога;
  • Save/Cancel/закрытии диалога;
  • уходе pointer от валидной стены;
  • наведении на existing opening;
  • выборе другого типа до пересчёта candidate;
  • выборе другого инструмента;
  • Escape при закрытом меню: очищает candidate/preset и переводит Plan editor в его текущий нейтральный инструмент draw, как существующий opening tool;
  • смене редактора/пространства или config revision;
  • pointerleave stage, а также pointercancel/lostpointercapture активного pointer gesture;
  • начале pan/pinch;
  • размонтировании карточки.

Быстрое движение между стенами не создаёт более одной preview-группы и не оставляет DOM-остатков предыдущего типа/позиции.

11. Совместимость и данные

  • OpeningCfg, сериализация, backend API и model version не меняются; миграции нет.
  • Старые openings рендерятся и редактируются без нормализации.
  • Config/layout revision меняется только существующим Save диалога.
  • Новый opening становится одним именованным geometry command по текущему контракту.
  • Undo/Redo сохранённых openings не меняется.
  • hide_openings: true не скрывает placement preview в Plan editor.
  • show_borders: false не мешает preview допустимой физической стены.
  • Preview не участвует в площади, Resize, Glow, солнце, barriers и room fill.

12. Edge-case matrix

Случай Ожидаемое поведение
Тонкая физическая стена Полный preview по оси стены
Толстая физическая стена Hit по всему телу; preview виден поверх кладки
Общая стена комнат Один deterministic candidate, без двойного preview
Внешняя стена Корректный face offset и направление символа
Виртуальная граница Preview отсутствует, создание запрещено
Перегородка/колонна Preview отсутствует
0°/45°/90°/любой угол Preview и committed geometry совпадают
Угол или Т-стык Hover и click выбирают один segment
Над existing opening Редактирование existing, без ghost
Ворота шире segment Сохраняется текущий permissive-контракт: preview/создание доступны, тип и 300 см не меняются
hide_openings Placement preview всё равно виден
Меню закрыто без выбора Старый tool/preset не меняется
Смена типа при ghost Старый ghost удалён, candidate пересчитан
Пространство без комнат Preview отсутствует, console чистая
Imperial UI Internal см каноничны, подписи в ft/in
Zoom 0.4×/fit/2.5× Пропорции и hit-testing согласованы
Mouse/trackpad/pen hover Preview обязателен
Первый click/tap без hover Candidate разрешается по координатам события, диалог открывается без pre-tap preview
Pointer покинул stage Preview и hints немедленно очищены

13. План реализации

Этап A — placement state и меню (#76)

  1. Добавить одну таблицу defaults и session-only preset.
  2. Описать группу «Проём» через _editorToolbarGroups только для Plan editor.
  3. Превратить текущую кнопку в launcher без прямого переключения _tool.
  4. Подключить выбор window/door/gate, lifecycle и серийное размещение.
  5. Перевести инициализацию _openingDialog на preset/candidate.

Этап B — candidate parity и renderer (#75)

  1. Вынести pure candidate resolver hover/click.
  2. Учесть полное тело толстой стены в hit-area и проекцию на ось.
  3. Выделить общий renderer видимой архитектурной геометрии.
  4. Заменить .opghost полным .opening-preview выбранного типа.
  5. Зафиксировать слои, очистку и отсутствие побочных эффектов.

Этапы можно разнести для code review, но не выпускать как два пользовательских релиза: готовой считается только совместная поставка A+B.

14. Тестирование

По принятому процессу тесты создаются с реализацией, но запускаются перед пре-релизом.

14.1. Unit

  • defaults resolver: window/120, door/90, gate/300, flip false;
  • candidate parity hover/click;
  • rejection virtual span, partition, column и точки вне hit-envelope;
  • permissive regression: широкий opening на коротком segment не меняет type/length;
  • click без предварительного hover;
  • deterministic junction selection;
  • hit по оси и обеим видимым половинам толстой стены;
  • candidate → dialog draft без потери координат.

14.2. Browser smoke

Расширить или создать demo/smoke_opening_preview.mjs:

  • launcher открывает kind-group, не меняет tool и не создаёт ghost;
  • один shared host, stage не меняет высоту;
  • каждый тип создаёт правильный preset;
  • .opening-preview содержит полный symbol выбранного типа;
  • opacity/color/pointer-events/aria и отсутствие committed contract;
  • preview видим на тонкой, толстой и диагональной стене;
  • pointer над обеими половинами толстого wall body даёт preview;
  • при одинаковом input preview/click/dialog/Save совпадают по type, width, x/y/angle/flip/face offset;
  • первый click/tap без hover создаёт тот же dialog draft;
  • invalid target и existing opening не показывают ghost;
  • Save/Cancel сохраняют serial preset;
  • lifecycle удаляет preview;
  • keyboard/outside-dismiss/focus restore соответствуют общей суб-панели.

Регрессионный набор: smoke_opening_measure, smoke_inert_openings, smoke_grid_snap, smoke_wall_thickness, smoke_opening_tunnel_fill, editor tray и geometry history.

14.3. Golden

Добавить детерминированную сцену Plan editor:

  • dark theme;
  • толстая физическая стена;
  • полупрозрачная дверь на 45° с hints;
  • окно и ворота отдельными состояниями либо в одной композиционной сцене;
  • семантический assertion подтверждает цветные пиксели preview поверх wall-body, а не только существование DOM-узла.

Golden обязан падать, если preview снова окажется полностью закрыт толстой стеной.

14.4. Ручная проверка

  • светлая/тёмная тема и узкий desktop viewport;
  • mouse и trackpad;
  • горизонтальная, вертикальная, диагональная, внешняя и общая толстая стена;
  • серия из трёх объектов и смена типа через меню.

15. Release-артефакты

  • docs/CHANGELOG.md и docs/CHANGELOG.ru.md со ссылками на #75/#76;
  • обновление раздела Plan editor/проёмы в docs/USER-GUIDE.ru.md;
  • RU/EN i18n parity для launcher, группы и пунктов;
  • принятый golden baseline;
  • закрытие обоих issue только после совместной проверки acceptance contract.

16. Критерии приёмки

Меню и поток

  • «Проём» только открывает общую суб-панель и не начинает placement.
  • В меню три локализованных пункта: окно, дверь, ворота.
  • Выбор создаёт preset 120/90/300 см без записи данных.
  • Меню не меняет высоту toolbar/stage и использует shared host.
  • Save/Cancel позволяют продолжить серию с явно выбранным preset.
  • Возвращение в Plan editor не запускает placement автоматически.

Preview и геометрия

  • Полный symbol соответствует выбранному типу; ворота открываются наружу двумя створками на 10°.
  • Preview имеет opacity 0.5, editor accent и не перехватывает события.
  • Hover и click используют один resolver; при одинаковом input preview, dialog и committed opening не прыгают, а click без hover работает самостоятельно.
  • На всём теле толстой стены candidate доступен и preview виден поверх кладки.
  • Face offset, jamb depth, створки и дуги совпадают с committed opening.
  • Measurement hints контрастны и используют тот же candidate.
  • Invalid target не показывает preview, который click затем отклоняет.

Безопасность и совместимость

  • До Save нет config/layout write, history, wall cut, tunnel, Glow/sun/area/Resize.
  • Virtual span, partition и column остаются недопустимыми.
  • Existing openings, их dialog, drag и Undo/Redo не регрессируют.
  • Модель данных, migrations и backend API не меняются.
  • Lifecycle, keyboard, focus и touch-gesture проверки выполнены.
  • Smoke и golden падают при удалении preview или переносе под тело толстой стены.