fix: auto-close rooms along shared walls

Issue: #138
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-14 12:59:09 +00:00
committed by claude[bot]
parent d66cd2ebab
commit f66cf8ad4d
13 changed files with 545 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`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
+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({