feat v1.37.0: open boundaries (virtual walls)

- room.open_to symmetric links; 'Open boundary' plan tool toggles the
  shared wall pair under the click (sharedBoundary collinear-overlap
  math + distToSegment pull, same as Split); dashed rendering with an
  amber hot state while the tool is active; Esc -> Draw
- glow: the clip becomes the transitive open zone (openZoneOf BFS,
  either-direction links) + door sectors from the zone's outer walls
- model rooms carry open_to; backend ROOM_SCHEMA open_to: [str]
- +3 unit tests (104), smoke_openwall.mjs (8 checks); docs same-commit
This commit is contained in:
Matysh
2026-07-23 14:18:50 +03:00
parent 3a89966e4b
commit 20c7b55a87
17 changed files with 420 additions and 64 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.36.4"
VERSION = "1.37.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.36.4"
"version": "1.37.0"
}
@@ -68,6 +68,7 @@ ROOM_SCHEMA = vol.All(
vol.Required("id"): str,
vol.Required("name"): str,
vol.Optional("area"): vol.Any(str, None),
vol.Optional("open_to"): [str],
vol.Optional("x"): vol.Coerce(float),
vol.Optional("y"): vol.Coerce(float),
vol.Optional("w"): vol.Coerce(float),
+51
View File
@@ -0,0 +1,51 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// r1|r2 делят стену x=0.55 → клик по ней открывает границу
const H = 1000 / (c._curSpaceCfg.aspect || 1);
c._openWallClick([550, 0.25 * H]);
await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
const r2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
out.linked = (r1.open_to || []).includes('r2') && (r2.open_to || []).includes('r1');
// пунктир отрисован
out.dashes = sr().querySelectorAll('.openwall').length > 0;
out.hotClass = !!sr().querySelector('.openwalls.hot');
// повторный клик закрывает
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
out.toggledOff = !(c._curSpaceCfg.rooms.find((r) => r.id === 'r1').open_to || []).includes('r2');
out.dashesGone = sr().querySelectorAll('.openwall').length === 0;
// клик мимо стен — тост, без изменений
c._openWallClick([100, 100]);
out.missToast = !!c._toast;
// снова открыть для glow-теста
c._openWallClick([550, 0.25 * H]); await c.updateComplete;
// glow: свет из r1 заливает и r2 (clip содержит оба полигона)
c._setMode('view'); await c.updateComplete;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== c._space ? s : ({
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) };
const litLight = c._devices.find((d) => d.space === c._space && d.entities.some((e) => e.startsWith('light.') && c.hass.states[e]?.state === 'on'));
const c1 = c._roomCenter(c._spaceModel().rooms.find((r) => r.id === 'r1'));
const aspect = c._curSpaceCfg.aspect || 1;
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
c.requestUpdate(); await c.updateComplete;
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
// транзитивность: r2↔r3 тоже открыть → clip получит 3 полигона
const rr2 = c._curSpaceCfg.rooms.find((r) => r.id === 'r2');
const rr3 = c._curSpaceCfg.rooms.find((r) => r.id === 'r3');
if (rr3) {
rr2.open_to = [...(rr2.open_to || []), 'r3'];
rr3.open_to = [...(rr3.open_to || []), 'r2'];
c._regSignature = ''; c.requestUpdate(); await c.updateComplete;
const clip2 = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.transitive = clip2 ? clip2.querySelectorAll('path').length >= 3 : false;
} else out.transitive = 'no r3';
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+38 -17
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## v1.37.0 — 2026-07-23 (open boundaries — virtual walls)
- Rooms divided only by zoning can now share an **open boundary**: the new
"Open boundary" tool in the Plan editor toggles it with a click on the wall
two rooms share. The stretch renders dashed; while the tool is active open
boundaries highlight amber. Stored as `room.open_to` links by room id, so
redrawing/merging neighbours doesn't break them.
- In the light-sources fill, light flows through open boundaries freely —
transitively across the whole connected zone (kitchen ↔ living ↔ hall as
one open space), still limited by the glow radius. Door sectors now work
from any outer wall of the zone.
## v1.36.4 — 2026-07-23
- Glow: sharper light edge — the pool is fully lit for the inner 80% of the
radius, the gradient falloff lives only in the outer 20%.
+6
View File
@@ -140,6 +140,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(explicit ripple color still wins); off/white lights unchanged [auto]
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Open boundaries (v1.37.0): the Plan editor's "Open boundary" tool
toggles a virtual wall between two rooms by clicking their shared wall
(pull like Split; miss → toast); open stretches render dashed (amber
highlight while the tool is active); glow light floods the whole
connected open zone transitively, door sectors work from the zone's
outer walls; merge/split keep links by room id [auto]
- [ ] Sector wedge fix (v1.36.3): door sectors never darken the light INSIDE
the room — room outline and sectors are separate clipPath children
(union), not subpaths of one nonzero path [auto]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.36.4",
"version": "1.37.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+101 -8
View File
@@ -14,7 +14,7 @@ import {
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment,
snapToWall, openingAmount,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
@@ -32,14 +32,14 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.36.4';
const CARD_VERSION = '1.37.0';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
const NORM_W = 1000; // width of the render space for normalized configs
const GRID_N = 240; // grid points across the plan width (half the previous step; old nodes are a subset of the new ones, positions are preserved)
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'delroom';
type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
@@ -343,6 +343,11 @@ class HouseplanCard extends LitElement {
e.preventDefault();
if (this._mergeSel) this._mergeSel = null;
else this._tool = 'draw';
return;
}
if (this._tool === 'openwall' || this._tool === 'opening' || this._tool === 'delroom') {
e.preventDefault();
this._tool = 'draw';
}
}
@@ -417,6 +422,7 @@ class HouseplanCard extends LitElement {
id: r.id,
name: r.name,
area: r.area ?? null,
open_to: r.open_to || undefined,
x: r.x != null ? r.x * NORM_W : undefined,
y: r.y != null ? r.y * H : undefined,
w: r.w != null ? r.w * NORM_W : undefined,
@@ -1290,6 +1296,10 @@ class HouseplanCard extends LitElement {
this._mergeClick(raw);
return;
}
if (this._tool === 'openwall') {
this._openWallClick(raw);
return;
}
if (this._tool === 'split') {
this._splitClick(raw);
return;
@@ -1588,6 +1598,73 @@ class HouseplanCard extends LitElement {
</div>`;
}
/** Dashed strokes over open (virtual) boundaries; highlighted in the tool. */
private _renderOpenWalls(): TemplateResult {
const pairs = this._openPairs();
if (!pairs.length) return svg`` as unknown as TemplateResult;
const hot = this._markup && this._tool === 'openwall';
return svg`<g class="openwalls ${hot ? 'hot' : ''}">
${pairs.flatMap((p) => p.segs.map((sg) => svg`<line class="openwall"
x1="${sg[0]}" y1="${sg[1]}" x2="${sg[2]}" y2="${sg[3]}"></line>`))}
</g>` as unknown as TemplateResult;
}
/** All open-boundary pairs of the current space with their shared segments. */
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const a = rooms[i], b = rooms[j];
const linked = ((a as any).open_to || []).includes(b.id) || ((b as any).open_to || []).includes(a.id);
if (!linked) continue;
const pa = roomPoly(a), pb = roomPoly(b);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
if (segs.length) res.push({ a, b, segs });
}
return res;
}
/** Open-boundary tool: a click on a shared wall toggles its "virtual" state. */
private _openWallClick(raw: number[]): void {
const rooms = this._spaceModel().rooms.filter((r) => r.id);
const pull = this._gridPitch * 6;
let best: { a: RoomCfg; b: RoomCfg; d: number } | null = null;
for (let i = 0; i < rooms.length; i++)
for (let j = i + 1; j < rooms.length; j++) {
const pa = roomPoly(rooms[i]), pb = roomPoly(rooms[j]);
if (!pa || !pb) continue;
const segs = sharedBoundary(pa, pb, this._gridPitch * 0.02);
for (const seg of segs) {
const d = distToSegment(raw, seg);
if (d <= pull && (!best || d < best.d)) best = { a: rooms[i], b: rooms[j], d };
}
}
if (!best) {
this._showToast(this._t('toast.openwall_pick'));
return;
}
const sp = this._curSpaceCfg;
const ra = sp.rooms.find((r: any) => r.id === best!.a.id);
const rb = sp.rooms.find((r: any) => r.id === best!.b.id);
if (!ra || !rb) return;
const linked = (ra.open_to || []).includes(rb.id) || (rb.open_to || []).includes(ra.id);
if (linked) {
ra.open_to = (ra.open_to || []).filter((x: string) => x !== rb.id);
rb.open_to = (rb.open_to || []).filter((x: string) => x !== ra.id);
if (!ra.open_to.length) delete ra.open_to;
if (!rb.open_to.length) delete rb.open_to;
this._showToast(this._t('toast.openwall_closed', { a: ra.name || '', b: rb.name || '' }));
} else {
ra.open_to = [...(ra.open_to || []), rb.id];
rb.open_to = [...(rb.open_to || []), ra.id];
this._showToast(this._t('toast.openwall_opened', { a: ra.name || '', b: rb.name || '' }));
}
this._saveConfig();
this.requestUpdate();
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[]): void {
const eps = this._gridPitch * 1.5;
@@ -2700,15 +2777,25 @@ class HouseplanCard extends LitElement {
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
let clip: string[] | null = null;
if (home) {
const shapes: string[] = ['M ' + home.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z'];
// doorways of this room spill light into neighbouring rooms only
// open (virtual) boundaries: light flows through the whole connected
// zone of rooms, not just the source's own room (owner's spec)
const zoneIds = home.r.id ? openZoneOf(home.r.id, space.rooms) : new Set([home.r.id]);
const zone = polys.filter((x) => x.r.id && zoneIds.has(x.r.id));
const zoneList = zone.length ? zone : [home];
const shapes: string[] = zoneList.map(
(z) => 'M ' + z.poly.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z',
);
// doorways on the ZONE's walls spill light into rooms outside the zone
const others = polys.filter((x) => !zoneList.includes(x)).map((x) => x.poly);
for (const o of doors) {
const near = closestPointOnBoundary([o.rx, o.ry], home.poly);
if (!near || Math.hypot(near[0] - o.rx, near[1] - o.ry) > g * 0.75) continue;
const onZoneWall = zoneList.some((z) => {
const near = closestPointOnBoundary([o.rx, o.ry], z.poly);
return near && Math.hypot(near[0] - o.rx, near[1] - o.ry) <= g * 0.75;
});
if (!onZoneWall) continue;
const rad = (o.angle * Math.PI) / 180;
const dx = (Math.cos(rad) * o.rlen) / 2;
const dy = (Math.sin(rad) * o.rlen) / 2;
const others = polys.filter((x) => x !== home).map((x) => x.poly);
if (!hasRoomBehind([o.rx, o.ry], o.angle, [pos.x, pos.y], others, g * 0.6)) continue;
const sector = doorSector([pos.x, pos.y], [o.rx - dx, o.ry - dy], [o.rx + dx, o.ry + dy], R);
if (sector) shapes.push('M ' + sector.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z');
@@ -3066,6 +3153,7 @@ class HouseplanCard extends LitElement {
@mouseleave=${() => (this._tip = null)}></rect>`;
return svg`${shape}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
})}
${this._renderOpenWalls()}
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
@@ -3662,6 +3750,11 @@ class HouseplanCard extends LitElement {
title=${this._t('title.markup_opening')}>
<ha-icon icon="mdi:door"></ha-icon>${this._t('markup.opening')}
</button>
<button class="btn ${this._tool === 'openwall' ? 'on' : ''}"
@click=${() => { this._cancelPath(); this._tool = 'openwall'; }}
title=${this._t('title.markup_openwall')}>
<ha-icon icon="mdi:border-none-variant"></ha-icon>${this._t('markup.openwall')}
</button>
<button class="btn ${this._tool === 'delroom' ? 'on' : ''}" @click=${() => (this._tool = 'delroom')}
title=${this._t('title.markup_delroom')}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('markup.delete')}
+6 -1
View File
@@ -293,5 +293,10 @@
"marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_hint": "empty = default from general settings"
"marker.glow_radius_hint": "empty = default from general settings",
"markup.openwall": "Open boundary",
"title.markup_openwall": "Open boundary — click a wall shared by two rooms to make it virtual: light flows through, the line turns dashed. Click again to close it.",
"toast.openwall_pick": "Click a wall shared by two rooms",
"toast.openwall_opened": "Boundary “{a}” ↔ “{b}” is now open",
"toast.openwall_closed": "Boundary “{a}” ↔ “{b}” is closed again"
}
+6 -1
View File
@@ -293,5 +293,10 @@
"marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек"
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек",
"markup.openwall": "Открытая граница",
"title.markup_openwall": "Открытая граница — клик по общей стене двух комнат делает её условной: свет проходит насквозь, линия становится пунктирной. Повторный клик закрывает.",
"toast.openwall_pick": "Кликните по стене, разделяющей две комнаты",
"toast.openwall_opened": "Граница «{a}» ↔ «{b}» теперь открыта",
"toast.openwall_closed": "Граница «{a}» ↔ «{b}» снова закрыта"
}
+70
View File
@@ -851,6 +851,76 @@ export function isControllable(entityId: string): boolean {
return entityId.startsWith('light.') || entityId.startsWith('switch.');
}
// ---------------- open (virtual) boundaries ----------------
/**
* Collinear overlapping stretches of two room outlines their shared
* boundary. Handles the real-house case where neighbouring walls only
* PARTIALLY overlap (collinear, different lengths). Returns segments
* [x1,y1,x2,y2] with length > eps.
*/
export function sharedBoundary(a: number[][], b: number[][], eps = 1e-6): number[][] {
const res: number[][] = [];
if (!a || !b || a.length < 3 || b.length < 3) return res;
for (let i = 0; i < a.length; i++) {
const p1 = a[i], p2 = a[(i + 1) % a.length];
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy);
if (len < eps) continue;
const ux = dx / len, uy = dy / len;
for (let j = 0; j < b.length; j++) {
const q1 = b[j], q2 = b[(j + 1) % b.length];
// both q endpoints must lie on the line of p1-p2
const d1 = Math.abs((q1[0] - p1[0]) * uy - (q1[1] - p1[1]) * ux);
const d2 = Math.abs((q2[0] - p1[0]) * uy - (q2[1] - p1[1]) * ux);
const tol = Math.max(eps, len * 1e-6);
if (d1 > tol || d2 > tol) continue;
// overlap of parameter intervals along the line
const t1 = (q1[0] - p1[0]) * ux + (q1[1] - p1[1]) * uy;
const t2 = (q2[0] - p1[0]) * ux + (q2[1] - p1[1]) * uy;
const lo = Math.max(0, Math.min(t1, t2));
const hi = Math.min(len, Math.max(t1, t2));
if (hi - lo > eps) {
res.push([p1[0] + ux * lo, p1[1] + uy * lo, p1[0] + ux * hi, p1[1] + uy * hi]);
}
}
}
return res;
}
/**
* Connected component of rooms joined by open (virtual) boundaries the
* "open zone" light flows through. The open_to link counts in either
* direction. Returns a set of room ids including the start.
*/
export function openZoneOf(roomId: string, rooms: { id?: string; open_to?: string[] | null }[]): Set<string> {
const zone = new Set<string>([roomId]);
const linked = (x: any, y: any) =>
(x.open_to || []).includes(y.id) || (y.open_to || []).includes(x.id);
let grew = true;
while (grew) {
grew = false;
for (const r of rooms) {
if (!r.id || zone.has(r.id)) continue;
for (const z of rooms) {
if (!z.id || !zone.has(z.id)) continue;
if (linked(r, z)) { zone.add(r.id); grew = true; break; }
}
}
}
return zone;
}
/** Distance from a point to a segment [x1,y1,x2,y2]. */
export function distToSegment(p: number[], s: number[]): number {
const dx = s[2] - s[0], dy = s[3] - s[1];
const len2 = dx * dx + dy * dy;
if (!len2) return Math.hypot(p[0] - s[0], p[1] - s[1]);
let t = ((p[0] - s[0]) * dx + (p[1] - s[1]) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return Math.hypot(p[0] - (s[0] + t * dx), p[1] - (s[1] + t * dy));
}
/** Device classes whose active state is an emergency, not a status. */
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
+12
View File
@@ -538,9 +538,21 @@ export const cardStyles = css`
/* room-picking stages: merge (both clicks) and split before a room is chosen */
.stage.markup.tool-merge,
.stage.markup.tool-split.pickstage,
.stage.markup.tool-openwall,
.stage.markup.tool-delroom {
cursor: pointer;
}
.openwall {
stroke: var(--card-background-color, var(--hp-bg, #fff));
stroke-width: 3.2;
stroke-dasharray: 7 7;
pointer-events: none;
opacity: 0.9;
}
.openwalls.hot .openwall {
stroke: #ffc14d;
opacity: 1;
}
.stage.markup .room {
pointer-events: none;
}
+2
View File
@@ -1,6 +1,8 @@
/** Shared types of the House Plan card. */
export interface RoomCfg {
/** Rooms this one has an OPEN (virtual) boundary with — light flows through. */
open_to?: string[] | null;
id?: string;
name: string;
area: string | null;
+37
View File
@@ -6,6 +6,7 @@ import {
splitRoomPath, polyContainsPoly, islandsOf,
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
controlsAction, isControllable,
sharedBoundary, openZoneOf, distToSegment,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js';
@@ -666,3 +667,39 @@ test('isControllable: lights and switches only', () => {
assert.ok(!isControllable('cover.gate'));
assert.ok(!isControllable('alarm_control_panel.home'));
});
test('sharedBoundary: exact, partial-collinear and none', () => {
const A = [[0, 0], [2, 0], [2, 2], [0, 2]];
// точное общее ребро x=2
const B = [[2, 0], [4, 0], [4, 2], [2, 2]];
const s1 = sharedBoundary(A, B);
assert.equal(s1.length >= 1, true);
const len = (s) => Math.hypot(s[2] - s[0], s[3] - s[1]);
assert.ok(Math.abs(Math.max(...s1.map(len)) - 2) < 1e-6);
// частичное коллинеарное наложение (дачный случай): стена соседа длиннее
const C = [[2, -1], [4, -1], [4, 3], [2, 3]];
const s2 = sharedBoundary(A, C);
assert.ok(Math.abs(Math.max(...s2.map(len)) - 2) < 1e-6); // перекрытие = наша стена
// нет наложения
assert.equal(sharedBoundary(A, [[5, 5], [6, 5], [6, 6], [5, 6]]).length, 0);
// перпендикулярное касание — не граница
assert.equal(sharedBoundary(A, [[2, 2], [3, 2], [3, 3], [2, 3]]).filter((s) => len(s) > 1e-6).length, 0);
});
test('openZoneOf: transitive, either-direction links', () => {
const rooms = [
{ id: 'a', open_to: ['b'] },
{ id: 'b' }, // связь только со стороны a
{ id: 'c', open_to: ['b'] }, // b↔c со стороны c
{ id: 'd' }, // не связан
];
const z = openZoneOf('a', rooms);
assert.deepEqual([...z].sort(), ['a', 'b', 'c']);
assert.deepEqual([...openZoneOf('d', rooms)], ['d']);
});
test('distToSegment', () => {
assert.equal(distToSegment([0, 5], [0, 0, 0, 10]), 0);
assert.equal(distToSegment([3, 5], [0, 0, 0, 10]), 3);
assert.ok(Math.abs(distToSegment([-3, -4], [0, 0, 0, 10]) - 5) < 1e-9);
});