Compare commits

...
Author SHA1 Message Date
claude[bot] 435b933914 docs: review document for #138
Issue: #138
User-Visible: no
2026-08-14 12:59:07 +00:00
Sergey Matyunin ca7dbeda92 fix: auto-close rooms along shared walls
Issue: #138
User-Visible: yes
2026-08-14 15:45:08 +03:00
Sergey Matyunin 1dbded5b73 Merge remote-tracking branch 'origin/dev' into issue/138-adjacent-room-autoclose 2026-08-14 15:34:47 +03:00
claude[bot] db7cf9d5f3 docs: review document for #138
Issue: #138
User-Visible: no
2026-08-14 12:33:14 +00:00
Sergey Matyunin 6704cfc925 docs(spec): guard autoclose minimum vertices
Issue: #138
User-Visible: no
2026-08-14 15:26:19 +03:00
claude[bot] 3a84c7f6d7 docs: review document for #138
Issue: #138
User-Visible: no
2026-08-14 12:23:28 +00:00
Sergey Matyunin 611c5a76f5 docs(spec): define adjacent-room autoclose
Issue: #138
User-Visible: no
2026-08-14 12:57:04 +03:00
18 changed files with 1608 additions and 219 deletions
File diff suppressed because one or more lines are too long
+149
View File
@@ -0,0 +1,149 @@
/** Issue #138: a new room may close along one existing continuous solid wall interval. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const clone = (value) => JSON.parse(JSON.stringify(value));
const baseSpace = {
id: 'autoclose', title: 'Autoclose', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [{
id: 'existing', name: 'Existing', area: null,
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]],
}],
walls: [{
key: '0.500000,0.300000@1.5708',
a: [0.5, 0.1], b: [0.5, 0.5], cm: 40,
}],
};
const reset = async (spacePatch = {}) => {
card._serverCfg = { spaces: [{ ...clone(baseSpace), ...clone(spacePatch) }], markers: [], settings: {} };
card._layout = {};
card._space = 'autoclose';
card._modelCache = null;
card._frame = null;
card._cfgEpoch++;
card._planSnapGeometryCache = null;
card._path = [];
card._activeDraftId = null;
card._draftSegmentCms = [];
card._closingWallCm = null;
card._roomDialog = false;
card._roomEditId = null;
card._pendingSplit = null;
card._toast = '';
card._drawWallField = '15';
card._setMode('plan');
card._tool = 'draw';
card._clearPlanSnapHover();
await update();
};
await reset();
const stage = root().querySelector('.stage');
const eventAt = (x, y, extra = {}) => {
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
return new MouseEvent('click', {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true,
...extra,
});
};
const click = async (x, y, extra = {}) => {
card._markupClick(eventAt(x, y, extra));
await update();
};
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
const currentDraft = () => card._curSpaceCfg.room_drafts?.find((draft) =>
draft.id === card._activeDraftId);
// r1 regression guard: two points on the common wall are still only an open line.
await click(500, 100);
await click(500, 500);
result.secondCommonWallPointStaysOpen = card._path.length === 2
&& !card._roomDialog && !card._toast
&& currentDraft()?.points.length === 2
&& currentDraft()?.segments.length === 1;
// Endpoint-to-endpoint auto-close after the new room has enough vertices.
await reset();
await click(500, 100);
await click(800, 100);
await click(800, 500);
await click(500, 500);
const successfulDraft = clone(currentDraft());
result.endpointAutoCloseOpensDialog = card._roomDialog
&& card._path.length === 5
&& close(card._path[0][0], card._path.at(-1)[0])
&& close(card._path[0][1], card._path.at(-1)[1]);
result.terminalSegmentPersistsBeforeDialog = successfulDraft?.points.length === 4
&& successfulDraft?.segments.length === 3
&& close(successfulDraft.points.at(-1)[0], 0.5)
&& close(successfulDraft.points.at(-1)[1], 0.5)
&& successfulDraft.segments.every((segment) => close(segment.cm, 15));
result.roomIsNotCommittedBeforeSave = card._curSpaceCfg.rooms.length === 1;
card._roomDialogCancel();
await update();
result.cancelKeepsTerminalOpenDraft = !card._roomDialog
&& card._path.length === 4
&& !card._contourClosed
&& JSON.stringify(currentDraft()) === JSON.stringify(successfulDraft)
&& close(card._path.at(-1)[0], 500) && close(card._path.at(-1)[1], 500);
// Save promotes the same draft, keeps the existing shared thickness and creates no partition.
await reset();
await click(500, 100);
await click(800, 100);
await click(800, 500);
await click(500, 500);
card._nameSel = 'Adjacent';
card._saveRoom();
await update();
const sharedWall = (card._curSpaceCfg.walls || []).find((wall) => wall.cm === 40
&& wall.a && wall.b
&& close(wall.a[0], 0.5) && close(wall.b[0], 0.5)
&& close(Math.min(wall.a[1], wall.b[1]), 0.1)
&& close(Math.max(wall.a[1], wall.b[1]), 0.5));
result.savePromotesDraftWithoutPartition = card._curSpaceCfg.rooms.length === 2
&& card._curSpaceCfg.rooms.some((room) => room.name === 'Adjacent')
&& !card._curSpaceCfg.room_drafts?.length
&& !card._curSpaceCfg.partitions?.length
&& card._path.length === 0;
result.sharedWallKeepsNeighbourThickness = !!sharedWall
&& (card._curSpaceCfg.walls || []).filter((wall) => wall.cm === 15).length >= 3;
// A canonical cut splits the room wall, so the same endpoint pair no longer closes.
await reset({ openings: [{
id: 'door', type: 'door', x: 0.5, y: 0.3, angle: 90, length: 0.1,
}] });
await click(500, 100);
await click(800, 100);
await click(800, 500);
await click(500, 500);
result.openingCutPreventsAutoClose = !card._roomDialog
&& card._path.length === 4
&& currentDraft()?.points.length === 4;
// An eligible but self-intersecting prospective ring is consumed without partial write.
await reset();
await click(500, 100);
await click(400, 300);
await click(700, 300);
const invalidDraftBefore = clone(currentDraft());
await click(500, 500);
result.invalidCloseHasNoPartialWrite = !card._roomDialog
&& card._path.length === 3
&& JSON.stringify(currentDraft()) === JSON.stringify(invalidDraftBefore)
&& card._toast === card._t('toast.contour_cannot_close');
return result;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+65 -65
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -414,6 +414,16 @@ field. Double click edits physical-object properties, pointer drag moves the
whole object on the grid, and Delete removes only the selected object. The
legacy root `space.segments` array is still stripped on every save.
Adjacent-room auto-close reuses the immutable architectural snap snapshot and
requires the first and prospective terminal points to belong to one completed
room's same solid segment after opening/open-span cuts. It runs only after the
draft has two edges and after explicit first-point/Ctrl closure handling. The
prospective polygon is checked for range, self-intersection and room overlap
before `_path` or `room_drafts` changes; success persists the terminal segment
and then opens the ordinary room dialog. Cancel therefore returns to the open
draft at that terminal point, while Save lets the normal room/wall
normalization preserve an existing shared wall's thickness.
While drawing, the length of the current segment follows the cursor (`_fmtLen` → `segmentCm`/
`formatLength`): metres, or feet+inches when `hass.config.unit_system` is imperial. The scale is
per-space `cell_cm` — cm represented by one grid cell (default 5, so 240 cells ≈ 12 m).
+10
View File
@@ -569,3 +569,13 @@ current anchor is excluded to prevent zero-length segments, while the first
point of a valid room outline remains an explicit closure target. The static
geometry is cached by structural editor state; pointer movement changes at
most the single active candidate and never writes config, layout or storage.
For **Room outline**, the same completed-room provenance also enables adjacent
auto-close. Once the draft already has two edges, a click that places its next
point on the same uninterrupted solid room-wall segment as the first point is
validated as the prospective polygon before any mutation. A valid click
persists that terminal draft segment and opens the standard room dialog with
the existing wall as the closing edge. A cut, a different edge, a saved draft
or an independent partition is ineligible; an eligible but invalid polygon is
reported without appending a point or segment. Explicit first-point and
Ctrl/Cmd closure retain priority.
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- A new room outline that starts and ends on one uninterrupted solid interval
of an existing room wall now closes automatically and opens the normal room
dialog. Endpoints and wall-bound T-connection points work alike; openings,
intentional gaps, different edges and a second point without enough sides do
not trigger the shortcut ([#138](https://github.com/Matysh/houseplan-card/issues/138)).
- Connected Room outline and Partition segments now form one seamless thick
wall immediately, including the live rubber-band. Right and oblique corners
use bounded mitre/bevel joins, T-connections have no visible butt face, each
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Новый контур комнаты, начатый и законченный на одном непрерывном сплошном
участке стены существующей комнаты, теперь замыкается автоматически и
открывает обычный диалог комнаты. Одинаково работают конечные узлы и точки
T-соединения на линии; проёмы, намеренные разрывы, разные грани и второй клик
без достаточного числа сторон быстрым замыканием не считаются
([#138](https://github.com/Matysh/houseplan-card/issues/138)).
- Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая
активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые
углы получают ограниченный mitre/bevel, T-соединение не показывает торец,
+12
View File
@@ -362,6 +362,18 @@ separately promised workflows:
- [ ] Ctrl/Cmd+click closes the current endpoint back to the first point without
adding another vertex. It requires at least two existing edges and refuses
degenerate or self-intersecting closure [auto: smoke_editor_tabs]
- [ ] Adjacent-room auto-close: after two drawn edges, ending on the same
uninterrupted solid existing-room wall interval as the first point adds
the terminal segment and opens the normal room dialog. Endpoints and
wall-interior snap points both work; Save creates one room without an
independent partition and preserves the shared wall thickness. Cancel
restores the open draft ending at that point [auto: smoke_room_autoclose;
unit: plan-snap-overlay.test.mjs]
- [ ] Adjacent-room auto-close safety: a second point alone stays open; different
room edges, draft/partition-only axes and points separated by an opening
or open-span cut do not trigger it. An eligible self-intersecting contour
shows the existing error and writes neither the terminal point nor segment
[auto: smoke_room_autoclose; unit: plan-snap-overlay.test.mjs]
- [ ] Room dialog: area list shows only unassigned areas; picking an area prefills the name
- [ ] Room dialog uses the medium width and its body has no horizontal overflow;
long options stay inside it at desktop and narrow widths [auto:
+6
View File
@@ -263,6 +263,12 @@ desktop: для точного рисования, Resize, модификато
5. Замкните контур кликом по первой точке или `Ctrl` + кликом в любом месте.
Быстрое замыкание доступно после двух нарисованных граней и не сработает,
если замыкающая стена создаёт самопересечение.
Если первая и текущая точки лежат на одном непрерывном сплошном участке
стены существующей комнаты, текущий клик сам добавит последнюю грань,
замкнёт контур общей стеной и откроет тот же диалог. Можно попадать как в
конечный узел, так и в точку на линии. Проём или намеренный разрыв разделяет
участок, разные грани не объединяются, а второй клик после единственной
первой точки остаётся обычным незамкнутым отрезком.
6. В диалоге задайте название, выберите свободную HA-зону либо оставьте в списке «— без зоны —», затем нажмите «Сохранить».
Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
+220
View File
@@ -0,0 +1,220 @@
# Code review — issue #138, cycle r1
Вердикт: **зелёный** · цикл r1/4 · High: 0 · Medium: 0
Ветка: `issue/138-adjacent-room-autoclose` · implementation-коммит
[`ca7dbed`](https://github.com/Matysh/houseplan-card/commit/ca7dbeda9280b9b0a424702676db8d6dfebb12d1)
· ТЗ: [`docs/specs/138-adjacent-room-autoclose.md`](../specs/138-adjacent-room-autoclose.md)
(редакция r2, зелёный `SPEC-REVIEW-138-r2.md`).
## Скоуп проверки
Диапазон `git diff origin/dev...HEAD` — 17 файлов. Продуктовый код:
`src/plan-snap-overlay.ts` (новый чистый helper `findSharedRoomSnapSegment` +
изменённый tie-break дедупликации сегментов по оси) и `src/houseplan-card.ts`
(рефакторинг `_closeRoomContour` в `_validateRoomContour`/
`_openRoomContourDialog`, новые `_canAppendRoomDraftPoint` и
`_tryAutoCloseRoomContour`, подключение в `_markupClick`). Тесты:
`test/plan-snap-overlay.test.mjs` (+46 строк, 3 новых теста), новый
production-bundle smoke `demo/smoke_room_autoclose.mjs`. Документация:
`ARCHITECTURE.md`, `CANVAS.md`, `USER-GUIDE.ru.md`, `TESTING.md`, оба
changelog, запись в `docs/specs/README.md`. Три копии бандла идентичны между
собой и совпадают со свежей локальной сборкой.
Единственный implementation-коммит `ca7dbed` несёт `Issue: #138` ·
`User-Visible: yes`; оба changelog обновлены в этом же коммите — требование
выполнено. Предшествующие коммиты (`611c5a7`, `3a84c7f`, `6704cfc`, `db7cf9d`)
— это ТЗ и его ревью-документы, `User-Visible: no`, класс C; в код не входят.
Merge-коммит `1dbded5` синхронизировал ветку с `dev` (в т.ч. #141) без
конфликтов, что подтверждено зелёным `npm test`/`npm run build` на итоговом
дереве.
## Как проверялось
Дешёвые гейты (всегда):
- `npx tsc --noEmit` → **зелёный**, без вывода.
- `npm test` → **796/796 green**.
- `npm run build` → зелёный; `cmp dist/houseplan-card.js
custom_components/houseplan/frontend/houseplan-card.js` и `cmp
dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — обе команды
молча завершились успехом. SHA-256 свежей сборки
(`83a64bc2c54dfbf00680a2a9bd1949f2beea917d2c27cc90f064697b5ea7cb8a`)
побайтно совпал с хэшем, заявленным автором в хендофф-комментарии —
бандл не редактировался руками после передачи на ревью.
Гейт по необходимости (diff трогает единственную затронутую поверхность —
инструмент «Контур комнаты» редактора Плана, назван в ТЗ и AC1–AC10):
- `node demo/smoke_room_autoclose.mjs` → **зелёный, 9/9**
(`secondCommonWallPointStaysOpen`, `endpointAutoCloseOpensDialog`,
`terminalSegmentPersistsBeforeDialog`, `roomIsNotCommittedBeforeSave`,
`cancelKeepsTerminalOpenDraft`, `savePromotesDraftWithoutPartition`,
`sharedWallKeepsNeighbourThickness`, `openingCutPreventsAutoClose`,
`invalidCloseHasNoPartialWrite`).
**Дисциплина «тест умеет падать»** — применена к обоим прогнанным наборам,
намеренной порчей кода с последующим откатом (дерево осталось чистым, `git
status --short` пуст, сборка и `npm test`/smoke зелёные после отката):
1. В `plan-snap-overlay.ts` откатил tie-break дедупликации до чистого
`localeCompare` (убрал `sourceRank`) → `npm test` **упал** ровно на новом
тесте `a completed room remains the authority for a coincident
deduplicated axis` (1 fail из 796). Тест не тавтологичен.
2. В `houseplan-card.ts` убрал guard `if (this._path.length < 2) return
false;` в `_tryAutoCloseRoomContour` (это в точности r1-High regression,
исправленный в ТЗ r2) → пересобранный бандл **проваливает**
`secondCommonWallPointStaysOpen` в smoke. Подтверждает, что
AC3/минимально-вершинный гейт реально протестирован, а не совпадение.
3. Полностью отключил eligibility (`_tryAutoCloseRoomContour` всегда `false`)
→ smoke **проваливает** 5 из 9 проверок
(`endpointAutoCloseOpensDialog`, `cancelKeepsTerminalOpenDraft`,
`savePromotesDraftWithoutPartition`, `sharedWallKeepsNeighbourThickness`,
`invalidCloseHasNoPartialWrite`).
Не прогонялось, с обоснованием (соразмерность гейтов, PROCESS.md §8):
- **`npm run golden:verify`** — diff не меняет ни один `render()`/lit-шаблон
(проверено: `git diff ... -- src/houseplan-card.ts | grep 'html\`\|render('`
— пусто), только приватную логику клика и чистый геометрический helper; ТЗ
§14.3 прямо утверждает отсутствие новых pixels/состояний. Полный набор (67+
сценариев) — предрелизный гейт, непропорциональный правке без визуальных
изменений.
- **Полный browser smoke-suite (127 файлов)** — diff касается ровно одной
поверхности (Room outline / draw), прогнан только целевой
`smoke_room_autoclose.mjs`, который и есть единственный новый/изменённый
smoke в этом диффе.
- **`python -m pytest tests_backend`** — `custom_components/**/*.py` не
тронут (подтверждено `git diff --stat`), ТЗ §11 и AC13 это же утверждают.
- **Performance-профили / Full Performance** — AC11 не заявляет изменение
бюджета, только «не должно расти»; новый код вызывается исключительно из
`_markupClick` (клик), не из pointermove/hover — проверено чтением (`grep
findSharedRoomSnapSegment|_tryAutoCloseRoomContour src/houseplan-card.ts`
даёт единственный вызывающий сайт на клик), геометрический snapshot
переиспользует существующий кеш `_planSnapGeometrySnapshot()`. Отдельного
повода подозревать регресс перфоманса нет.
## Разбор AC
- **AC1, AC3–AC9** — доказаны smoke (`demo/smoke_room_autoclose.mjs`, все
сценарии выше) и/или unit (`test/plan-snap-overlay.test.mjs`); падение
проверено намеренной порчей (см. «Как проверялось», пп. 1–3).
- **AC2 (line-snap точки на общем интервале)** — geometric часть доказана
unit-тестом `shared-room interval contains endpoints and interior
wall-bound points only on one edge` (`[20,0]-[80,0]`, `[0,0]-[40,0]`).
Полный browser-сценарий с mid-line точкой в `smoke_room_autoclose.mjs`
отсутствует (там только endpoint-to-endpoint). **Проверено чтением, не
исполнением:** `resolvePlanSnap`/`quantizedPoint`
(`plan-snap-overlay.ts:248-283`) проецируют указатель строго на ось
сегмента и возвращают точку `segment.a + u·distance`, то есть коллинеарную
этому же сегменту с той же epsilon, что использует
`findSharedRoomSnapSegment`; отдельного резолвера или допуска для
line-snap точек в `_tryAutoCloseRoomContour` нет — используется тот же
`pt`, что и для endpoint-случая. Риск несовпадения точности признан
низким. **Low, не блокирует** — не заведено отдельным issue: geometric
ядро уже protected unit-тестом, а интеграционная часть — прямое повторное
использование уже покрытого пути без специального кода для этого случая.
- **AC10 (touch: tap без hover, gesture-safety)** — заявленный в ТЗ метод
«smoke» не выполнен: ни один tap/pan/pinch/pointercancel сценарий не
добавлен ни в `demo/smoke_room_autoclose.mjs`, ни в существующий
`demo/smoke_editor_gestures.mjs` (второй файл в этом диффе не менялся).
**Проверено чтением, не исполнением:** `_markupClick`
(`houseplan-card.ts:6508-6619`) отклоняет клик до вызова
`_tryAutoCloseRoomContour`, если `this._suppressClick` (строка 6512) —
это уже существующий, не тронутый этим диффом guard, которым
управляют pan/pinch/pointercancel/synthetic-click пути в другом месте
файла. `_tryAutoCloseRoomContour` не читает hover-состояние: единственные
входы — `this._path[0]`, только что резолвнутый `pt` (тем же
`_resolvePlanDrawPoint`, что и обычный клик) и кешированный snapshot.
Отдельного hover-зависимого кода нет, поэтому tap-без-hover эквивалентен
click по построению, а не по совпадению. **Low, не блокирует** — записано,
не заводится отдельным issue: риск регресса архитектурно исключён тем, что
новая логика не создаёт нового обработчика событий и не вводит
hover-зависимости.
- **AC11 (perf)** — проверено чтением (см. «Как проверялось»): единственный
вызывающий сайт — клик, `O(S)`-скан по кешированному snapshot, кеш не
растёт (тот же `_planSnapGeometryCache`, ключ не расширён новыми полями).
- **AC12** — гейты зелёные, три копии бандла побайтно идентичны, оба
changelog и `USER-GUIDE.ru.md`/`ARCHITECTURE.md`/`CANVAS.md` обновлены в
том же коммите `ca7dbed`.
- **AC13** — подтверждено `git diff --stat`: ни `custom_components/**/*.py`,
ни `src/i18n/*.json`, ни `custom_components/**/translations/**`, ни
`src/types.ts` (схема) не затронуты.
## Что проверено и корректно
- **Порядок разрешения клика (§7.1)** — прочитан `_markupClick`
(`houseplan-card.ts:6508-6619`): `pt` берётся из актуального
`_resolvePlanDrawPoint` **до** любой ветки; порядок веток — ctrl/cmd-close
→ close-by-first-point → `_tryAutoCloseRoomContour(pt)` →
`_draftEndAt`/resume-join → обычное добавление. Это ровно порядок,
который требует ТЗ (автозамыкание после explicit-веток, до
`_draftEndAt()`).
- **Минимально-вершинный гейт (r1-High из SPEC-REVIEW-138-r1, симметрия с
ручным замыканием)** — `_tryAutoCloseRoomContour` отказывает при
`this._path.length < 2`, что вместе с добавляемым `pt` даёт минимум 3
вершины — то же требование, что и explicit-закрытие
(`this._path.length >= 3`). Регресс-тест существует и умеет падать
(проверено намеренной порчей, см. выше).
- **Проёмы/open span (§7.3)** — `findSharedRoomSnapSegment` фильтрует только
`sourceKind === 'room'`, а сами сегменты уже разрезаны `cutSegments` по
`roomCuts` до вызова; второй resolver не появился (`grep` подтверждает
единственный источник cuts — существующий `_planSnapOpeningCuts`/
`_openCuts`). Smoke `openingCutPreventsAutoClose` зелёный.
- **Дедупликация оси room/draft/partition (новый `sourceRank`)** — без этого
изменения `findSharedRoomSnapSegment` мог бы не найти `sourceKind: 'room'`
сегмент для оси, которая после `canonicalPair` совпадает с осью partition
или draft (алфавитный tie-break предпочитал `'partition...' <
'room...'`). Изменение не расширяет скоуп: это внутренний, не наблюдаемый
пользователем инвариант того же модуля, необходимый именно для контракта
AC4/AC9 («drafts/partitions не запускают автозамыкание, но не должны и
вытеснять room-сегмент с той же осью»). Покрыт unit-тестом, который умеет
падать (проверено выше).
- **Валидация без mutation (§8.1–8.2)** — `_validateRoomContour` вызывается с
явным `path`-параметром (без побочных эффектов на `this._path`) и в
`_closeRoomContour`, и в `_tryAutoCloseRoomContour`; запись в `this._path`/
`_persistActiveDraftSegment()` происходит только после успешной валидации.
Smoke `invalidCloseHasNoPartialWrite` подтверждает отсутствие частичной
записи и правильный toast.
- **Cancel/Save/толщина (§9, AC7-AC8)** — smoke подтверждает: Cancel
возвращает открытый draft с точкой `B` и тем же состоянием, что до диалога;
Save создаёт комнату без независимой partition, `B—A` наследует толщину
соседней стены (40), новые внешние сегменты сохраняют свою (15).
«Оставить замкнутыми стенами» и сам диалог не менялись этим диффом — новая
замыкающая грань становится обычной частью закрытого `this._path` до
открытия диалога, поэтому существующий save/keep-walls контракт применяется
к ней без специального кода (проверено чтением: `_openRoomContourDialog`
не отличает автозамкнутый путь от вручную замкнутого).
- **i18n/backend/схема (AC13)** — новых ключей, файлов Python, полей схемы
нет; подтверждено diff'ом.
- **Терминология документации** — `docs/USER-GUIDE.ru.md`,
`docs/CHANGELOG.md`/`.ru.md` переиспользуют термины «T-соединение» / точка
на линии, уже введённые предыдущим (#141) разделом того же Unreleased
changelog, а не изобретают новые.
## Чего не проверял
- **`npm run golden:verify` (полный набор)** — не прогонялся; diff не трогает
шаблоны/рендер, ТЗ явно не ожидает новых pixels. Обоснование см. выше.
- **Полный browser smoke-suite (127 файлов)** — не прогонялся, вне
относящейся к задаче поверхности.
- **`python -m pytest tests_backend`** — не прогонялся, Python не тронут.
- **Performance smoke / Full Performance** — не прогонялись; предрелизный
гейт, повода подозревать регресс нет.
- **AC2 line-snap и AC10 touch/gesture — end-to-end browser-сценарий** — не
выполнялся (в диффе нет такого smoke); закрыто чтением кода, см. раздел
«Разбор AC» — оба Low, записаны, не блокируют и не заводятся отдельными
issue.
- **Ручное визуальное тестирование в браузере** (не headless) — не
выполнялось; выводы основаны на smoke/unit и прямом чтении/воспроизведении
через намеренную порчу кода.
## Итог
High: 0. Medium: 0. Low: 2 (AC2 line-snap и AC10 touch/gesture — интеграционный
smoke не добавлен, риск закрыт чтением кода и не блокирует; записаны выше,
отдельные issue не заводятся).
Вердикт зелёный: AC1–AC13 доказаны автотестом (с подтверждённой способностью
падать) либо разобраны по коду с явной записью «проверено чтением, не
исполнением». Задача готова к очереди на пре-релиз.
+218
View File
@@ -0,0 +1,218 @@
# Ревью ТЗ — issue #138, цикл r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/138
- **ТЗ:** `docs/specs/138-adjacent-room-autoclose.md`, коммит `611c5a76f56ce83587eafbd00dbd6ad1a0f8c9d8`
- **Этап:** spec (PROCESS.md §2.4)
- **Вердикт:** красный · цикл r1/4 · High: 1 · Medium: 0
## Скоуп ревью
Прочитаны в указанном порядке: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1,
§2, §3, §4, §7, §12), тело issue #138 и оба комментария владельца (аналитика
Q1–Q5 с defaults, подтверждение ТЗ), `docs/USER-GUIDE.ru.md` (терминология
диалога комнаты, кнопка «Оставить замкнутыми стенами», секции про соседние
комнаты и общую стену), и связанный `docs/specs/137-plan-snap-overlay.md` как
основа, на которой строится #138.
Задача не помечена `small`, ТЗ корректно лежит в `docs/specs/138-adjacent-room-autoclose.md`
и зарегистрировано в `docs/specs/README.md:49`. Формат ревью — полный документ,
не комментарий.
## Как проверялось
Ревью читало не только текст ТЗ, но и код, на который оно опирается, чтобы
отличить обоснованное техническое утверждение от догадки, выданной за факт:
- `src/houseplan-card.ts`: `_markupClick()` (6454–6575), `_closeRoomContour()`
(6426–6452), `_resolvePlanDrawPoint()` (6011–6031), `_draftEndAt()`
(6580–6594) — сверка с §3 «Причина дефекта», §7.1 «Порядок разрешения
клика», §8 «Контракт замыкания».
- `src/plan-snap-overlay.ts` (1–180): структура `PlanSnapSegment`,
`sourceKind: 'room'|'draft'|'partition'`, `cutSegments`/`roomEdges`,
дедупликация по `axisKey` — сверка с §7.2 «Допустимый существующий
интервал» и терминологией «room-owned solid interval».
- `src/wall-thickness.ts:461` `applyWallThicknessToNewRoom()` — сверка с §9
(наследование толщины общей стены) и AC7.
- `src/i18n/ru.json:9,267–269` — существующие `btn.keep_as_walls`,
`toast.room_overlap`, `toast.contour_min_edges`, `toast.contour_cannot_close`
— сверка с заявлением «новых i18n-ключей не требуется» (§4.4, §6).
- `docs/specs/README.md` — регистрация spec-файла.
Каждое утверждение ТЗ о текущем поведении, которое удалось проверить чтением
кода, подтвердилось: терминология (`sourceKind`, `_markupClick`,
`_closeRoomContour`, `_resolvePlanDrawPoint`, `applyWallThicknessToNewRoom`,
«Оставить замкнутыми стенами») — не изобретена, а взята из реального кода.
Отдельно оценивалась полнота контракта: не пропущен ли граничный случай,
который спецификация не проговаривает и не относит явно в non-scope.
## Находки
### High-1 — контракт автозамыкания перехватывает и блокирует обычный второй клик по той же стене
**Где:** `docs/specs/138-adjacent-room-autoclose.md` §7.1 (строки 105–119),
§7.2 (121–139), §8.1 (155–173), §8.2 (175–186).
**Формулировка:** §7.1 п.3 требует проверять на автозамыкание **любой**
обычный клик, отличный от клика в собственную первую точку контура — без
условия «в контуре уже есть хотя бы одно нарисованное ребро». §7.2 требует
только геометрических условий (общий room-owned solid interval, содержит `A`
и финальную точку, положительная длина, точки различны) — тоже без условия на
уже накопленное число вершин. §8.1 требует **минимум три различные вершины**
у prospective polygon `[..., P, B] + B—A`, иначе (§8.2) диалог не открывается,
`B`/`P—B`/`B—A` не записываются, **и клик не превращается в обычное
добавление `B`** — то есть точка не появляется в drafting вообще, только
показывается existing validation toast.
Отсюда прямое следствие: если пользователь ставит **первую** точку `A` на
существующем углу (`this._path = [A]`, `houseplan-card.ts:6544`), а **второй**
клик кладёт на другой конец **той же самой** прямой существующей стены (что
является совершенно естественным способом начать соседнюю комнату — сначала
отметить оба конца общей стены, а затем обвести остальной контур), —
`P` на этот момент равен `A` (в пути одна точка), и prospective polygon равен
`[A, B] + B—A`: **две** различные вершины, не три. Проверка §8.1 обязана
провалиться, и по букве §8.2 клик обязан быть отклонён с toast, а `B` —
**не добавлен в draft вообще**.
Сегодня, до этой задачи, тот же клик работает штатно: `_markupClick()` не
делает такой проверки и просто добавляет `B` как обычную точку контура
(`houseplan-card.ts:6573–6574`, путь `this._path = [...this._path, pt];
this._persistActiveDraftSegment();`). Показательно, что для симметричного
случая — клика в собственную первую точку — система уже сегодня специально
избегает этой ловушки: строка `houseplan-card.ts:6522`
(`const closing = this._path.length >= 3 && this._samePt(pt, this._path[0]);`)
трактует клик как попытку замкнуть контур **только когда уже накоплено
достаточно вершин**; при недостатке вершин точка добавляется как обычная —
ошибка «нужно минимум два ребра» никогда не показывается на пустом контуре.
Новый контракт для автозамыкания (§7.1 п.3) не воспроизводит этот защитный
гейт: он назначает автозамыкание кандидатом для проверки независимо от того,
сколько вершин уже нарисовано, а §8.2 явно запрещает деградацию к обычному
добавлению точки при провале.
**Сценарий воспроизведения (по тексту ТЗ, до реализации — логическая проверка контракта):**
1. Существует завершённая комната с прямой стеной `A—C` (общая длина, `A` и
`C` — её концы).
2. Пользователь выбирает «Контур комнаты», кликает в `A` — `this._path = [A]`.
3. Следующим кликом отмечает `C` — противоположный конец **той же** стены,
намереваясь провести по ней первую грань новой комнаты, а затем обвести
остальной периметр и замкнуть контур явным кликом в `A` (существующий,
неизменяемый способ, приоритет 1 из §7.1).
4. По §7.1 п.3 клик в `C` не равен `_path[0]=A`, не является Ctrl/Cmd — значит
проверяется как кандидат на автозамыкание. По §7.2 `A` и `C` лежат на одном
`sourceKind: room` сплошном интервале, точки различны, длина положительна —
eligibility выполнена.
5. По §8.1 prospective polygon `[A, C] + C—A` содержит 2 различные вершины,
что меньше требуемых трёх → невалиден.
6. По §8.2 диалог не открывается, `C` не добавляется в draft, показывается
toast «Чтобы замкнуть контур, сначала нарисуйте минимум две грани»
(`toast.contour_min_edges`), а **клик не превращается в обычное добавление
точки**.
7. Результат: пользователь не может продолжить рисовать — второй клик по
общей стене, который сегодня работает и является естественным способом
начать примыкающую комнату, теперь ничего не делает, кроме показа
сообщения об ошибке, не имеющего отношения к тому, что человек пытался
сделать.
Это прямая регрессия текущего, работающего поведения — причём именно в той
области (рисование вдоль общей стены соседней комнаты), которую задача #138
должна улучшить, а не ухудшить. Ни один AC (§13) и ни один пункт плана
unit-тестов (§14.1, пп.1–9) не покрывает этот случай: пункт 7
(«одинаковые A/B, zero-length и current anchor не подходят») — про **совпадающие**
точки, а не про две **различные** точки на одном интервале при пустом ранее
контуре. Риск не упомянут и в таблице §17.
**Почему это High, а не Medium:** правило §3.8 и §2.4 требует, чтобы контракт
поведения был однозначным и не содержал воспроизводимой логической ошибки;
здесь ошибка выводится напрямую из текста ТЗ без домысливания реализации, и
затрагивает основной, ежедневный workflow редактора (первые же клики при
разметке соседней комнаты — ровно сценарий из тела issue). Это не пограничный
кейс, а типичный способ начать рисовать общую стену.
**Что нужно поправить в ТЗ (не решение реализации, а контракт):**
Автозамыкание должно проверяться только когда в контуре уже достаточно
вершин, чтобы в принципе замкнуться (аналогично существующему гейту
`this._path.length >= 3` для клика в первую точку — т.е. до клика в `B` уже
должно быть отрисовано **хотя бы одно** дополнительное ребро, не лежащее
целиком на том же интервале что и `A—B`), либо явно указать, что при провале
именно по причине «меньше трёх вершин» (в отличие от self-intersection/overlap/
zero-area) клик **деградирует к обычному добавлению точки**, а не блокируется
toast'ом. Выбор между этими двумя вариантами — продуктовый (что видит
пользователь при клике по второй точке общей стены сразу после первой):
либо клик тихо добавляет обычную точку (как сегодня), либо показывает
ошибку. Это ровно тот вопрос, который стоило задать владельцу вместе с Q1–Q5,
и он остался незамеченным.
## Что проверено и корректно
- **Терминология и техническая база не выдуманы.** Все ссылки на
`_resolvePlanDrawPoint`, `_markupClick`, `_closeRoomContour`, `_draftEndAt`,
`_persistActiveDraftSegment`, `sourceKind: 'room'|'draft'|'partition'`,
`applyWallThicknessToNewRoom`, `plan-snap-overlay.ts`, кнопку «Оставить
замкнутыми стенами» и существующие toast-ключи — соответствуют
действительному коду. §3 «Причина дефекта» описывает код, каким он есть
сегодня, а не предположение.
- **Обязательные разделы §7.1 PROCESS.md на месте:** сценарий/персона/
поверхность (§1), «что человек увидит до/после» одной фразой без терминов
реализации (§2), проблема (§3), scope/non-scope (§5–6), контракт поведения
(§7–9), UX и touch-деградация (§10), модель данных/миграция (§11),
AC1…AC12 с указанием способа доказательства (§13), план автотестов (§14),
риски (§17), откат (§18), release-артефакты (§16).
- **i18n корректно закрыт:** новых ключей нет, переиспользуются существующие
`toast.contour_min_edges`, `toast.contour_cannot_close`, `toast.room_overlap`,
`btn.keep_as_walls` — подтверждено чтением `src/i18n/ru.json`/`en.json`.
- **Наследование толщины общей стены (AC7, §9)** грамотно опирается на уже
существующий `applyWallThicknessToNewRoom()` (`wall-thickness.ts:461–484`),
который и сегодня пропускает интервалы с уже ненулевой толщиной (`cms[i] >
0`) — новый код для этого не нужен, утверждение ТЗ верно.
- **Проёмы и cuts (§7.3, AC4)** корректно опираются на уже существующий
`cutSegments`/canonical cuts из #137 (`plan-snap-overlay.ts`), не вводят
второй resolver — соответствует Non-scope и архитектурному контракту §12.
- **Дедупликация общей стены между двумя комнатами (§9, «не создаёт вторую
физическую стену»)** согласуется с найденной в `plan-snap-overlay.ts`
дедупликацией сегментов по `axisKey` (один `PlanSnapSegment` на общую ось
независимо от числа комнат-источников) — заявление обосновано, не догадка.
- **Открытых продуктовых вопросов действительно не осталось** для того, что
было явно задано: Q1–Q5 из комментария аналитики и все технические defaults
подтверждены владельцем 2026-08-14 (issue-комментарий), ссылка есть в ТЗ
§4 и §19.7. «Ни одного открытого вопроса» здесь не является тревожным
сигналом самим по себе — вопросы были заданы и закрыты пакетом, а не
обойдены.
- **Release-артефакты и трек соответствуют процессу:** `User-Visible: yes`,
оба changelog в одном коммите, `docs/CANVAS.md`/`ARCHITECTURE.md`/
`USER-GUIDE.ru.md` в списке правок (§16), полный трек (не `small`/`trivial`)
обоснован в аналитике — сложность и число задетых инвариантов оправдывают
выбор.
- **Non-scope сформулирован конкретно и исключает реальные соблазны**
расширения (путь по нескольким рёбрам, второй resolver проёмов, новая
визуальная индикация, hit-radius #137) — не оставляет скрытого расширения
скоупа.
## Чего не проверял
- Реализацию — её ещё нет; это ревью ТЗ, а не кода (PROCESS.md §2.4 не
предполагает исполнения на этом этапе).
- Golden/визуальные артефакты — ТЗ корректно утверждает, что новых пикселей
нет (§14.3), баз для сверки нет и не требовалось.
- Производительность вживую — утверждение об O(S)-поиске только на click
(§12, AC10) проверено чтением архитектуры snap-геометрии (кэшированный
snapshot в `_planSnapGeometrySnapshot()`), не профилированием — на этапе ТЗ
профилирование не требуется.
- Backend/schema — ТЗ утверждает отсутствие изменений (§11, AC12); код
`custom_components/houseplan/**/*.py` не затрагивается по scope ТЗ, отдельно
не сверялся построчно, поскольку заявленный scope его не касается.
## Вывод
Один блокирующий (High) дефект контракта: связка §7.1 п.3 + §7.2 + §8.1 + §8.2
без защитного гейта по минимальному числу уже нарисованных вершин превращает
обычный, сегодня работающий второй клик по общей стене в блокирующую ошибку —
регрессия ровно того сценария, который issue #138 должен исправить. Остальной
контракт, включая проёмы, толщину, drafts/partitions, historyCancel/Save,
touch-деградацию и release-артефакты, проверен по коду и корректен.
Возврат в «ТЗ в работе» (`S3-spec`), цикл r1/4. Исправление: явно решить (и
записать в ТЗ), что происходит при клике на другую точку того же интервала,
когда в контуре ещё недостаточно вершин для валидного замыкания — деградация к
обычному добавлению точки (по аналогии с текущим гейтом `path.length >= 3` для
закрытия по первой точке) либо иной явно обоснованный вариант, покрытый
отдельным AC и unit-тестом.
+165
View File
@@ -0,0 +1,165 @@
# Ревью ТЗ — issue #138, цикл r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/138
- **ТЗ:** `docs/specs/138-adjacent-room-autoclose.md`, коммит `6704cfc9258d78688e83f13233cf67a12eae7bea`
- **Предыдущий цикл:** `docs/reviews/SPEC-REVIEW-138-r1.md`, вердикт красный, High: 1
- **Этап:** spec (PROCESS.md §2.4)
- **Вердикт:** зелёный · цикл r2/4 · High: 0 · Medium: 0
## Скоуп ревью
Прочитаны заново: `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (§1, §2, §2.4,
§4, §7.1–7.2, §12), тело issue #138 и все комментарии владельца, включая
аналитику Q1–Q5 с defaults, `SPEC-REVIEW-138-r1.md` целиком, ответ владельца
на r1 (коммит `6704cfc`), и полный текущий текст
`docs/specs/138-adjacent-room-autoclose.md` (не только диф r1→r2— весь
документ прочитан заново, чтобы не подтверждать чужой вывод не глядя).
Дополнительно: `docs/USER-GUIDE.ru.md` (терминология «Оставить замкнутыми
стенами», секции про соседние комнаты и общую стену), `docs/TOUCH-SUPPORT.md`
(safety floor, «best effort», список из шести условий деградации),
`docs/specs/137-plan-snap-overlay.md` как основа контракта.
Задача не помечена `small`, лимит цикла — 4 (полный трек), это r2. ТЗ лежит в
`docs/specs/138-adjacent-room-autoclose.md`, зарегистрировано в
`docs/specs/README.md:49`.
## Как проверялось
Между r1 и r2 изменился только сам файл ТЗ — `git diff 611c5a7 6704cfc --
docs/specs/138-adjacent-room-autoclose.md` подтверждает, что коммит `6704cfc`
не трогает `src/**` или что-либо ещё; продуктовый код с r1 не менялся, поэтому
все выводы r1 о соответствии терминологии реальному коду остаются в силе без
повторной построчной сверки там, где текст ТЗ не изменился.
Специально перечитан и построчно сверен с кодом именно тот фрагмент, который
правит High-1 из r1:
- `src/houseplan-card.ts:6454–6575` (`_markupClick`) и `6426–6452`
(`_closeRoomContour`) — чтобы проверить, действительно ли новый
minimum-vertex gate (§7.1 п.3, §7.2, §8.1–8.3) воспроизводит существующий
защитный гейт `this._path.length >= 3 && this._samePt(pt, this._path[0])`
(`houseplan-card.ts:6522`) по аналогии, а не только по формулировке.
- Прослежены все достижимые состояния `this._path` в момент клика: путь длины
`0` обрабатывается отдельной веткой (`houseplan-card.ts:6527–6545`, resume
или `this._path = [pt]`) и никогда не доходит до строки 6522 / новой
eligibility-проверки; значит единственное «недостаточное» состояние на
входе в новый контракт — `this._path.length === 1` (только точка `A`).
Именно это состояние и описывает новый AC3/§7.1/§8.3, без пробелов.
- Пересчитана арифметика минимума: у ручного замыкания на входе требуется
`path.length >= 3` (уже 3 узла), клик не добавляет новый узел — итоговый
полигон имеет 3 разных вершины. У автозамыкания на входе требуется
`path.length >= 2` (2 узла: `A`, `P`), клик добавляет `B` как новый узел —
итоговый полигон `[A, P, B]` тоже 3 разных вершины. Это тот же инвариант
«минимум треугольник», выраженный симметрично для двух разных путей входа
(совпадение с первой точкой vs. новая точка на общей стене), а не
произвольное число.
- Проверено, что порядок проверок в тексте (§8.2: «недостаточное количество
вершин... обрабатывается как обычный клик по §8.3 без toast») не
противоречит §8.1/§8.2 для случая, когда вершин уже достаточно, но prospective
polygon невалиден по другой причине (self-intersection/overlap/zero-area) —
эти два условия (мало вершин / геометрически невалидно при достаточном числе
вершин) взаимно исключающие и оба покрыты отдельными ветками контракта и
отдельными AC (AC3 и AC6).
- Перечитаны переномерованные AC1…AC13 (§13) и пункты плана автотестов §14.1
(1–10) и §14.2 (1–9) на согласованность номеров и полноту — ссылок на старые
номера, пропущенных или задвоенных пунктов не найдено.
- `docs/specs/README.md:49` — запись на ТЗ не устарела после переименования
редакции.
- `docs/TOUCH-SUPPORT.md:58–70` (safety floor) — сверка с §10 нового текста:
все пять перечисленных touch-инвариантов (`pinch`, `pan`, `pointercancel`,
второй touch, suppressed synthetic click) прямо соответствуют
канонической формулировке «saving unintended geometry merely because a
pinch, pointer cancellation or second touch was misread as a click».
- `docs/USER-GUIDE.ru.md:281,294,324` — «Оставить замкнутыми стенами»,
«Объединить», «общая стена» — термины ТЗ не изобретены.
- `src/plan-snap-overlay.ts:1–180` (`cutSegments`, `roomEdges`, `sourceKind`,
`axisKey`-дедупликация) — повторно проверено, что §7.2/§7.3 нового текста не
расходятся с уже проверенной в r1 структурой; правки r1→r2 в эти секции не
меняли геометрический контракт, только добавляли gate по числу вершин перед
ним.
Отдельно рассмотрен вопрос, не следовало ли решение (второй клик по общей
стене при недостающих вершинах тихо добавляет обычную точку, без toast)
вынести владельцу как продуктовый вопрос, а не решить технически в ответ на
red-вердикт. Вывод: нет — выбранный вариант **не меняет никакое видимое
поведение** относительно состояния до issue #138: это ровно то, что делает
сегодняшний код (`houseplan-card.ts:6573–6574`) для того же клика. Автор не
принял новое продуктовое решение, а сузил новый контракт так, чтобы он не
трогал случай, для которого никакого изменения не было заявлено ни в issue,
ни в Q1–Q5. Эскалация «оставить работающий сегодня клик работающим и дальше»
была бы вопросом без реального разночтения.
## Находки
Нет. Единственный High из r1 устранён без побочных эффектов; новых High или
Medium при повторном прочтении всего документа не найдено.
### High-1 из r1 — статус: исправлено
Гейт «недостаточное число вершин» теперь явно предшествует eligibility-проверке
(§7.1 п.3, §7.2 первая строка, §8.2 последний абзац, §8.3 первый пункт списка),
получил собственный AC3, unit-пункт §14.1.3, smoke-пункт §14.2.2 и строку риска
в §17. Проверено по коду: единственное достижимое «недостаточное» состояние —
`this._path.length === 1` — теперь однозначно закрыто и ведёт к тому же
поведению, что и сегодня (обычное добавление точки, без toast, без диалога).
Сценарий из r1 (первая точка `A` на угле, второй клик `B` на другом конце той
же стены) больше не регрессирует: он explicit-но отнесён к §8.3, не к
eligibility.
## Что проверено и корректно
- Всё, что было подтверждено в r1 («Что проверено и корректно» этого
документа) и не менялось между r1 и r2, остаётся в силе: терминология не
изобретена, обязательные разделы §7.1 PROCESS.md на месте, i18n не меняется,
наследование толщины общей стены (`wall-thickness.ts:461`) обосновано,
проёмы/cuts переиспользуют #137 без второго resolver, дедупликация общей
стены по `axisKey` подтверждена, вопросы Q1–Q5 закрыты владельцем пакетом,
release-артефакты и трек соответствуют процессу, non-scope конкретен.
- Новый minimum-vertex gate арифметически и логически симметричен
существующему гейту ручного замыкания (`path.length >= 3` для клика в
первую точку ↔ `path.length >= 2` перед добавлением новой точки `B` для
автозамыкания) — оба требуют ровно 3 разные вершины итогового полигона,
бez произвольных чисел.
- AC1–AC13 переномерованы согласованно; каждый AC называет способ
доказательства (`unit`, `smoke`, `code review`, `performance review`,
`schema/security review`); ни один не сформулирован как открытый вопрос.
- Риски (§17) содержат отдельную строку для нового защитного гейта с указанием
меры (unit + smoke), т.е. класс регрессии из r1 не остался незамеченным в
таблице.
- План автотестов §14.1/§14.2 покрывает именно новый граничный случай (пункт
3 unit, пункт 2 smoke) отдельно от общих negative-кейсов (пункты 4–5/3–4),
так что AC3 доказуем автоматически, а не только по тексту.
- Решение не escalировать выбор поведения при недостатке вершин владельцу —
обосновано выше: вариант сохраняет статус-кво без нового видимого
поведения, то есть не является продуктовой развилкой.
## Чего не проверял
- Реализацию — её ещё нет; код `src/**` не менялся с r1 (подтверждено
`git diff 611c5a7 6704cfc --stat` — только spec-файл), проверка кода
на этом этапе не требуется PROCESS.md §2.4.
- Golden/визуальные артефакты и живой перфоманс — не изменились относительно
r1: заявления §14.3/§12 не изменились по смыслу, только переномерованы;
повторная сверка архитектуры snap-геометрии не требовалась, так как её текст
между r1 и r2 не менялся.
- Backend/schema — как и в r1, `custom_components/houseplan/**/*.py` вне
scope ТЗ, отдельно не сверялся.
- Полный повторный аудит секций 7.2/7.3/8.1/9–12/15/18/19, не затронутых
диффом r1→r2, — прочитан целиком в рамках этого цикла (не только
построчный git diff), но без повторного code-review каждого отдельного
утверждения, ранее уже подтверждённого в r1 по неизменному коду; проверялась
внутренняя согласованность с новым текстом §7.1/§8, а не повторная сверка с
исходниками с нуля.
## Вывод
High-1 из r1 устранён без побочных эффектов и без появления новых High/Medium
находок. Новый minimum-vertex gate — прямая и арифметически верная аналогия
уже существующего защитного гейта ручного замыкания, покрыта собственным AC,
unit- и smoke-пунктом и строкой риска. Решение не деградирует ни один
соседний сценарий и не меняет поведение, для которого issue #138 не заявляла
изменений. Остальной контракт (проёмы, толщина, drafts/partitions,
Cancel/Save/history, touch-деградация, release-артефакты) не менялся с r1 и
остаётся проверенным и корректным.
Готово к разработке (`S5-ready`).
+459
View File
@@ -0,0 +1,459 @@
# Issue #138 — автозамыкание комнаты по существующей стене
- **Issue:** https://github.com/Matysh/houseplan-card/issues/138
- **Редакция:** r2 после High из `docs/reviews/SPEC-REVIEW-138-r1.md`;
статус определяется только метками issue
- **Тип / приоритет:** bug / P1
- **Оценка:** пользовательская ценность 9/10; ценность для разработки 7/10;
сложность 5/10; риск 7/10
- **Область:** редактор Плана, инструмент «Контур комнаты», архитектурная
endpoint/line-привязка #137, замыкание и сохранение room draft
- **Модель данных:** без новых полей, миграции и backend-изменений
- **Связано:** #137, `docs/SCOPE.md`, `docs/ARCHITECTURE.md`,
`docs/CANVAS.md`, `docs/WALL-THICKNESS.md`, `docs/TOUCH-SUPPORT.md`
## 1. Сценарий и продуктовый контекст
**Персона:** администратор дома, который дорисовывает соседнее помещение в
редакторе Плана.
**Поверхность:** desktop browser с мышью или точным pointer. Touch editor остаётся
best effort по `docs/TOUCH-SUPPORT.md`.
**Момент:** пользователь начинает новый контур на углу либо середине стены
существующей комнаты, обходит новое помещение и заканчивает контур на другом месте
той же непрерывной стены.
Задача поддерживает:
- **J4:** план можно точно нарисовать встроенным GUI без ручного редактирования SVG;
- **J6:** соседние помещения используют общую границу без микрозазоров и лишних
обходных действий.
## 2. Что человек увидит до и после
**До:** #137 точно привязывает финальный клик к существующему углу или линии, но
редактор сохраняет его как ещё одну точку открытого draft. Диалог новой комнаты не
открывается, хотя последний и первый узлы уже однозначно соединены существующей
сплошной стеной.
**После:** если первая и последняя точки нового контура лежат на одном непрерывном
сплошном интервале существующей стены, финальный клик сразу использует участок этой
стены как замыкающее ребро и открывает обычный диалог новой комнаты.
## 3. Причина дефекта
`_resolvePlanDrawPoint()` уже возвращает точную endpoint- или line-snap координату,
но `_markupClick()` считает контур замкнутым только при клике в собственную первую
точку либо при `Ctrl`/`Cmd` + click. При клике в другую точку существующей стены
результат snap добавляется в `_path` и сохраняется в `room_drafts`, после чего
рисование остаётся открытым.
Это не ошибка точности #137: отсутствует продуктовый контракт, связывающий два узла
одного существующего сплошного wall interval с неявным замыкающим ребром.
## 4. Решения владельца
Владелец подтвердил 2026-08-14 все предложенные defaults:
1. Автозамыкание действует только тогда, когда первая и последняя точки лежат на
одном прямом каноническом ребре существующей комнаты. Допустимы исходные
endpoints/углы и промежуточные line-snap точки. Разные рёбра одной комнаты не
подходят.
2. Door/window/gate и `open_span` разрывают допустимую стену. Первая и последняя
точки должны принадлежать одному непрерывному видимому сплошному интервалу #137.
3. Подходящий финальный клик сразу добавляет существующий интервал как неявное
замыкающее ребро и открывает стандартный диалог новой комнаты, без отдельного
подтверждения.
4. Новая визуальная индикация и новые строки не нужны: используются увеличенная
snap-точка, live-preview и существующий диалог.
5. Приоритет задачи — P1.
## 5. Scope
В issue входят:
1. Распознавание подходящего финального клика инструмента «Контур комнаты» после
актуального endpoint/line resolution #137.
2. Проверка принадлежности первой и финальной точек одному room-owned solid
interval из общего архитектурного snap snapshot.
3. Автозамыкание валидного контура существующим участком стены и открытие обычного
диалога комнаты.
4. Сохранение существующих validation, draft, per-segment thickness, history,
Cancel/Save и Undo/Redo контрактов.
5. Unit и production-bundle smoke для положительных, отрицательных и touch-safety
сценариев.
6. Обновление пользовательской и архитектурной документации.
## 6. Non-scope
В issue не входят:
- автозамыкание между разными рёбрами одной или разных комнат;
- поиск пути по нескольким коллинеарным либо угловым стенам;
- автозамыкание по `room_drafts`, `partitions`, колоннам, декору или подложке;
- проведение замыкающего ребра через дверь, окно, ворота или виртуальную границу;
- автоматическое дробление, переписывание или удаление существующей стены;
- новая кнопка, настройка, toast, анимация, цвет или i18n-ключ;
- изменение endpoint/line hit radius, приоритета и визуального слоя #137;
- исправление старой off-grid геометрии;
- новая schema, backend API, storage key, импорт/экспорт или миграция;
- полный hover-паритет редактора на coarse pointer.
## 7. Контракт распознавания
### 7.1 Порядок разрешения клика
Финальный click/tap сначала повторно получает авторитетную точку через действующий
resolver #137. Hover-кандидат не используется как сохранённое обещание. Проверка
автозамыкания выполняется до обычного добавления точки в draft и до `_draftEndAt()`,
чтобы существующий endpoint draft не перехватил подходящий клик.
Существующие явные варианты имеют приоритет и не меняются:
1. клик в собственную первую точку замыкает контур как сейчас;
2. `Ctrl`/`Cmd` + click выполняет существующее быстрое замыкание;
3. только отличный от первой точки обычный клик, после добавления которого в
prospective path будет не меньше трёх вершин, проверяется как новое
автозамыкание по существующей стене.
Повторный клик в текущий anchor остаётся no-op и не создаёт нулевой сегмент.
Если в path пока находится только первая точка `A`, второй клик `B` не считается
попыткой автозамыкания даже при общем solid interval `A—B`: он без validation
toast добавляется в draft обычным способом. Это тот же минимальный gate, который
уже защищает ручное замыкание кликом в первую точку.
### 7.2 Допустимый существующий интервал
После прохождения gate минимального числа вершин автозамыкание разрешено, если
существует хотя бы один сегмент текущего архитектурного snap snapshot, который
одновременно:
- имеет `sourceKind: room` и происходит из завершённого контура комнаты;
- является одним прямым сплошным интервалом после канонических cuts #137;
- содержит первую точку нового контура;
- содержит финальную resolved-точку нового контура;
- имеет положительную длину, а две точки на нём различны.
«Содержит» означает коллинеарность и положение внутри закрытого интервала с
геометрической точностью существующих pure helpers, а не визуальную близость в CSS
pixels. Если угол принадлежит двум стенам, достаточно одного общего подходящего
интервала для первой и финальной точки. Стабильный порядок решает технические ties,
не меняя полученную линию замыкания.
`room_draft` и `partition` остаются видимыми snap-кандидатами #137, но не могут
стать неявной границей новой комнаты.
### 7.3 Проёмы и виртуальные участки
Используется уже разрезанная геометрия #137:
- door/window/gate и `open_span` удаляются из room axis до проверки;
- граница cut не становится новым постоянным endpoint;
- точки по разные стороны любого cut не принадлежат одному solid interval и не
запускают автозамыкание;
- точка внутри cut не получает line-snap, как и до этой задачи.
Новый код не вводит второй resolver проёмов или виртуальных границ.
## 8. Контракт замыкания и валидации
### 8.1 Успешный финальный клик
Пусть `A` — первая точка draft, `P` — его текущий конец, `B` — отличный от `A`
resolved-финал на общем solid interval `A—B`.
Проверка автозамыкания начинается только когда до клика path уже содержит минимум
две вершины, поэтому добавление `B` создаёт prospective path минимум из трёх
вершин. Для подходящего клика редактор рассматривает prospective polygon
`[..., P, B] + B—A` и требует минимум три различные вершины. До пользовательского
диалога он применяет те же проверки минимального размера, нулевой площади,
self-intersection, overlap и общих geometry limits, что и обычное замыкание.
Если контур валиден, одним пользовательским действием:
1. `B` становится настоящей последней вершиной нового контура;
2. нарисованный сегмент `P—B` сохраняется как обычный завершённый draft segment с
выбранной для него толщиной;
3. `B—A` считается замыкающим ребром комнаты;
4. открывается существующий диалог имени/HA-зоны комнаты.
Отдельного confirm, toast об успехе или промежуточного UI нет.
### 8.2 Невалидный prospective contour
Если при достаточном количестве вершин подходящий клик дал невалидный prospective
polygon:
- диалог комнаты не открывается;
- `B`, `P—B` и `B—A` не записываются;
- draft остаётся в том же редактируемом состоянии, что до клика;
- показывается существующий подходящий validation toast;
- config, layout и history не получают частично применённой операции.
Клик не превращается после ошибки в обычное добавление `B`: подходящая точка уже
означает намерение замкнуть контур, как клик в собственную первую точку.
Недостаточное количество вершин не является такой ошибкой: оно отсекается до
eligibility и обрабатывается как обычный клик по §8.3 без toast.
### 8.3 Обычный клик
Если общего room-owned solid interval нет либо после добавления resolved-точки в
path всё ещё будет меньше трёх вершин, поведение остаётся полностью текущим:
resolved-точка добавляется в открытый draft, сегмент сохраняется, а диалог не
открывается. Это относится в том числе к:
- второму клику `B` после единственной первой точки `A`, даже если `A` и `B` —
разные endpoints одной существующей стены;
- разным рёбрам одной комнаты;
- разным комнатам;
- точкам по разные стороны opening/open-span cut;
- `room_draft` и `partition`;
- обычной точке сетки рядом со стеной.
После такого клика пользователь по-прежнему может явно замкнуть контур первой
точкой или `Ctrl`/`Cmd` + click.
## 9. Draft, диалог, история и толщина
- Каждый явно нарисованный сегмент до и включая `P—B` сохраняет текущую толщину в
`room_drafts` по существующему контракту.
- Неявный `B—A` не создаёт independent partition или отдельную физическую запись.
Он становится room-boundary edge только при сохранении комнаты.
- **Save** удаляет соответствующий draft и создаёт обычную комнату через текущий
commit/history boundary.
- **Cancel** стандартного диалога снимает состояние замкнутого контура, но оставляет
открытый draft с последней настоящей точкой `B` и сегментом `P—B`, как при
существующем ручном замыкании.
- **Оставить замкнутыми стенами** продолжает использовать текущий путь конвертации
рёбер в independent partitions.
- Undo/Redo, смена инструмента, reload/resume, external config adoption и лимит
50 команд не получают отдельной ветки поведения.
- На общей границе `B—A` сохраняется уже существующая толщина соседней комнаты.
Выбранная толщина рисования применяется только к новым внешним участкам по
действующему `applyWallThicknessToNewRoom`-контракту.
- Room-boundary walls остаются производными и дедуплицированными. Автозамыкание не
создаёт вторую физическую стену поверх общей границы.
## 10. Touch, accessibility и визуальная деградация
**Touch editor: best effort / intentionally degraded.**
- Tap без предварительного pointermove повторно решает snap и выполняет тот же
контракт автозамыкания.
- Hover или увеличенная точка до tap на no-hover устройстве не обещаются.
- Pinch, pan, pointercancel, второй touch и suppressed synthetic click не должны
замыкать или менять draft.
- Новый DOM, focus target, ARIA-содержимое или клавиатурная команда не создаются.
- View и kiosk не меняют pixels, gestures или действия.
## 11. Модель данных, совместимость и миграция
Новых данных нет.
- `rooms[].poly`, `room_drafts`, `walls`, `openings` и `open_spans` сохраняют
текущую схему;
- старые и импортированные планы читаются без миграции;
- backend validation и integration API не меняются;
- downgrade не требует data rollback: созданная комната является обычным polygon;
- новая версия меняет только момент интерпретации подходящего финального клика.
## 12. Архитектурный и performance-контракт
1. Проверка общего интервала является чистой геометрической операцией над
существующим immutable snap snapshot; SVG DOM не является источником данных.
2. Canonical opening/open-span cuts переиспользуются из `plan-snap-overlay.ts` или
эквивалентного общего helper, без дублирования wall topology.
3. Проверка выполняется только на click/tap. Она не добавляет работу в pointermove,
не пересобирает статическую геометрию и не меняет O(E) overlay DOM #137.
4. Линейный O(S) просмотр room-owned solid segments на click допустим. Новый
постоянно растущий cache или spatial index не требуется.
5. Prospective validation завершается до mutation. Успех использует существующие
draft/save/history границы; ошибка не требует rollback частичного состояния.
6. Решение не создаёт websocket, HA service, fetch, timer, storage key или внешнюю
зависимость.
## 13. Acceptance criteria
- **AC1 (`unit` + `smoke`; разработчик):** контур, начатый в endpoint `A`
завершённой room wall, после обхода нового помещения заканчивается в другом
endpoint `B` того же непрерывного solid interval; финальный click сохраняет
`P—B`, замыкает polygon через `B—A` и сразу открывает стандартный room dialog.
- **AC2 (`unit` + `smoke`; разработчик):** тот же результат получается, когда `A`,
`B` или обе точки являются line-snap точками внутри одного solid room interval;
сохранённые координаты остаются точно на существующей стене.
- **AC3 (`unit` + `smoke`; разработчик):** если path содержит только первую точку
`A`, второй click в отличный endpoint/line-node `B` того же solid room interval
не запускает автозамыкание и validation: `B` добавляется как обычная точка
открытого draft без dialog и toast, после чего контур можно продолжить.
- **AC4 (`unit` + `smoke`; разработчик):** разные рёбра одной комнаты, стены разных
комнат, saved draft и partition не запускают автозамыкание: финальная точка
остаётся обычной точкой открытого draft, а явное замыкание первой точкой и
`Ctrl`/`Cmd` продолжают работать.
- **AC5 (`unit` + `smoke`; разработчик):** door/window/gate или `open_span` между
`A` и `B` разрывает общий interval и не допускает автозамыкание; cut boundary не
становится новым endpoint и отдельный resolver проёмов не появляется.
- **AC6 (`unit` + `smoke`; разработчик):** при достаточном числе вершин zero-area,
self-intersecting, overlapping, слишком маленький или выходящий за лимиты
prospective contour не
открывает диалог, показывает существующий validation toast и оставляет draft,
config и history в состоянии до финального клика.
- **AC7 (`unit` + `smoke`; разработчик):** успешный финальный `P—B` сохраняет
выбранную толщину; Cancel оставляет открытый draft с `B`, Save создаёт комнату и
удаляет draft, а secondary action создаёт замкнутые стены по текущему контракту.
- **AC8 (`unit` + wall-thickness regression; разработчик):** `B—A` использует
существующую толщину общей стены, новые внешние рёбра сохраняют свои per-segment
значения, room geometry не создаёт duplicate partition или двойное физическое
wall body.
- **AC9 (`unit` + code review; разработчик/ревьюер):** собственная первая точка и
`Ctrl`/`Cmd` имеют прежний приоритет, current anchor остаётся no-op, а eligibility
решается до resume/draft endpoint handling.
- **AC10 (`smoke`; разработчик):** tap без hover выполняет тот же результат; pan,
pinch, pointercancel и suppressed synthetic click не меняют geometry. View,
kiosk, остальные Plan tools и другие editors не меняются.
- **AC11 (`unit` + performance review; разработчик/ревьюер):** общий interval
определяется из уже кэшированного snapshot чистым O(S) helper только на click;
pointermove, cache size, DOM count и network/storage activity #137 не растут.
- **AC12 (`typecheck` + `unit` + `build` + documentation review; разработчик):**
implementation-loop gates зелёные, три bundle-копии побайтно одинаковы,
пользовательская документация и оба changelog обновлены в том же видимом
коммите.
- **AC13 (`schema/security review`; ревьюер):** backend, schema, import/export,
i18n, HA permissions/calls и зависимости не меняются; старые планы совместимы.
## 14. План автотестов
### 14.1 Unit
Добавить pure-helper покрытие:
1. оба endpoints одного room segment дают общий interval;
2. endpoint + interior point и две interior line-snap точки дают общий interval;
3. при path `[A]` второй click `B` на том же room interval не вызывает eligibility,
dialog или toast, а добавляет `B` и `A—B` в открытый draft;
4. угол, принадлежащий двум рёбрам, соединяется только с точкой на одном из них;
5. разные рёбра/rooms, draft и partition возвращают отсутствие eligibility;
6. opening/open-span cut разделяет исходную прямую на разные intervals;
7. reversed segment, floating tolerance и stable tie дают тот же результат;
8. одинаковые `A/B`, zero-length и current anchor не подходят;
9. prospective polygon валидируется до mutation для success и каждого класса
существующей ошибки;
10. shared-wall thickness и внешние per-segment thickness сохраняются.
Каждый тест должен падать отдельно при ослаблении `sourceKind`, игнорировании cut,
смешивании разных рёбер, добавлении точки до validation или перезаписи толщины.
### 14.2 Targeted browser smoke
Расширить `demo/smoke_plan_snap_overlay.mjs` либо добавить отдельный
production-bundle smoke:
1. создать существующую комнату, opening/open span, draft и partition;
2. начать `A→B` по двум точкам одной существующей стены и проверить, что второй
click добавляет `B` без dialog/toast и оставляет открытый draft;
3. нарисовать соседний контур endpoint→внешние точки→endpoint той же стены и
проверить немедленное открытие room dialog;
4. повторить с mid-line point и diagonal room edge;
5. доказать отрицательные случаи different edge, cut, draft и partition;
6. доказать отсутствие partial write при self-intersection/overlap;
7. проверить Cancel, Save, secondary action, reload/resume и Undo/Redo;
8. проверить inherited shared thickness и разные толщины внешних сегментов;
9. повторить tap без pointermove и gesture-safety случаи.
Targeted smoke пишется вместе с кодом; полный smoke-suite запускается перед бетой.
### 14.3 Golden и performance
Новых pixels, стилей и состояний overlay нет, поэтому новый golden baseline не
нужен. Существующие editor/View golden должны остаться без изменений; любое
изменение baseline требует отдельного объяснения и review.
Отдельный performance fixture не нужен: новый O(S) поиск выполняется только на
click. Перед бетой обязательны существующие performance smoke и Full Performance
на точном SHA без ослабления budgets.
### 14.4 Backend
Backend не меняется. Нового backend-теста не требуется; полный Linux Validate
остаётся release gate.
## 15. План реализации
1. Добавить чистый helper принадлежности двух точек одному room-owned solid
segment в snap geometry #137.
2. Отделить prospective validation от mutation либо дать текущему close helper
безопасно проверить путь с новым терминальным `B`.
3. Подключить eligibility в `_markupClick()` после click re-resolution и до
обычного draft/resume handling.
4. На success сохранить `P—B` обычным draft-механизмом и открыть существующий
dialog с `B—A`; на failure не менять state.
5. Добавить unit и production-bundle smoke.
6. Обновить `docs/ARCHITECTURE.md`, `docs/CANVAS.md`,
`docs/USER-GUIDE.ru.md`, оба changelog и три bundle-копии.
Имена helper-функций и приватных полей не являются продуктовым контрактом.
## 16. Release-артефакты
Изменение пользовательское: implementation-коммит имеет `User-Visible: yes` и в
том же коммите обновляет:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — автозамыкание соседней комнаты;
- `docs/CANVAS.md` и `docs/ARCHITECTURE.md` — внутренний snap/markup контракт;
- три поставляемые bundle-копии.
Новый screenshot/golden не требуется, потому что визуал #137 не меняется.
Перед бетой обязательны:
- exact-SHA Linux Validate;
- полный smoke-suite;
- существующие golden без необъяснённого diff;
- performance smoke и Full Performance на точном SHA;
- code review с отрицательным security/network/schema verdict.
Отдельный security report не нужен: новых внешних данных, HTML input, HA calls,
network или storage путей нет. Публикация проходит через бету до stable.
## 17. Риски и меры
| Риск | Вероятность / влияние | Мера |
|---|---|---|
| Разные рёбра ошибочно считаются одной стеной | средняя / высокий | проверять один canonical segment, unit на corner/different edge |
| Автозамыкание проходит через проём | средняя / высокий | использовать cut snapshot #137, unit + smoke opening/open-span |
| Второй узел общей стены ошибочно трактуется как невалидное замыкание | средняя / высокий | minimum-vertex gate до eligibility, отдельные unit + smoke |
| Невалидный click частично сохраняет draft | средняя / высокий | prospective validation до mutation, state/history assertions |
| Resume endpoint перехватывает финальный click | средняя / высокий | eligibility до `_draftEndAt()`, integration smoke |
| Общая стена получает новую толщину | средняя / высокий | действующий wall inheritance helper + regression matrix |
| Cancel теряет финальный нарисованный сегмент | средняя / средний | явный Cancel contract и smoke snapshot draft |
| Поведение touch расходится с click | низкая / средний | re-resolve на tap, gesture-safety regression |
| Click начинает пересобирать overlay | низкая / средний | использовать cached snapshot, performance/code review |
## 18. Откат
Откат — revert implementation-коммита #138 вместе с тестами, документацией,
changelog и bundle-копиями. Persisted schema не меняется, поэтому data rollback и
миграция не нужны. Комнаты, сохранённые новой версией, являются обычными polygons и
полностью читаются предыдущей версией.
## 19. Принятые технические предположения — можно менять без продуктового ревью
1. Eligibility helper рекомендуется разместить в `src/plan-snap-overlay.ts` и
передавать ему готовый snapshot; точное имя и сигнатура свободны.
2. Геометрическая точность использует существующий `samePoint`/segment epsilon или
более строгий эквивалент, совместимый с grid- и wall-bound координатами. Она не
превращается в новую CSS hit tolerance.
3. При нескольких одинаково подходящих room segments применяется стабильный ключ
#137; persisted id результата не нужен.
4. Prospective validation может быть вынесена из `_closeRoomContour()` в pure helper
либо параметризовать существующий путь, если success/error contract не меняется.
5. Успешное добавление `B` может использовать текущий `_persistActiveDraftSegment()`;
отдельной транзакции backend не требуется.
6. При достаточном числе вершин geometry validation error не добавляет `B`. Если
же `B` была бы только второй вершиной, eligibility не запускается и `B`
добавляется обычным способом без ошибки; это воспроизводит существующий
minimum-vertex gate ручного замыкания.
7. Никаких открытых продуктовых вопросов нет: Q1–Q5 и технические defaults приняты
владельцем 2026-08-14.
+1
View File
@@ -46,6 +46,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) |
| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) |
| [#131](https://github.com/Matysh/houseplan-card/issues/131) Полный первый кадр View у read-only-пользователя | [131-readonly-cold-start.md](131-readonly-cold-start.md) |
| [#138](https://github.com/Matysh/houseplan-card/issues/138) Автозамыкание комнаты по существующей стене | [138-adjacent-room-autoclose.md](138-adjacent-room-autoclose.md) |
## P2
+65 -23
View File
@@ -132,7 +132,7 @@ import {
physicalBodies, physicalBodySet,
} from './physical-geometry';
import {
buildPlanSnapGeometry, resolvePlanSnap,
buildPlanSnapGeometry, findSharedRoomSnapSegment, resolvePlanSnap,
type PlanSnapCandidate, type PlanSnapGeometry, type PlanSnapSegment,
} from './plan-snap-overlay';
import {
@@ -6424,27 +6424,33 @@ class HouseplanCard extends LitElement {
return false;
}
/** Validate and close the draft without using the closing click as a vertex. */
private _closeRoomContour(showMinimumError = false): void {
/** Validate one prospective room ring without mutating the live draft. */
private _validateRoomContour(
path: number[][], showMinimumError = false,
): { closingCm: number } | null {
// Three placed vertices mean two existing edges; the closing edge becomes
// the third one. Anything shorter cannot enclose a room.
if (this._path.length < 3) {
if (path.length < 3) {
if (showMinimumError) this._showToast(this._t('toast.contour_min_edges'));
return;
return null;
}
const closingCm = this._drawWallCm;
if (closingCm == null) { this._showPhysicalRange(100); return; }
if (this._contourSelfIntersects(this._path) || polygonArea(this._path) <= 1e-6) {
if (closingCm == null) { this._showPhysicalRange(100); return null; }
if (this._contourSelfIntersects(path) || polygonArea(path) <= 1e-6) {
this._showToast(this._t('toast.contour_cannot_close'));
return; // keep the draft editable
return null; // keep the draft editable
}
// A contour can enclose an existing room without any vertex inside it.
const clash = this._overlapRoom(this._path);
const clash = this._overlapRoom(path);
if (clash) {
this._showToast(this._t('toast.room_overlap', { name: clash.name || '' }));
return;
return null;
}
this._path = [...this._path, [...this._path[0]]];
return { closingCm };
}
private _openRoomContourDialog(path: number[][], closingCm: number): void {
this._path = [...path, [...path[0]]];
this._closingWallCm = closingCm;
this._clearPlanSnapHover();
this._nameSel = '';
@@ -6453,6 +6459,52 @@ class HouseplanCard extends LitElement {
this._roomDialog = true;
}
/** Validate and close the draft without using the closing click as a vertex. */
private _closeRoomContour(showMinimumError = false): void {
const valid = this._validateRoomContour(this._path, showMinimumError);
if (!valid) return;
this._openRoomContourDialog(this._path, valid.closingCm);
}
/** The same append limits guard both an ordinary point and an auto-close terminal point. */
private _canAppendRoomDraftPoint(): boolean {
if (this._drawWallCm == null) { this._showPhysicalRange(100); return false; }
if (this._path.length >= MAX_DRAFT_POINTS) {
this._showToast(this._t('toast.physical_limit'));
return false;
}
const spCfg = this._curSpaceCfg as any;
const newDraft = !this._activeDraftId;
if ((newDraft && (spCfg?.room_drafts || []).length >= MAX_ROOM_DRAFTS)
|| this._draftSegmentCount(spCfg) >= MAX_DRAFT_SEGMENTS) {
this._showToast(this._t('toast.physical_limit'));
return false;
}
return true;
}
/** Consume a qualifying click whether it closes successfully or reports a validation error. */
private _tryAutoCloseRoomContour(pt: number[]): boolean {
// One existing point plus B is only a line. Preserve the ordinary second
// click, symmetrically with the existing first-point closure gate.
if (this._path.length < 2) return false;
const shared = findSharedRoomSnapSegment(
this._planSnapGeometrySnapshot().value,
this._path[0],
pt,
this._gridPitch * 0.0002,
);
if (!shared) return false;
if (!this._canAppendRoomDraftPoint()) return true;
const prospective = [...this._path, pt];
const valid = this._validateRoomContour(prospective);
if (!valid) return true;
this._path = prospective;
this._persistActiveDraftSegment();
this._openRoomContourDialog(this._path, valid.closingCm);
return true;
}
private _markupClick(ev: MouseEvent): void {
if (this._vacFit) return; // the fit overlay owns all pointer input
if (!this._markup) return;
@@ -6555,23 +6607,13 @@ class HouseplanCard extends LitElement {
this._closeRoomContour();
return;
}
if (this._tryAutoCloseRoomContour(pt)) return;
const join = this._draftEndAt(pt, this._activeDraftId || undefined);
if (join) {
this._mergeDraftEndpoint(join);
return;
}
if (this._drawWallCm == null) { this._showPhysicalRange(100); return; }
if (this._path.length >= MAX_DRAFT_POINTS) {
this._showToast(this._t('toast.physical_limit'));
return;
}
const spCfg = this._curSpaceCfg as any;
const newDraft = !this._activeDraftId;
if ((newDraft && (spCfg?.room_drafts || []).length >= MAX_ROOM_DRAFTS)
|| this._draftSegmentCount(spCfg) >= MAX_DRAFT_SEGMENTS) {
this._showToast(this._t('toast.physical_limit'));
return;
}
if (!this._canAppendRoomDraftPoint()) return;
this._path = [...this._path, pt];
this._persistActiveDraftSegment();
}
+41 -1
View File
@@ -101,6 +101,10 @@ function segmentKey(source: SourceSegment, a: readonly number[], b: readonly num
return `${sourceKey(source)}|${pointKey(ca)}|${pointKey(cb)}`;
}
function sourceRank(kind: PlanSnapSourceKind): number {
return kind === 'room' ? 0 : kind === 'draft' ? 1 : 2;
}
function touches(point: readonly number[], segment: readonly number[], epsilon: number): boolean {
return pointsEqual(point, [segment[0], segment[1]], epsilon)
|| pointsEqual(point, [segment[2], segment[3]], epsilon);
@@ -171,7 +175,10 @@ export function buildPlanSnapGeometry(options: BuildPlanSnapGeometryOptions): Pl
sourceId: source.id,
};
const existing = segmentsByAxis.get(axisKey);
if (!existing || candidate.key.localeCompare(existing.key) < 0) {
if (!existing
|| sourceRank(candidate.sourceKind) < sourceRank(existing.sourceKind)
|| (sourceRank(candidate.sourceKind) === sourceRank(existing.sourceKind)
&& candidate.key.localeCompare(existing.key) < 0)) {
segmentsByAxis.set(axisKey, candidate);
}
}
@@ -189,6 +196,39 @@ export function buildPlanSnapGeometry(options: BuildPlanSnapGeometryOptions): Pl
};
}
function pointOnSnapSegment(
point: readonly number[], segment: PlanSnapSegment, epsilon: number,
): boolean {
const dx = segment.b[0] - segment.a[0];
const dy = segment.b[1] - segment.a[1];
const length = Math.hypot(dx, dy);
if (!(length > epsilon)) return false;
const ux = dx / length;
const uy = dy / length;
const px = point[0] - segment.a[0];
const py = point[1] - segment.a[1];
const along = px * ux + py * uy;
const perpendicular = Math.abs(px * uy - py * ux);
return perpendicular <= epsilon && along >= -epsilon && along <= length + epsilon;
}
/**
* Return the stable completed-room solid interval that contains both points.
* Because room openings and open spans are cut while the snapshot is built,
* points on opposite sides of a gap can never share a returned segment.
*/
export function findSharedRoomSnapSegment(
geometry: PlanSnapGeometry,
a: readonly number[],
b: readonly number[],
epsilon = DEFAULT_EPSILON,
): PlanSnapSegment | null {
if (!finitePoint(a) || !finitePoint(b) || pointsEqual(a, b, epsilon)) return null;
return geometry.segments.find((segment) => segment.sourceKind === 'room'
&& pointOnSnapSegment(a, segment, epsilon)
&& pointOnSnapSegment(b, segment, epsilon)) || null;
}
function isExcluded(
point: readonly number[],
excluded: readonly (readonly number[])[],
+46
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildPlanSnapGeometry,
findSharedRoomSnapSegment,
resolvePlanSnap,
} from '../test-build/plan-snap-overlay.js';
@@ -71,6 +72,51 @@ test('room cuts leave solid intervals but do not create cut-boundary endpoints',
'an original endpoint remains when another solid wall still meets it');
});
test('shared-room interval contains endpoints and interior wall-bound points only on one edge', () => {
const geometry = buildPlanSnapGeometry({
space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }),
});
assert.ok(findSharedRoomSnapSegment(geometry, [0, 0], [100, 0]));
assert.ok(findSharedRoomSnapSegment(geometry, [0, 0], [40, 0]));
assert.ok(findSharedRoomSnapSegment(geometry, [20, 0], [80, 0]));
assert.equal(findSharedRoomSnapSegment(geometry, [0, 0], [100, 100]), null,
'different room edges never imply an auto-closing wall');
assert.equal(findSharedRoomSnapSegment(geometry, [40, 0], [40, 0]), null,
'one point cannot define a closing interval');
assert.equal(findSharedRoomSnapSegment(geometry, [-1, 0], [40, 0], 0.001), null,
'collinearity outside the closed segment is insufficient');
});
test('shared-room interval respects cuts and rejects draft or partition-only axes', () => {
const cut = buildPlanSnapGeometry({
space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }),
roomCuts: [[40, 0, 60, 0]],
});
assert.ok(findSharedRoomSnapSegment(cut, [0, 0], [30, 0]));
assert.equal(findSharedRoomSnapSegment(cut, [0, 0], [100, 0]), null,
'opening or open-span cuts split eligibility');
const independent = buildPlanSnapGeometry({
space: space({
room_drafts: [{ id: 'draft', points: [[0, 10], [100, 10]], segments: [{ cm: 15 }] }],
partitions: [{ id: 'partition', a: [0, 20], b: [100, 20], cm: 15 }],
}),
});
assert.equal(findSharedRoomSnapSegment(independent, [0, 10], [100, 10]), null);
assert.equal(findSharedRoomSnapSegment(independent, [0, 20], [100, 20]), null);
});
test('a completed room remains the authority for a coincident deduplicated axis', () => {
const geometry = buildPlanSnapGeometry({
space: space({
rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }],
partitions: [{ id: 'partition', a: [100, 0], b: [0, 0], cm: 15 }],
}),
});
const shared = findSharedRoomSnapSegment(geometry, [0, 0], [100, 0]);
assert.equal(shared?.sourceKind, 'room');
});
test('endpoint wins over a closer line and tie resolution is stable', () => {
const geometry = buildPlanSnapGeometry({
space: space({