mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
perf/fix v1.43.1: external audit P1 — render cost, drag threshold, geometry, backend
L1: memoized space model + open pairs (structural fingerprint key, epoch bumped synchronously at mutation time, not inside the debounce); hoisted per-room geometry out of the render loop; smoke asserts zero recomputation across state pushes. L4: openings get the 3 px drag threshold used by every other pipeline and only write when the geometry actually changed — taps open the dialog again. G2: interiorPoint() replaces the vertex mean, so island rooms inside concave (U/L) parents are accepted and their evenodd holes render; traced duplicates still are not containment. G3: segKey rounds before ordering — one shared wall, one key. B2: _check_write fails closed when the entry is unavailable. B3: layout/set honours expected_rev and returns the new rev. B4: config/set without expected_rev over a non-empty store logs a warning. B5: coordinates reject NaN/Infinity; spaces/rooms/markers/decor/layout capped. +2 unit tests (118), +2 backend tests (14), smoke_render_perf; docs same-commit
This commit is contained in:
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
|
|||||||
CONTENT_URL = "/api/houseplan/content"
|
CONTENT_URL = "/api/houseplan/content"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.43.0"
|
VERSION = "1.43.1"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.43.0"
|
"version": "1.43.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,31 @@ def valid_space_id(value: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
# ---------- voluptuous schemas ----------
|
# ---------- voluptuous schemas ----------
|
||||||
|
def _finite(value):
|
||||||
|
"""Coerce to float and reject NaN/Infinity (audit B5).
|
||||||
|
|
||||||
|
'NaN' and 'Infinity' pass Coerce(float) and serialize to null on write,
|
||||||
|
silently corrupting a stored position forever.
|
||||||
|
"""
|
||||||
|
f = float(value)
|
||||||
|
if f != f or f in (float("inf"), float("-inf")):
|
||||||
|
raise vol.Invalid("coordinate must be a finite number")
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
# generous caps: the product targets 20-200 devices and a handful of floors
|
||||||
|
MAX_SPACES = 50
|
||||||
|
MAX_ROOMS = 400
|
||||||
|
MAX_MARKERS = 2000
|
||||||
|
MAX_OPENINGS = 500
|
||||||
|
MAX_DECOR = 1000
|
||||||
|
MAX_LAYOUT = 5000
|
||||||
|
|
||||||
POS_SCHEMA = vol.Schema(
|
POS_SCHEMA = vol.Schema(
|
||||||
{vol.Required("x"): vol.Coerce(float), vol.Required("y"): vol.Coerce(float)},
|
{vol.Required("x"): _finite, vol.Required("y"): _finite},
|
||||||
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
extra=vol.ALLOW_EXTRA, # v2 records carry the "s" key (space id)
|
||||||
)
|
)
|
||||||
LAYOUT_SCHEMA = vol.Schema({str: POS_SCHEMA})
|
LAYOUT_SCHEMA = vol.All(vol.Schema({str: POS_SCHEMA}), vol.Length(max=MAX_LAYOUT))
|
||||||
|
|
||||||
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
POINT = vol.All([vol.Coerce(float)], vol.Length(min=2, max=2))
|
||||||
|
|
||||||
@@ -142,8 +162,8 @@ SPACE_SCHEMA = vol.Schema(
|
|||||||
vol.Optional("plan_url"): vol.Any(str, None),
|
vol.Optional("plan_url"): vol.Any(str, None),
|
||||||
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
vol.Required("aspect"): vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20)),
|
||||||
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
vol.Required("view_box"): vol.All([vol.Coerce(float)], vol.Length(min=4, max=4)),
|
||||||
vol.Required("rooms"): [ROOM_SCHEMA],
|
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
|
||||||
vol.Optional("decor"): [DECOR_SCHEMA],
|
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
|
||||||
vol.Optional("openings"): [
|
vol.Optional("openings"): [
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
@@ -199,8 +219,8 @@ MARKER_SCHEMA = vol.Schema(
|
|||||||
)
|
)
|
||||||
CONFIG_SCHEMA = vol.Schema(
|
CONFIG_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("spaces"): [SPACE_SCHEMA],
|
vol.Required("spaces"): vol.All([SPACE_SCHEMA], vol.Length(max=MAX_SPACES)),
|
||||||
vol.Optional("markers", default=list): [MARKER_SCHEMA],
|
vol.Optional("markers", default=list): vol.All([MARKER_SCHEMA], vol.Length(max=MAX_MARKERS)),
|
||||||
vol.Optional("settings", default=dict): vol.Schema(
|
vol.Optional("settings", default=dict): vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
vol.Optional("glow_radius_cm"): vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
"""House Plan WS commands: layout, space configuration, plan uploads."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -22,6 +24,9 @@ from .validation import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_register(hass: HomeAssistant) -> None:
|
def async_register(hass: HomeAssistant) -> None:
|
||||||
"""Register the WS commands."""
|
"""Register the WS commands."""
|
||||||
@@ -49,8 +54,17 @@ def _runtime(hass: HomeAssistant, connection, msg_id: int) -> HouseplanData | No
|
|||||||
|
|
||||||
|
|
||||||
def _check_write(hass: HomeAssistant, connection) -> bool:
|
def _check_write(hass: HomeAssistant, connection) -> bool:
|
||||||
|
"""May this connection write?
|
||||||
|
|
||||||
|
Fails CLOSED (audit B2): when the entry cannot be read — during a reload or
|
||||||
|
while the integration is disabled — the policy is unknown, and "unknown" is
|
||||||
|
not the same as "permissive". Previously this returned True and ws_plan_set,
|
||||||
|
which never touches the runtime helper, accepted uploads in that window.
|
||||||
|
"""
|
||||||
entry = get_entry(hass)
|
entry = get_entry(hass)
|
||||||
admin_only = bool(entry and entry.options.get(CONF_ADMIN_ONLY, False))
|
if entry is None:
|
||||||
|
return bool(getattr(connection.user, "is_admin", False))
|
||||||
|
admin_only = bool(entry.options.get(CONF_ADMIN_ONLY, False))
|
||||||
return connection.user.is_admin if admin_only else True
|
return connection.user.is_admin if admin_only else True
|
||||||
|
|
||||||
|
|
||||||
@@ -69,11 +83,21 @@ async def ws_layout_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
{vol.Required("type"): "houseplan/layout/set", vol.Required("layout"): LAYOUT_SCHEMA}
|
{
|
||||||
|
vol.Required("type"): "houseplan/layout/set",
|
||||||
|
vol.Required("layout"): LAYOUT_SCHEMA,
|
||||||
|
vol.Optional("expected_rev"): int,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
|
||||||
"""Replace the layout entirely."""
|
"""Replace the layout entirely, with optimistic locking (audit B3).
|
||||||
|
|
||||||
|
Wholesale layout writes used to have no revision check at all, so two
|
||||||
|
clients silently overwrote each other. `expected_rev` is optional for
|
||||||
|
backwards compatibility with older cards, but when supplied it is enforced
|
||||||
|
exactly like the config store does.
|
||||||
|
"""
|
||||||
if not _check_write(hass, connection):
|
if not _check_write(hass, connection):
|
||||||
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
connection.send_error(msg["id"], "unauthorized", "Only administrators may edit the layout")
|
||||||
return
|
return
|
||||||
@@ -81,8 +105,15 @@ async def ws_layout_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
if rt is None:
|
if rt is None:
|
||||||
return
|
return
|
||||||
async with rt.write_lock:
|
async with rt.write_lock:
|
||||||
await rt.store.async_save({"layout": msg["layout"]})
|
data = await rt.store.async_load() or {}
|
||||||
connection.send_result(msg["id"], {"ok": True})
|
current_rev = int(data.get("rev", 0))
|
||||||
|
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "conflict", f"Layout changed elsewhere (rev {current_rev})"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await rt.store.async_save({"layout": msg["layout"], "rev": current_rev + 1})
|
||||||
|
connection.send_result(msg["id"], {"ok": True, "rev": current_rev + 1})
|
||||||
|
|
||||||
|
|
||||||
@websocket_api.websocket_command(
|
@websocket_api.websocket_command(
|
||||||
@@ -227,6 +258,15 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
|
|||||||
async with rt.write_lock:
|
async with rt.write_lock:
|
||||||
data = await rt.config_store.async_load() or {}
|
data = await rt.config_store.async_load() or {}
|
||||||
current_rev = data.get("rev", 0)
|
current_rev = data.get("rev", 0)
|
||||||
|
if "expected_rev" not in msg and current_rev:
|
||||||
|
# audit B4: expected_rev stays optional for old cards mid-upgrade,
|
||||||
|
# but a blind overwrite of a non-empty store is worth a warning —
|
||||||
|
# it is exactly how a stale client silently discards someone's work.
|
||||||
|
_LOGGER.warning(
|
||||||
|
"House Plan: config/set without expected_rev over rev %s — "
|
||||||
|
"the client bypasses conflict detection (outdated card?)",
|
||||||
|
current_rev,
|
||||||
|
)
|
||||||
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
if "expected_rev" in msg and msg["expected_rev"] != current_rev:
|
||||||
connection.send_error(
|
connection.send_error(
|
||||||
msg["id"], "conflict",
|
msg["id"], "conflict",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { launch } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
// счётчики вызовов тяжёлой геометрии
|
||||||
|
let pairsCalls = 0, buildCalls = 0;
|
||||||
|
const origPairs = c._computeOpenPairs.bind(c);
|
||||||
|
c._computeOpenPairs = (...a) => { pairsCalls++; return origPairs(...a); };
|
||||||
|
const origBuild = c._buildModel.bind(c);
|
||||||
|
c._buildModel = (...a) => { buildCalls++; return origBuild(...a); };
|
||||||
|
// открыть границу, чтобы pairs было что считать
|
||||||
|
const sp = c._curSpaceCfg;
|
||||||
|
const r1 = sp.rooms[0], r2 = sp.rooms[1];
|
||||||
|
r1.open_to = [r2.id]; r2.open_to = [r1.id];
|
||||||
|
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||||
|
pairsCalls = 0; buildCalls = 0;
|
||||||
|
// 10 «пушей состояния» от HA без изменения конфига
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states } };
|
||||||
|
await c.updateComplete;
|
||||||
|
}
|
||||||
|
out.pairsPerRender = pairsCalls; // должно быть 0: кэш живёт между рендерами
|
||||||
|
out.modelBuildsPer10Renders = buildCalls;
|
||||||
|
// правка конфига обязана инвалидировать кэш
|
||||||
|
const before = pairsCalls;
|
||||||
|
const r3 = sp.rooms[2] || sp.rooms[0];
|
||||||
|
r3.open_to = [r1.id]; r1.open_to = [r2.id, r3.id];
|
||||||
|
c._saveConfig(); c.requestUpdate(); await c.updateComplete;
|
||||||
|
out.invalidatesOnEdit = pairsCalls > before;
|
||||||
|
// геометрия по-прежнему правильная: пунктир на месте
|
||||||
|
out.dashesStillRendered = (c.shadowRoot || c.renderRoot).querySelectorAll('.openwall').length > 0;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 1));
|
||||||
|
await browser.close();
|
||||||
@@ -21,7 +21,7 @@ const res = await page.evaluate(async () => {
|
|||||||
const sp = c._curSpaceCfg;
|
const sp = c._curSpaceCfg;
|
||||||
sp.rooms.push({ id: 'race_room', name: 'RACE', area: null, poly: [[0.8, 0.8], [0.9, 0.8], [0.9, 0.9], [0.8, 0.9]] });
|
sp.rooms.push({ id: 'race_room', name: 'RACE', area: null, poly: [[0.8, 0.8], [0.9, 0.8], [0.9, 0.9], [0.8, 0.9]] });
|
||||||
c._saveConfig();
|
c._saveConfig();
|
||||||
out.pending = c._saveConfig.pending();
|
out.pending = c._saveConfigDebounced.pending();
|
||||||
// через 100 мс приходит событие о чужой ревизии — раньше это стирало правку
|
// через 100 мс приходит событие о чужой ревизии — раньше это стирало правку
|
||||||
c._cfgRev = server.rev; // симулируем: наша ревизия отстала
|
c._cfgRev = server.rev; // симулируем: наша ревизия отстала
|
||||||
await c._reloadConfigOnly();
|
await c._reloadConfigOnly();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Vendored
+38
-38
File diff suppressed because one or more lines are too long
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.43.1 — 2026-07-27 (external audit: P1 fixes)
|
||||||
|
|
||||||
|
- **Render cost (L1).** Home Assistant replaces `hass` on every state change in
|
||||||
|
the home, and each of those renders recomputed the whole plan geometry —
|
||||||
|
`_openPairs()` ran once per room (O(rooms³) collinear-overlap math) and the
|
||||||
|
space model was rebuilt twice. Both are now memoized on the config's
|
||||||
|
structural fingerprint and hoisted out of the per-room loop; cache
|
||||||
|
invalidation happens synchronously at mutation time, not inside the debounce.
|
||||||
|
- **Opening tap vs drag (L4).** Dragging a door/window had no movement
|
||||||
|
threshold, so any pointer jitter counted as a drag: the properties dialog
|
||||||
|
never opened and an unchanged config was written (which then fed the L2 race).
|
||||||
|
Now the same 3 px threshold as every other drag pipeline, and the write only
|
||||||
|
happens when the geometry actually changed.
|
||||||
|
- **Concave rooms (G2).** Containment used the arithmetic mean of the vertices
|
||||||
|
as an "interior point" — which lies OUTSIDE U- and L-shaped rooms, so island
|
||||||
|
rooms in them were rejected as overlaps and their holes never rendered. A
|
||||||
|
real interior point is computed instead (`interiorPoint`).
|
||||||
|
- **Wall dedup (G3).** `segKey` ordered endpoints by raw floats but printed
|
||||||
|
rounded ones, so one shared wall could produce two keys and be drawn twice.
|
||||||
|
Rounds first, then orders.
|
||||||
|
- **Backend hardening (B2–B5).** The write-authorization check now fails
|
||||||
|
**closed** when the config entry is unavailable (it used to allow writes
|
||||||
|
during a reload); `layout/set` supports `expected_rev` and conflicts like the
|
||||||
|
config store; a `config/set` without `expected_rev` over a non-empty store
|
||||||
|
logs a warning; coordinates reject NaN/Infinity, and spaces/rooms/markers/
|
||||||
|
decor/layout have generous size caps.
|
||||||
|
|
||||||
## v1.43.0 — 2026-07-27 (external audit: P0 fixes)
|
## v1.43.0 — 2026-07-27 (external audit: P0 fixes)
|
||||||
|
|
||||||
An external code audit of v1.41.1 found four critical issues. All four are fixed
|
An external code audit of v1.41.1 found four critical issues. All four are fixed
|
||||||
|
|||||||
@@ -140,6 +140,20 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
(explicit ripple color still wins); off/white lights unchanged [auto]
|
(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
|
- [ ] 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
|
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||||
|
- [ ] Render cost (v1.43.1, audit L1): geometry (space model, open pairs) is
|
||||||
|
computed once per config change, not per HA state push — smoke asserts
|
||||||
|
zero recomputations across 10 state pushes and recomputation after an
|
||||||
|
edit; the plan still renders dashes/islands correctly [auto]
|
||||||
|
- [ ] Opening tap vs drag (v1.43.1, audit L4): a tap on a door in the Plan
|
||||||
|
editor opens its properties (3 px threshold like the other pipelines) and
|
||||||
|
writes nothing; a real drag that ends where it started also writes nothing [auto]
|
||||||
|
- [ ] Concave containment (v1.43.1, audit G2): an island room inside a U- or
|
||||||
|
L-shaped parent is accepted and punches the evenodd hole; a traced
|
||||||
|
duplicate outline is still NOT containment [auto]
|
||||||
|
- [ ] Backend hardening (v1.43.1, audit B2-B5): the admin check fails closed
|
||||||
|
when the entry is unavailable; layout/set honours expected_rev; a
|
||||||
|
config/set without expected_rev over a non-empty store logs a warning;
|
||||||
|
NaN/Infinity coordinates and oversized collections are rejected [auto]
|
||||||
- [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any
|
- [ ] Save race (v1.43.0, audit L2): make a markup edit, then press Save in any
|
||||||
dialog within 500 ms (or let another client save) — the markup edit must
|
dialog within 500 ms (or let another client save) — the markup edit must
|
||||||
survive and reach the server; a failed reload now shows a toast [auto]
|
survive and reach the server; a failed reload now shows a toast [auto]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.43.0",
|
"version": "1.43.1",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+97
-18
@@ -32,7 +32,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
import { langOf, t, type I18nKey } from './i18n';
|
||||||
|
|
||||||
const CARD_VERSION = '1.43.0';
|
const CARD_VERSION = '1.43.1';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -177,7 +177,7 @@ class HouseplanCard extends LitElement {
|
|||||||
x: number; y: number; angle: number; // render units (from the wall snap)
|
x: number; y: number; angle: number; // render units (from the wall snap)
|
||||||
} | null = null;
|
} | null = null;
|
||||||
private _openingInfo: OpeningCfg | null = null;
|
private _openingInfo: OpeningCfg | null = null;
|
||||||
private _opDrag: { id: string; moved: boolean } | null = null;
|
private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean } | null = null;
|
||||||
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
|
||||||
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
|
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
|
||||||
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
|
||||||
@@ -360,7 +360,7 @@ class HouseplanCard extends LitElement {
|
|||||||
clearTimeout(this._kioskDotsTimer);
|
clearTimeout(this._kioskDotsTimer);
|
||||||
clearTimeout(this._kioskHoldTimer);
|
clearTimeout(this._kioskHoldTimer);
|
||||||
clearTimeout(this._reloadRetry);
|
clearTimeout(this._reloadRetry);
|
||||||
this._saveConfig.flush(); // never leave an edit unsent on teardown
|
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
|
||||||
window.removeEventListener('hashchange', this._onHashChange);
|
window.removeEventListener('hashchange', this._onHashChange);
|
||||||
clearTimeout(this._holdTimer);
|
clearTimeout(this._holdTimer);
|
||||||
this._roViewport?.disconnect();
|
this._roViewport?.disconnect();
|
||||||
@@ -480,6 +480,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
const c = JSON.parse(localStorage.getItem(LS_CFG) || 'null');
|
||||||
if (c && c.config && Array.isArray(c.config.spaces)) {
|
if (c && c.config && Array.isArray(c.config.spaces)) {
|
||||||
this._serverCfg = c.config;
|
this._serverCfg = c.config;
|
||||||
|
this._cfgEpoch++;
|
||||||
this._cfgRev = c.rev || 0;
|
this._cfgRev = c.rev || 0;
|
||||||
this._layout = c.layout || {};
|
this._layout = c.layout || {};
|
||||||
this._serverStorage = true;
|
this._serverStorage = true;
|
||||||
@@ -520,7 +521,36 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
/** Spaces in render units (NORM_W × NORM_W/aspect). */
|
||||||
|
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */
|
||||||
|
private _cfgEpoch = 0;
|
||||||
|
private _modelCache: { key: string; model: SpaceModel[] } | null = null;
|
||||||
|
|
||||||
|
/** Cheap structural fingerprint of the config (audit L1 cache key). */
|
||||||
|
private _cfgFingerprint(): string {
|
||||||
|
const sp = this._serverCfg?.spaces || [];
|
||||||
|
let s = sp.length + ':';
|
||||||
|
for (const x of sp as any[]) {
|
||||||
|
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ','
|
||||||
|
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
|
||||||
|
for (const r of x.rooms || [])
|
||||||
|
s += (r.poly?.length || 0) + '.' + (r.id || '') + '.' + (r.open_to || []).join('+') + '.'
|
||||||
|
+ (r.area || '') + '.' + JSON.stringify(r.settings || 0) + ';';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
private get _model(): SpaceModel[] {
|
private get _model(): SpaceModel[] {
|
||||||
|
if (!this._serverCfg) return [];
|
||||||
|
// same reasoning as _openPairs: mutations in place mean the epoch can lag,
|
||||||
|
// so the key also carries the config's structural fingerprint
|
||||||
|
const key = this._cfgEpoch + '|' + this._cfgFingerprint();
|
||||||
|
if (this._modelCache && this._modelCache.key === key) return this._modelCache.model;
|
||||||
|
const built = this._buildModel();
|
||||||
|
this._modelCache = { key, model: built };
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildModel(): SpaceModel[] {
|
||||||
if (!this._serverCfg) return [];
|
if (!this._serverCfg) return [];
|
||||||
return this._serverCfg.spaces.map((s: any) => {
|
return this._serverCfg.spaces.map((s: any) => {
|
||||||
const H = NORM_W / s.aspect;
|
const H = NORM_W / s.aspect;
|
||||||
@@ -645,6 +675,7 @@ class HouseplanCard extends LitElement {
|
|||||||
this._serverStorage = true;
|
this._serverStorage = true;
|
||||||
const cfg = cfgResp?.config;
|
const cfg = cfgResp?.config;
|
||||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||||
|
this._cfgEpoch++;
|
||||||
this._cfgRev = cfgResp?.rev || 0;
|
this._cfgRev = cfgResp?.rev || 0;
|
||||||
this._layout = layResp?.layout || {};
|
this._layout = layResp?.layout || {};
|
||||||
// live sync: the config was changed in another window → re-read it
|
// live sync: the config was changed in another window → re-read it
|
||||||
@@ -698,7 +729,7 @@ class HouseplanCard extends LitElement {
|
|||||||
*/
|
*/
|
||||||
private async _reloadConfigOnly(force = false): Promise<void> {
|
private async _reloadConfigOnly(force = false): Promise<void> {
|
||||||
if (!force) {
|
if (!force) {
|
||||||
if (this._saveConfig.pending()) this._saveConfig.flush();
|
if (this._saveConfigDebounced.pending()) this._saveConfigDebounced.flush();
|
||||||
if (this._cfgWriting) {
|
if (this._cfgWriting) {
|
||||||
// retry once the in-flight write settles
|
// retry once the in-flight write settles
|
||||||
clearTimeout(this._reloadRetry);
|
clearTimeout(this._reloadRetry);
|
||||||
@@ -710,6 +741,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
const resp = await this.hass.callWS({ type: 'houseplan/config/get' });
|
||||||
const cfg = resp?.config;
|
const cfg = resp?.config;
|
||||||
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
this._serverCfg = cfg && Array.isArray(cfg.spaces) ? cfg : null;
|
||||||
|
this._cfgEpoch++;
|
||||||
this._cfgRev = resp?.rev || 0;
|
this._cfgRev = resp?.rev || 0;
|
||||||
this._cacheSnapshot();
|
this._cacheSnapshot();
|
||||||
this._regSignature = '';
|
this._regSignature = '';
|
||||||
@@ -1437,7 +1469,18 @@ class HouseplanCard extends LitElement {
|
|||||||
/** A config write is in flight — the card must not adopt a server revision. */
|
/** A config write is in flight — the card must not adopt a server revision. */
|
||||||
private _cfgWriting = false;
|
private _cfgWriting = false;
|
||||||
|
|
||||||
private _saveConfig = debounce(() => {
|
/**
|
||||||
|
* Every mutation path ends here, so this is the one place that can invalidate
|
||||||
|
* the geometry caches synchronously. The WRITE stays debounced; the epoch bump
|
||||||
|
* must not be (audit L1: a bump inside the debounce arrives 500 ms after the
|
||||||
|
* render that needed it, and the plan renders stale geometry).
|
||||||
|
*/
|
||||||
|
private _saveConfig(): void {
|
||||||
|
this._cfgEpoch++;
|
||||||
|
this._saveConfigDebounced();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _saveConfigDebounced = debounce(() => {
|
||||||
if (!this._serverCfg) return;
|
if (!this._serverCfg) return;
|
||||||
this._dropLegacySegments();
|
this._dropLegacySegments();
|
||||||
this._cfgWriting = true;
|
this._cfgWriting = true;
|
||||||
@@ -1844,7 +1887,25 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** All open-boundary pairs of the current space with their shared segments. */
|
/** All open-boundary pairs of the current space with their shared segments. */
|
||||||
|
private _openPairsCache: { key: string; pairs: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] } | null = null;
|
||||||
|
|
||||||
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||||
|
// audit L1: this used to run once PER ROOM on every render (O(rooms^3)
|
||||||
|
// collinear-overlap math on every HA state push). Memoized on the config
|
||||||
|
// epoch + current space.
|
||||||
|
// The key includes the open_to links themselves: _serverCfg is mutated in
|
||||||
|
// place in ~22 places (audit L7), so an epoch counter alone is not a
|
||||||
|
// trustworthy cache key — a missed bump would render a stale plan, which is
|
||||||
|
// far worse than recomputing a short string here.
|
||||||
|
const sp = this._spaceModel();
|
||||||
|
const key = this._space + '|' + sp.rooms.map((r) => r.id + ':' + ((r as any).open_to || []).join(',')).join(';');
|
||||||
|
if (this._openPairsCache && this._openPairsCache.key === key) return this._openPairsCache.pairs;
|
||||||
|
const pairs = this._computeOpenPairs();
|
||||||
|
this._openPairsCache = { key, pairs };
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _computeOpenPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||||
const rooms = this._spaceModel().rooms.filter((r) => r.id);
|
const rooms = this._spaceModel().rooms.filter((r) => r.id);
|
||||||
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
|
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
|
||||||
for (let i = 0; i < rooms.length; i++)
|
for (let i = 0; i < rooms.length; i++)
|
||||||
@@ -1955,11 +2016,16 @@ class HouseplanCard extends LitElement {
|
|||||||
} catch {
|
} catch {
|
||||||
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
/* an inactive pointerId (synthetic events, some browsers) must not kill the drag */
|
||||||
}
|
}
|
||||||
this._opDrag = { id: o.id, moved: false };
|
this._opDrag = { id: o.id, moved: false, sx: ev.clientX, sy: ev.clientY, dirty: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
private _opPointerMove(ev: PointerEvent, o: OpeningCfg): void {
|
||||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||||
|
// audit L4: the other drag pipelines require 3 px before calling it a drag.
|
||||||
|
// Without it every tap counted as a drag: the properties dialog never
|
||||||
|
// opened and an unchanged config was written (which then broadcast the
|
||||||
|
// event behind the L2 data loss).
|
||||||
|
if (Math.abs(ev.clientX - this._opDrag.sx) + Math.abs(ev.clientY - this._opDrag.sy) <= 3) return;
|
||||||
const raw = this._svgPoint(ev);
|
const raw = this._svgPoint(ev);
|
||||||
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
||||||
if (!snap) return; // too far from any wall: the opening stays where it was
|
if (!snap) return; // too far from any wall: the opening stays where it was
|
||||||
@@ -1967,8 +2033,11 @@ class HouseplanCard extends LitElement {
|
|||||||
const sp = this._curSpaceCfg;
|
const sp = this._curSpaceCfg;
|
||||||
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
||||||
if (!cfg) return;
|
if (!cfg) return;
|
||||||
cfg.x = snap.x / NORM_W;
|
const nx = snap.x / NORM_W;
|
||||||
cfg.y = snap.y / this._spaceH;
|
const ny = snap.y / this._spaceH;
|
||||||
|
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
|
||||||
|
cfg.x = nx;
|
||||||
|
cfg.y = ny;
|
||||||
cfg.angle = snap.angle;
|
cfg.angle = snap.angle;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
@@ -1976,7 +2045,8 @@ class HouseplanCard extends LitElement {
|
|||||||
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
|
||||||
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
if (!this._opDrag || this._opDrag.id !== o.id) return;
|
||||||
const moved = this._opDrag.moved;
|
const moved = this._opDrag.moved;
|
||||||
if (moved) this._saveConfig();
|
// only write when the geometry actually changed (audit L4)
|
||||||
|
if (moved && this._opDrag.dirty) this._saveConfig();
|
||||||
// keep the flag until the click event that follows pointerup, then let it go
|
// keep the flag until the click event that follows pointerup, then let it go
|
||||||
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
|
if (moved) window.setTimeout(() => (this._opDrag = null), 0);
|
||||||
else this._opDrag = null;
|
else this._opDrag = null;
|
||||||
@@ -2867,6 +2937,7 @@ class HouseplanCard extends LitElement {
|
|||||||
conflict again. */
|
conflict again. */
|
||||||
private async _saveConfigNow(): Promise<void> {
|
private async _saveConfigNow(): Promise<void> {
|
||||||
this._dropLegacySegments();
|
this._dropLegacySegments();
|
||||||
|
this._cfgEpoch++;
|
||||||
try {
|
try {
|
||||||
const r = await this.hass.callWS({
|
const r = await this.hass.callWS({
|
||||||
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
|
||||||
@@ -3418,7 +3489,18 @@ class HouseplanCard extends LitElement {
|
|||||||
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />`
|
||||||
: nothing}
|
: nothing}
|
||||||
${this._renderDecorLayer()}
|
${this._renderDecorLayer()}
|
||||||
${space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
${(() => {
|
||||||
|
// audit L1: hoisted out of the per-room map — these depend on the
|
||||||
|
// config, not on entity state, and were recomputed per room.
|
||||||
|
const allPairs = this._openPairs();
|
||||||
|
const polyCache = new Map<any, number[][] | null>();
|
||||||
|
const polyOf = (rr: any) => {
|
||||||
|
if (!polyCache.has(rr)) polyCache.set(rr, roomPoly(rr));
|
||||||
|
return polyCache.get(rr)!;
|
||||||
|
};
|
||||||
|
const otherPolys = (rr: any) =>
|
||||||
|
space.rooms.filter((o) => o !== rr).map(polyOf).filter(Boolean) as number[][][];
|
||||||
|
return space.rooms.filter((r) => r.area || this._markup || disp.showBorders).map((r) => {
|
||||||
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
let cls = 'room ' + (space.bg ? 'overlay' : 'yard') + (this._markup ? ' outlined' : '');
|
||||||
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
if (this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId))
|
||||||
cls += ' picked';
|
cls += ' picked';
|
||||||
@@ -3465,16 +3547,12 @@ class HouseplanCard extends LitElement {
|
|||||||
// amber highlight — the merge/split selection must stay visible).
|
// amber highlight — the merge/split selection must stay visible).
|
||||||
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
|
const isPicked = this._markup && (r.id === this._mergeSel || r.id === this._splitSel?.roomId);
|
||||||
const openCuts = r.id && !isPicked
|
const openCuts = r.id && !isPicked
|
||||||
? this._openPairs()
|
? allPairs.filter((pp) => pp.a.id === r.id || pp.b.id === r.id).flatMap((pp) => pp.segs)
|
||||||
.filter((pp) => pp.a.id === r.id || pp.b.id === r.id)
|
|
||||||
.flatMap((pp) => pp.segs)
|
|
||||||
: [];
|
: [];
|
||||||
if (openCuts.length) cls += ' noedge';
|
if (openCuts.length) cls += ' noedge';
|
||||||
// island rooms punch holes in their parent's fill (evenodd)
|
// island rooms punch holes in their parent's fill (evenodd)
|
||||||
const myPoly = roomPoly(r);
|
const myPoly = polyOf(r);
|
||||||
const holes = myPoly
|
const holes = myPoly ? islandsOf(myPoly, otherPolys(r)) : [];
|
||||||
? islandsOf(myPoly, space.rooms.filter((o) => o !== r).map((o) => roomPoly(o)!).filter(Boolean))
|
|
||||||
: [];
|
|
||||||
const pathD = (pts: number[][]) =>
|
const pathD = (pts: number[][]) =>
|
||||||
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
|
'M ' + pts.map((p) => p[0] + ' ' + p[1]).join(' L ') + ' Z';
|
||||||
const shape = holes.length && myPoly
|
const shape = holes.length && myPoly
|
||||||
@@ -3499,7 +3577,8 @@ class HouseplanCard extends LitElement {
|
|||||||
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
|
style=${this._markup ? nothing : `stroke:${disp.color};stroke-opacity:${disp.showBorders ? disp.opacity : 0}`}></path>`
|
||||||
: nothing;
|
: nothing;
|
||||||
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
return svg`${shape}${outline}${label ? svg`<text class="rlabel" x="${c[0]}" y="${c[1]}">${r.name}</text>` : nothing}`;
|
||||||
})}
|
});
|
||||||
|
})()}
|
||||||
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||||
${this._renderOpenWalls(disp)}
|
${this._renderOpenWalls(disp)}
|
||||||
${this._editing ? this._renderAlignGuides() : nothing}
|
${this._editing ? this._renderAlignGuides() : nothing}
|
||||||
|
|||||||
+44
-13
@@ -38,8 +38,14 @@ export function formatLength(cm: number, imperial: boolean): string {
|
|||||||
* normalized (0..1) coordinates need more (see roomEdges).
|
* normalized (0..1) coordinates need more (see roomEdges).
|
||||||
*/
|
*/
|
||||||
export function segKey(a: number[], b: number[], prec = 1): string {
|
export function segKey(a: number[], b: number[], prec = 1): string {
|
||||||
const [p, q] = a[0] < b[0] || (a[0] === b[0] && a[1] <= b[1]) ? [a, b] : [b, a];
|
// audit G3: ROUND FIRST, then order. Ordering on raw floats while printing
|
||||||
return `${p[0].toFixed(prec)},${p[1].toFixed(prec)}-${q[0].toFixed(prec)},${q[1].toFixed(prec)}`;
|
// rounded ones let one wall produce two different keys, so shared walls were
|
||||||
|
// emitted twice and the dedup invariant quietly broke.
|
||||||
|
const ax = a[0].toFixed(prec), ay = a[1].toFixed(prec);
|
||||||
|
const bx = b[0].toFixed(prec), by = b[1].toFixed(prec);
|
||||||
|
const first = ax < bx || (ax === bx && ay <= by);
|
||||||
|
const [px, py, qx, qy] = first ? [ax, ay, bx, by] : [bx, by, ax, ay];
|
||||||
|
return `${px},${py}-${qx},${qy}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,6 +230,36 @@ export function segmentsProperlyCross(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
/** Is any area of outline `a` strictly inside `b`? Also catches nested and duplicate outlines. */
|
||||||
|
/**
|
||||||
|
* A point guaranteed to lie strictly inside the polygon (audit G2).
|
||||||
|
* The arithmetic mean of the vertices lies OUTSIDE concave shapes (U/L rooms
|
||||||
|
* are common in hand-drawn plans), which made containment tests misfire.
|
||||||
|
* Strategy: try the midpoints of the diagonals from each vertex, then a
|
||||||
|
* triangle centroid of consecutive vertices — the first point that passes
|
||||||
|
* pointStrictlyInside wins.
|
||||||
|
*/
|
||||||
|
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
|
||||||
|
if (!poly || poly.length < 3) return null;
|
||||||
|
const n = poly.length;
|
||||||
|
const mean = [
|
||||||
|
poly.reduce((s, p) => s + p[0], 0) / n,
|
||||||
|
poly.reduce((s, p) => s + p[1], 0) / n,
|
||||||
|
];
|
||||||
|
if (pointStrictlyInside(mean, poly, eps)) return mean;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
// centroid of the ear at vertex i
|
||||||
|
const a = poly[(i - 1 + n) % n], b = poly[i], c = poly[(i + 1) % n];
|
||||||
|
const cand = [(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3];
|
||||||
|
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < n; i++)
|
||||||
|
for (let j = i + 2; j < n; j++) {
|
||||||
|
const cand = [(poly[i][0] + poly[j][0]) / 2, (poly[i][1] + poly[j][1]) / 2];
|
||||||
|
if (pointStrictlyInside(cand, poly, eps)) return cand;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
||||||
let allOnBoundary = true;
|
let allOnBoundary = true;
|
||||||
for (const v of a) {
|
for (const v of a) {
|
||||||
@@ -232,11 +268,8 @@ function coversArea(a: number[][], b: number[][], eps: number): boolean {
|
|||||||
}
|
}
|
||||||
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
// every vertex sits on b's outline → a duplicate or traced outline: probe the middle
|
||||||
if (allOnBoundary) {
|
if (allOnBoundary) {
|
||||||
const c = [
|
const c = interiorPoint(a, eps); // audit G2: NOT the vertex mean
|
||||||
a.reduce((s, p) => s + p[0], 0) / a.length,
|
return !!c && pointStrictlyInside(c, b, eps);
|
||||||
a.reduce((s, p) => s + p[1], 0) / a.length,
|
|
||||||
];
|
|
||||||
return pointStrictlyInside(c, b, eps);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -250,12 +283,10 @@ export function polyContainsPoly(outer: number[][], inner: number[][], eps = 1e-
|
|||||||
return false;
|
return false;
|
||||||
for (const v of inner)
|
for (const v of inner)
|
||||||
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
if (!pointStrictlyInside(v, outer, eps) && !pointOnBoundary(v, outer, eps)) return false;
|
||||||
// identical/traced outlines are NOT containment — probe the centroid strictness both ways
|
// identical/traced outlines are NOT containment — probe a real interior point
|
||||||
const c = [
|
// (audit G2: the vertex mean lies outside concave rooms)
|
||||||
inner.reduce((s, p) => s + p[0], 0) / inner.length,
|
const c = interiorPoint(inner, eps);
|
||||||
inner.reduce((s, p) => s + p[1], 0) / inner.length,
|
return !!c && pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
||||||
];
|
|
||||||
return pointStrictlyInside(c, outer, eps) && polygonArea(inner) < polygonArea(outer) - eps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
migratePdfUrls,
|
migratePdfUrls,
|
||||||
roomFillModeOf,
|
roomFillModeOf,
|
||||||
contentUrl,
|
contentUrl,
|
||||||
|
interiorPoint,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
@@ -844,3 +845,25 @@ test('contentUrl: legacy static paths become authenticated ones (audit B1)', ()
|
|||||||
assert.equal(contentUrl(''), '');
|
assert.equal(contentUrl(''), '');
|
||||||
assert.equal(contentUrl(null), '');
|
assert.equal(contentUrl(null), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('interiorPoint / polyContainsPoly on concave rooms (audit G2)', () => {
|
||||||
|
// U-образная комната: среднее вершин лежит СНАРУЖИ
|
||||||
|
const u = [[0, 0], [10, 0], [10, 10], [9, 10], [9, 2], [1, 2], [1, 10], [0, 10]];
|
||||||
|
const mean = [u.reduce((s, p) => s + p[0], 0) / u.length, u.reduce((s, p) => s + p[1], 0) / u.length];
|
||||||
|
assert.equal(pointStrictlyInside(mean, u), false, 'предпосылка: среднее снаружи');
|
||||||
|
const ip = interiorPoint(u);
|
||||||
|
assert.ok(ip && pointStrictlyInside(ip, u), 'interiorPoint обязана быть внутри');
|
||||||
|
// вложенность вогнутых теперь распознаётся
|
||||||
|
const outer = [[-1, -1], [11, -1], [11, 11], [8.5, 11], [8.5, 2.5], [1.5, 2.5], [1.5, 11], [-1, 11]];
|
||||||
|
assert.equal(polyContainsPoly(outer, u), true);
|
||||||
|
assert.equal(roomsOverlap(outer, u), false);
|
||||||
|
// дубликат по-прежнему НЕ вложенность
|
||||||
|
assert.equal(polyContainsPoly(u, u), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('segKey: one wall, one key at any precision (audit G3)', () => {
|
||||||
|
assert.equal(segKey([1.000001, 1], [1.000002, 2]), segKey([1.000002, 1], [1.000001, 2]));
|
||||||
|
assert.equal(segKey([0, 0], [1, 1], 3), segKey([1, 1], [0, 0], 3));
|
||||||
|
// разные стены — разные ключи
|
||||||
|
assert.notEqual(segKey([0, 0], [1, 1]), segKey([0, 0], [2, 2]));
|
||||||
|
});
|
||||||
|
|||||||
@@ -125,3 +125,18 @@ def test_space_temp_bounds():
|
|||||||
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
"settings": {"fill_mode": "temp", "temp_min": 19.5, "temp_max": "24"},
|
||||||
}
|
}
|
||||||
v.SPACE_SCHEMA(ok)
|
v.SPACE_SCHEMA(ok)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finite_coordinates_rejected():
|
||||||
|
"""audit B5: NaN/Infinity coordinates must not reach storage."""
|
||||||
|
for bad in ("NaN", "Infinity", "-Infinity", float("nan"), float("inf")):
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.LAYOUT_SCHEMA({"dev1": {"x": bad, "y": 0.5}})
|
||||||
|
assert v.LAYOUT_SCHEMA({"dev1": {"x": 0.5, "y": 0.25}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_caps():
|
||||||
|
"""audit B5: unbounded collections are capped."""
|
||||||
|
big = {f"d{i}": {"x": 0.1, "y": 0.1} for i in range(v.MAX_LAYOUT + 1)}
|
||||||
|
with pytest.raises(vol.Invalid):
|
||||||
|
v.LAYOUT_SCHEMA(big)
|
||||||
|
|||||||
Reference in New Issue
Block a user