mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
435b933914 | ||
|
|
ca7dbeda92 | ||
|
|
1dbded5b73 | ||
|
|
db7cf9d5f3 | ||
|
|
6704cfc925 | ||
|
|
3a84c7f6d7 | ||
|
|
611c5a76f5 |
File diff suppressed because one or more lines are too long
@@ -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
Vendored
+65
-65
File diff suppressed because one or more lines are too long
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Новый контур комнаты, начатый и законченный на одном непрерывном сплошном
|
||||
участке стены существующей комнаты, теперь замыкается автоматически и
|
||||
открывает обычный диалог комнаты. Одинаково работают конечные узлы и точки
|
||||
T-соединения на линии; проёмы, намеренные разрывы, разные грани и второй клик
|
||||
без достаточного числа сторон быстрым замыканием не считаются
|
||||
([#138](https://github.com/Matysh/houseplan-card/issues/138)).
|
||||
- Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая
|
||||
активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые
|
||||
углы получают ограниченный mitre/bevel, T-соединение не показывает торец,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -263,6 +263,12 @@ desktop: для точного рисования, Resize, модификато
|
||||
5. Замкните контур кликом по первой точке или `Ctrl` + кликом в любом месте.
|
||||
Быстрое замыкание доступно после двух нарисованных граней и не сработает,
|
||||
если замыкающая стена создаёт самопересечение.
|
||||
Если первая и текущая точки лежат на одном непрерывном сплошном участке
|
||||
стены существующей комнаты, текущий клик сам добавит последнюю грань,
|
||||
замкнёт контур общей стеной и откроет тот же диалог. Можно попадать как в
|
||||
конечный узел, так и в точку на линии. Проём или намеренный разрыв разделяет
|
||||
участок, разные грани не объединяются, а второй клик после единственной
|
||||
первой точки остаётся обычным незамкнутым отрезком.
|
||||
6. В диалоге задайте название, выберите свободную HA-зону либо оставьте в списке «— без зоны —», затем нажмите «Сохранить».
|
||||
|
||||
Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
|
||||
|
||||
@@ -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 доказаны автотестом (с подтверждённой способностью
|
||||
падать) либо разобраны по коду с явной записью «проверено чтением, не
|
||||
исполнением». Задача готова к очереди на пре-релиз.
|
||||
@@ -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-тестом.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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[])[],
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user