v1.48.0: the canvas is always square, the plan is centred inside it

A space carried an aspect ratio, and coordinates were normalised against it: x
by the width, y by the height. Every geometric question therefore depended on a
per-space number, and picking a canvas orientation was a decision the user had
no reason to make. The render space is now NORM_W x NORM_W and a plan image is
fitted into it by its OWN ratio, centred — wide plans get margins above and
below, tall ones at the sides.

Migration (geometry_migration.py, pure and unit-tested) runs once at setup under
the write lock. Nothing about a drawing changes: the old box is padded out to a
square and every coordinate re-expressed against it — rooms as rects and
polygons, openings and their lengths, decor, view_box, and the marker positions
in the separate layout store. In render units it is a uniform scale plus an
offset, so angles and proportions are exact. cell_cm is scaled for tall plans,
because the grid pitch is a fraction of the width: without it a wall would
measure less than it does.

 is now dropped by the schema rather than accepted — a stale tab sending
it would be sending coordinates from the old normalisation too, and honouring
the field would not make them right.

The demo fixture was migrated with the same transform, so the smokes exercise
the new geometry rather than a square-native fake; six of them needed their
render-space helpers updated and one its click coordinates.

Not released — dev only, per the owner's instruction.
This commit is contained in:
Matysh
2026-07-28 22:20:59 +03:00
parent 01bc4f9711
commit 94b298962a
29 changed files with 490 additions and 181 deletions
+21
View File
@@ -20,6 +20,7 @@ from .const import (
PLANS_URL, PLANS_URL,
VERSION, VERSION,
) )
from .geometry_migration import migrate_config
from .plans import collect_attachments, collect_plans, sweep_upload_temps from .plans import collect_attachments, collect_plans, sweep_upload_temps
from .repairs import async_check_plan_files from .repairs import async_check_plan_files
from .store import HouseplanConfigEntry, create_data from .store import HouseplanConfigEntry, create_data
@@ -99,6 +100,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
module_url, module_url, module_url, module_url,
) )
# One-time move to the square canvas (v1.48.0). Coordinates used to be
# normalised against a per-space aspect ratio; the canvas is now always
# square and a plan is centred inside it. Nothing about the drawing changes
# — the box is padded and the numbers re-expressed against it.
async with data.write_lock:
stored = await data.config_store.async_load() or {}
cfg = stored.get("config")
lay_stored = await data.store.async_load() or {}
layout = lay_stored.get("layout") or {}
if cfg and migrate_config(cfg, layout):
rev = int(stored.get("rev", 0)) + 1
await data.config_store.async_save({"config": cfg, "rev": rev})
await data.store.async_save(
{"layout": layout, "rev": int(lay_stored.get("rev", 0)) + 1}
)
_LOGGER.info(
"House Plan: migrated %s space(s) to the square canvas", len(cfg.get("spaces") or [])
)
hass.bus.async_fire("houseplan_config_updated", {"rev": rev})
await async_check_plan_files(hass, entry) await async_check_plan_files(hass, entry)
# Scheduled collection of everything nobody ended up referencing. # Scheduled collection of everything nobody ended up referencing.
+1 -1
View File
@@ -31,7 +31,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600 SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.47.0" VERSION = "1.48.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
"""One-time migration to a square canvas — pure, so it can be tested alone.
Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
it: x by the width, y by the HEIGHT. Making every canvas square without touching
the numbers would stretch every plan vertically.
Nothing about the drawing changes here. The canvas is padded to a square —
top and bottom for a wide plan, left and right for a tall one — and the
coordinates are re-expressed against that larger box. In render units it is a
uniform scale plus an offset, so angles, room proportions and relative positions
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
tall plan the width grew, so the same wall would otherwise measure less.
"""
from __future__ import annotations
import logging
from typing import Any
_LOGGER = logging.getLogger(__name__)
def transform_for(aspect: float) -> tuple[float, float, float, float]:
"""(dx, dy, kx, ky) that map old normalised coordinates onto the square.
x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
axis's factor; both are the same uniform scale in RENDER units, which is
why angles are preserved.
"""
a = float(aspect)
if not a or a <= 0:
a = 1.0
k = min(1.0, a) # how much the old box shrinks inside the square
kx = k # x was normalised by the width
ky = k / a # y was normalised by the height (= width / aspect)
return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky
def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
if isinstance(p, (list, tuple)) and len(p) >= 2:
return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
return p
def migrate_space(space: dict[str, Any]) -> bool:
"""Rewrite one space in place. Returns True when anything was changed."""
if "aspect" not in space:
return False
try:
aspect = float(space.get("aspect") or 1)
except (TypeError, ValueError):
aspect = 1.0
dx, dy, kx, ky = transform_for(aspect)
space.pop("aspect", None)
for room in space.get("rooms") or []:
if room.get("x") is not None:
room["x"] = dx + float(room["x"]) * kx
if room.get("y") is not None:
room["y"] = dy + float(room["y"]) * ky
if room.get("w") is not None:
room["w"] = float(room["w"]) * kx
if room.get("h") is not None:
room["h"] = float(room["h"]) * ky
if room.get("poly"):
room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]
for op in space.get("openings") or []:
op["x"] = dx + float(op.get("x", 0)) * kx
op["y"] = dy + float(op.get("y", 0)) * ky
# a length is measured along the wall, and the render scale is uniform
if op.get("length") is not None:
op["length"] = float(op["length"]) * kx
for shape in space.get("decor") or []:
for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
if shape.get(a) is not None:
shape[a] = dx + float(shape[a]) * fx
if shape.get(b) is not None:
shape[b] = dy + float(shape[b]) * fy
if shape.get("w") is not None:
shape["w"] = float(shape["w"]) * kx
if shape.get("h") is not None:
shape["h"] = float(shape["h"]) * ky
vb = space.get("view_box")
if isinstance(vb, list) and len(vb) == 4:
space["view_box"] = [
dx + float(vb[0]) * kx, dy + float(vb[1]) * ky,
float(vb[2]) * kx, float(vb[3]) * ky,
]
# The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
# canvas, so a wall now covers fewer cells; without this every measurement
# in the plan would silently shrink.
if kx != 1:
try:
cell = float(space.get("cell_cm") or 5)
except (TypeError, ValueError):
cell = 5.0
space["cell_cm"] = round(cell / kx, 4)
# The image keeps its own proportions and is centred; the space no longer
# has any of its own.
if space.get("plan_url") and not space.get("plan_aspect"):
space["plan_aspect"] = round(aspect, 6)
return True
def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
"""Migrate every space, and the marker positions that belong to them."""
spaces = config.get("spaces") or []
factors = {}
for space in spaces:
if "aspect" in space:
factors[str(space.get("id"))] = transform_for(space.get("aspect") or 1)
if not factors:
return False
for space in spaces:
migrate_space(space)
for pos in (layout or {}).values():
if not isinstance(pos, dict):
continue
f = factors.get(str(pos.get("s")))
if not f:
continue
dx, dy, kx, ky = f
if pos.get("x") is not None:
pos["x"] = dx + float(pos["x"]) * kx
if pos.get("y") is not None:
pos["y"] = dy + float(pos["y"]) * ky
return True
+1 -1
View File
@@ -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.47.0" "version": "1.48.0"
} }
+9 -1
View File
@@ -188,7 +188,15 @@ SPACE_SCHEMA = vol.Schema(
vol.Required("title"): str, vol.Required("title"): str,
vol.Optional("settings"): SPACE_DISPLAY_SCHEMA, vol.Optional("settings"): SPACE_DISPLAY_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)), # The canvas is square since v1.48.0. What used to be the space's own
# `aspect` is gone; the background image keeps its own proportions and
# is centred, so only the IMAGE's ratio is stored. A stale tab may still
# send the old field — it is dropped rather than trusted, because the
# coordinates it comes with were normalised against a different box.
vol.Remove("aspect"): object,
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)), vol.Required("view_box"): vol.All([_finite], vol.Length(min=4, max=4)),
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)), vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)), vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
+2 -2
View File
@@ -33,7 +33,7 @@ const res = await page.evaluate(async () => {
const a = devs[0], b = devs[1]; const a = devs[0], b = devs[1];
const pa = c._pos(a); const pa = c._pos(a);
// поставим b на тот же Y, начнём drag // поставим b на тот же Y, начнём drag
c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / (1000 / (c._curSpaceCfg.aspect || 1)) } }; c._layout = { ...c._layout, [b.id]: { s: c._space, x: (pa.x + g * 12) / 1000, y: pa.y / 1000 } };
c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true }; c._drag = { id: b.id, sx: 0, sy: 0, ox: 0, oy: 0, moved: true };
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
out.devGuide = guides() >= 1; out.devGuide = guides() >= 1;
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
// 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры // 4) подложка: рисование прямоугольника с углом на одном X с углом другой фигуры
c._setMode('decor'); await c.updateComplete; c._setMode('decor'); await c.updateComplete;
c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }]; c._curSpaceCfg.decor = [{ id: 'd1', kind: 'rect', x: 0.2, y: 0.2, w: 0.1, h: 0.1, color: '#ff0000', width: 3 }];
const W = 1000, H = 1000 / (c._curSpaceCfg.aspect || 1); const W = 1000, H = 1000; // square canvas
c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1 c._decorDraft = { kind: 'rect', a: [0.5 * W, 0.5 * H], b: [0.2 * W, 0.6 * H], pid: 9 }; // b.x == углу d1
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
out.decorGuide = guides() >= 1; out.decorGuide = guides() >= 1;
+2 -2
View File
@@ -11,7 +11,7 @@ const res = await page.evaluate(async () => {
document.body.appendChild(c1); document.body.appendChild(c1);
c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{}, c1.hass = { language:'en', locale:{language:'en'}, devices:{}, entities:{}, areas:{}, states:{},
callWS: async (m) => m.type==='houseplan/config/get' callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, aspect:1.4, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 } ? { config:{ spaces:[{ id:'s1', title:'Empty', plan_url:null, view_box:[0,0,1,1], rooms:[], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} }, : { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } }; connection:{ subscribeEvents: async()=>()=>{} } };
await new Promise(r=>setTimeout(r,150)); await new Promise(r=>setTimeout(r,150));
@@ -31,7 +31,7 @@ const res = await page.evaluate(async () => {
devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } }, devices:{ d1:{ id:'d1', name: evil, model:'M<script>1</script>', area_id:'a1', identifiers:[['x','1']] } },
entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{}, entities:{}, areas:{ a1:{ area_id:'a1', name:'A1' } }, states:{},
callWS: async (m) => m.type==='houseplan/config/get' callWS: async (m) => m.type==='houseplan/config/get'
? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, aspect:1, view_box:[0,0,1,1], ? { config:{ spaces:[{ id:'s1', title:'S', plan_url:null, view_box:[0,0,1,1],
rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 } rooms:[{ id:'r1', name: evil, area:'a1', poly:[[0.1,0.1],[0.9,0.1],[0.9,0.9],[0.1,0.9]] }], segments:[] }], markers:[], settings:{} }, rev:1 }
: { layout:{} }, : { layout:{} },
connection:{ subscribeEvents: async()=>()=>{} } }; connection:{ subscribeEvents: async()=>()=>{} } };
+3 -4
View File
@@ -44,7 +44,7 @@ const res = await page.evaluate(async () => {
const c2 = c._roomCenter(r2); const c2 = c._roomCenter(r2);
const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]]; const poly1 = r1.poly || [[r1.x, r1.y], [r1.x + r1.w, r1.y], [r1.x + r1.w, r1.y + r1.h], [r1.x, r1.y + r1.h]];
// общая стена вертикальная — дверь ставим на неё // общая стена вертикальная — дверь ставим на неё
const H = 1000 / (c._curSpaceCfg.aspect || 1); const H = 1000; // square canvas
const doorPt = (() => { const doorPt = (() => {
let best = null, bd = 1e9; let best = null, bd = 1e9;
for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) { for (const [x, y] of [[550, 150], [550, 200], [550, 250]]) {
@@ -57,9 +57,8 @@ const res = await page.evaluate(async () => {
...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) }; ...s, openings: [{ id: 'gd', type: 'door', x: doorPt[0] / 1000, y: doorPt[1] / H, angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
// источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу) // источник детерминированно ставим в центр r1 (двигаем реальную включённую лампу)
const aspect = c._curSpaceCfg.aspect || 1;
const c1 = c._roomCenter(r1); const c1 = c._roomCenter(r1);
c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } }; c._layout = { ...c._layout, [litLight.id]: { s: spId, x: c1[0] / 1000, y: c1[1] / 1000 } };
// радиус 6 м, чтобы дверь заведомо была в зоне досягаемости // радиус 6 м, чтобы дверь заведомо была в зоне досягаемости
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } }; c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 600 } };
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
@@ -73,7 +72,7 @@ const res = await page.evaluate(async () => {
const minX = Math.min(...poly1.map((p) => p[0])); const minX = Math.min(...poly1.map((p) => p[0]));
const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2; const yMid = (Math.min(...poly1.map((p) => p[1])) + Math.max(...poly1.map((p) => p[1]))) / 2;
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({ c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== spId ? s : ({
...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / (1000 / aspect), angle: 90, length: 0.09 }] })) }; ...s, openings: [{ id: 'gd2', type: 'door', x: minX / 1000, y: yMid / 1000, angle: 90, length: 0.09 }] })) };
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')]; const clipEls2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"]')];
out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1); out.entranceNoSector = clipEls2.every((cp) => cp.querySelectorAll('path').length === 1);
+1 -2
View File
@@ -38,8 +38,7 @@ const res = await page.evaluate(async () => {
const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id; const vid = c._serverCfg.markers.find((m) => m.name === 'Тест')?.id;
const center = c._roomCenter(room); const center = c._roomCenter(room);
const vpos = c._layout[vid]; const vpos = c._layout[vid];
const aspect = c._curSpaceCfg.aspect || 1; out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * 1000 - center[1]) < 1;
out.newCentered = vpos && Math.abs(vpos.x * 1000 - center[0]) < 1 && Math.abs(vpos.y * (1000 / aspect) - center[1]) < 1;
return out; return out;
}); });
checkAll(res); checkAll(res);
+1 -1
View File
@@ -9,7 +9,7 @@ const restore = () => page.evaluate((s) => {
}, snap); }, snap);
// norm→render helper mirrors what _markupClick passes to handlers // norm→render helper mirrors what _markupClick passes to handlers
const R = (nx, ny) => page.evaluate(([nx, ny]) => { const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H]; return [nx * 1000, ny * 1000]; // the canvas is square (v1.48.0)
}, [nx, ny]); }, [nx, ny]);
const S = () => page.evaluate(() => { const S = () => page.evaluate(() => {
const c = window.__card; const c = window.__card;
+2 -3
View File
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot; const sr = () => c.shadowRoot || c.renderRoot;
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete; c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
// r1|r2 делят стену x=0.55 → клик по ней открывает границу // r1|r2 делят стену x=0.55 → клик по ней открывает границу
const H = 1000 / (c._curSpaceCfg.aspect || 1); const H = 1000; // square canvas
c._openWallClick([550, 0.25 * H]); c._openWallClick([550, 0.25 * H]);
await c.updateComplete; await c.updateComplete;
const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1'); const r1 = c._curSpaceCfg.rooms.find((r) => r.id === 'r1');
@@ -53,8 +53,7 @@ const res = await page.evaluate(async () => {
...s, settings: { ...(s.settings || {}), fill_mode: 'glow' } })) }; ...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 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 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 } };
c._layout = { ...c._layout, [litLight.id]: { s: c._space, x: c1[0] / 1000, y: c1[1] / (1000 / aspect) } };
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]'); const clip = sr().querySelector('defs clipPath[id^="hp-glowclip"]');
out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false; out.zoneClip = clip ? clip.querySelectorAll('path').length >= 2 : false;
+1 -1
View File
@@ -6,7 +6,7 @@ const res = await page.evaluate(async () => {
const sr = () => c.shadowRoot || c.renderRoot; const sr = () => c.shadowRoot || c.renderRoot;
const stage = () => sr().querySelector('.stage'); const stage = () => sr().querySelector('.stage');
c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete; c._setMode('plan'); c._tool = 'openwall'; await c.updateComplete;
const H = 1000 / (c._curSpaceCfg.aspect || 1); const H = 1000; // square canvas
// 1) без наведения: курсор default, превью нет // 1) без наведения: курсор default, превью нет
c._cursorPt = null; c.requestUpdate(); await c.updateComplete; c._cursorPt = null; c.requestUpdate(); await c.updateComplete;
out.idleCursor = getComputedStyle(stage()).cursor === 'default'; out.idleCursor = getComputedStyle(stage()).cursor === 'default';
+4 -4
View File
@@ -39,7 +39,7 @@ const res = await page.evaluate(async () => {
const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1'); const sentF1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1'); const liveF1 = (c._serverCfg?.spaces || []).find((s) => s.id === 'f1');
out.sentPlanUrl = sentF1?.plan_url; out.sentPlanUrl = sentF1?.plan_url;
out.sentAspect = sentF1?.aspect; out.sentPlanAspect = sentF1?.plan_aspect; // the IMAGE's ratio; the canvas is square
out.sentTitle = sentF1?.title; out.sentTitle = sentF1?.title;
out.livePlanUrl = liveF1?.plan_url; out.livePlanUrl = liveF1?.plan_url;
out.dialogClosed = c._spaceDialog === null; out.dialogClosed = c._spaceDialog === null;
@@ -52,19 +52,19 @@ const res = await page.evaluate(async () => {
const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic'); const attic = (c.__sent?.spaces || []).find((s) => s.title === 'Attic');
out.atticSaved = !!attic; out.atticSaved = !!attic;
out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/'); out.atticHasPlan = !!attic && typeof attic.plan_url === 'string' && attic.plan_url.includes('/content/plans/');
out.atticAspect = attic?.aspect; out.atticPlanAspect = attic?.plan_aspect;
return out; return out;
}); });
// зафиксировано прогоном на v1.44.8 и сверено с кодом // зафиксировано прогоном на v1.44.8 и сверено с кодом
checkAll(res, { checkAll(res, {
reloadHappened: true, reloadHappened: true,
sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42', sentPlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
sentAspect: 1.6, sentPlanAspect: 1.6,
sentTitle: 'Ground', sentTitle: 'Ground',
livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42', livePlanUrl: '/api/houseplan/content/plans/_/f1.png?v=42',
dialogClosed: true, dialogClosed: true,
atticSaved: true, atticSaved: true,
atticHasPlan: true, atticHasPlan: true,
atticAspect: 0.8, atticPlanAspect: 0.8,
}); });
await finish(browser); await finish(browser);
+2 -2
View File
@@ -42,7 +42,7 @@ const res = await page.evaluate(async () => {
out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]'); out.saveEnabled = !sr().querySelector('.dialog .btn.on[disabled]');
await c._saveSpaceDialog(); await c.updateComplete; await c._saveSpaceDialog(); await c.updateComplete;
const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic'); const attic = c._serverCfg.spaces.find((s) => s.title === 'Attic');
out.atticAspect = attic?.aspect; out.atticSquare = attic?.aspect === undefined; // no per-space ratio any more
out.atticSettings = attic?.settings; out.atticSettings = attic?.settings;
out.atticNoPlan = attic ? attic.plan_url === null : null; out.atticNoPlan = attic ? attic.plan_url === null : null;
return out; return out;
@@ -55,7 +55,7 @@ checkAll(res, {
"labels": ["Living room", "Kitchen", "Bedroom", "Hallway"], "labels": ["Living room", "Kitchen", "Bedroom", "Hallway"],
"livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180", "livingStyle": "--room-stroke:#ff8800;--room-stroke-op:0.8;--room-fill:#ffd45c;--room-fill-op:0.180",
"lqiFills": 0, "lqiFills": 0,
"atticAspect": 1, "atticSquare": true,
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false}, "atticSettings": {"show_borders": true, "show_names": true, "room_color": "#3ea6ff", "room_opacity": 0.55, "fill_mode": "none", "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
}); });
await finish(browser, res); await finish(browser, res);
+3 -3
View File
@@ -2,13 +2,13 @@
import { launch, checkAll, finish } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const R = (nx, ny) => page.evaluate(([nx, ny]) => { const R = (nx, ny) => page.evaluate(([nx, ny]) => {
const c = window.__card; const H = 1000 / c._curSpaceCfg.aspect; return [nx * 1000, ny * H]; return [nx * 1000, ny * 1000]; // square canvas (v1.48.0)
}, [nx, ny]); }, [nx, ny]);
const out = {}; const out = {};
await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';}); await page.evaluate(()=>{const c=window.__card; if(!c._markup)c._setMode('plan'); c._tool='split';});
// living room (r1) has walls at y=0.05 which are NOT grid nodes; click near the wall // living room (r1) has walls at y=0.14 which are NOT grid nodes; click near the wall
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.3)); // pick living
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.052)); // near top wall (off grid) await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.142)); // near top wall (off grid)
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall await page.evaluate((p)=>window.__card._splitClick(p), await R(0.3,0.58)); // near bottom wall
out.pending = await page.evaluate(()=>!!window.__card._pendingSplit); out.pending = await page.evaluate(()=>!!window.__card._pendingSplit);
out.dialog = await page.evaluate(()=>!!window.__card._roomDialog); out.dialog = await page.evaluate(()=>!!window.__card._roomDialog);
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -51,17 +51,17 @@ customElements.define('ha-card',HaCard);
<script type="module"> <script type="module">
const CFG = { const CFG = {
spaces: [ spaces: [
{ id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', aspect:1.25, { id:'f1', title:'Ground floor', plan_url:'/assets/f1.svg', plan_aspect:1.25,
view_box:[0,0,1,1], view_box:[0,0,1,1],
rooms:[ rooms:[
{id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.05],[0.55,0.05],[0.55,0.6],[0.04,0.6]]}, {id:'r1', name:'Living room', area:'living_room', poly:[[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]]},
{id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.05],[0.96,0.05],[0.96,0.45],[0.55,0.45]]}, {id:'r2', name:'Kitchen', area:'kitchen', poly:[[0.55,0.14],[0.96,0.14],[0.96,0.46],[0.55,0.46]]},
{id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.45],[0.96,0.45],[0.96,0.95],[0.55,0.95]]}, {id:'r3', name:'Bedroom', area:'bedroom', poly:[[0.55,0.46],[0.96,0.46],[0.96,0.86],[0.55,0.86]]},
{id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.6],[0.55,0.6],[0.55,0.95],[0.04,0.95]]} {id:'r4', name:'Hallway', area:'hallway', poly:[[0.04,0.58],[0.55,0.58],[0.55,0.86],[0.04,0.86]]}
], segments:[] }, ], segments:[] },
{ id:'garden', title:'Garden', plan_url:'/assets/garden.svg', aspect:1.4286, { id:'garden', title:'Garden', plan_url:'/assets/garden.svg', plan_aspect:1.4286,
view_box:[0,0,1,1], view_box:[0,0,1,1],
rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.04],[0.97,0.04],[0.97,0.96],[0.03,0.96]]} ], segments:[] } rooms:[ {id:'g1', name:'Garden', area:'garden', poly:[[0.03,0.178],[0.97,0.178],[0.97,0.822],[0.03,0.822]]} ], segments:[] }
], ],
markers: [], markers: [],
settings: {} settings: {}
+22 -27
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -182,6 +182,17 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` | | `houseplan/plans/delete` | `name` | `{ok, removed}` / err `in_use` |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) | | `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**The canvas is square, the image is not** (v1.48.0). A space used to carry an
`aspect`, and coordinates were normalised against it — x by the width, y by the
height. That made every geometric question depend on a per-space number for no
benefit. Now the render space is `NORM_W × NORM_W` and a plan image is fitted
inside it by its own ratio (`fitInSquare`, shared by both renderers), which is
stored as `plan_aspect` so the layout does not jump before the file loads.
Upgrading runs `geometry_migration.migrate_config` once: it pads the old box out
to a square and re-expresses every coordinate against it — a uniform scale plus
an offset in render units, so angles and proportions are exact — and scales
`cell_cm` for tall plans, since the grid pitch is a fraction of the width.
**User content is served inert** (HP-1454-01). An uploaded SVG is the only **User content is served inert** (HP-1454-01). An uploaded SVG is the only
thing here that a browser will happily treat as a *document* rather than an thing here that a browser will happily treat as a *document* rather than an
image, and it would be a document of Home Assistant's own origin. Inside the image, and it would be a document of Home Assistant's own origin. Inside the
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## v1.48.0 — 2026-07-28 (the canvas is always square)
- **A space no longer has proportions of its own.** The drawing area is a square;
a plan image keeps its own shape and is centred inside it, so a wide plan gets
margins above and below and a tall one gets them at the sides. There is
nothing left to choose — the canvas orientation setting for hand-drawn spaces
is gone with it.
- **Existing plans are migrated once, on upgrade.** Nothing about a drawing
changes: the box is padded out to a square and every coordinate is
re-expressed against it — rooms, doors and windows, decor, marker positions
and the saved viewport. Angles, room proportions and relative positions are
preserved exactly. For a tall plan the scale in centimetres per grid cell is
adjusted along with it, because the grid is tied to the width; without that a
wall would silently measure less than it does.
## v1.47.0 — 2026-07-28 (pick a plan you already uploaded) ## v1.47.0 — 2026-07-28 (pick a plan you already uploaded)
- **The space dialog can now show the plans stored on the server.** Detaching a - **The space dialog can now show the plans stored on the server.** Detaching a
plan keeps the image on disk — that has been the rule since v1.46.4, but until plan keeps the image on disk — that has been the rule since v1.46.4, but until
+14
View File
@@ -6,6 +6,20 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим > **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md). > изменением — как и остальная документация (см. docs/STATUS.md).
## v1.48.0 — 2026-07-28 (холст всегда квадратный)
- **У пространства больше нет собственных пропорций.** Область рисования —
квадрат, а картинка плана сохраняет свою форму и вписывается в него по
центру: у широкого плана появляются поля сверху и снизу, у вытянутого — по
бокам. Выбирать нечего, поэтому настройка ориентации холста для пространств
без картинки убрана.
- **Существующие планы переносятся один раз, при обновлении.** В самом рисунке
ничего не меняется: коробка дополняется до квадрата, и все координаты
пересчитываются относительно неё — комнаты, двери и окна, декор, позиции
значков и сохранённая область просмотра. Углы, пропорции комнат и взаимное
расположение сохраняются точно. Для вытянутых планов заодно пересчитывается
масштаб в сантиметрах на клетку: сетка привязана к ширине, и без этого стены
молча стали бы короче.
## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов) ## v1.47.0 — 2026-07-28 (выбор из уже загруженных планов)
- **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление - **Диалог пространства показывает планы, сохранённые на сервере.** Отцепление
плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было плана оставляет картинку на диске — так с v1.46.4, но вернуть её можно было
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.47.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | | Version | **v1.48.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) | | Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) | | GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish | | CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) | | HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.47.0** via direct copy (HACS custom repo also installed) | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.48.0** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover | | Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run | | Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts | | Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
+9
View File
@@ -239,6 +239,15 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
on the plan after a reload. Same for each tap action and each fill mode on the plan after a reload. Same for each tap action and each fill mode
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and [auto: backend test_every_display_mode_the_editor_offers_is_accepted and
neighbours, test_a_marker_showing_its_value_can_be_saved] neighbours, test_a_marker_showing_its_value_can_be_saved]
- [ ] Square canvas migration (v1.48.0): after the upgrade every existing plan
looks exactly as before, just with margins where the canvas was extended.
Measure a wall in the plan editor — the length in cm is unchanged. Marker
positions, doors, decor and the saved zoom are all where they were
[auto: unit: test_a_wide_plan_gains_margins_above_and_below and neighbours,
test_migration_preserves_real_lengths_and_shapes]
- [ ] A plan image is centred (v1.48.0): a wide image sits in the middle with
empty bands above and below, a tall one with bands at the sides, and it is
never stretched [auto: unit: fitInSquare + smoke_space_settings]
- [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE - [ ] Re-attaching a detached plan (v1.47.0): detach a plan, save, RELOAD THE
PAGE, open space settings → "Already uploaded" → the image is listed with PAGE, open space settings → "Already uploaded" → the image is listed with
its size and no "in use" note → attach it → it renders. The one a space its size and no "in use" note → attach it → it renders. The one a space
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.47.0", "version": "1.48.0",
"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",
+26 -39
View File
@@ -33,15 +33,16 @@ import type {
import './editor'; import './editor';
import './space-card'; import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { fitInSquare } from './space-geometry';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.47.0'; const CARD_VERSION = '1.48.0';
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';
const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were) const LS_NAV = 'houseplan_card_nav_v1'; // last space + editor mode (owner: restore where you were)
const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs) const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs)
const NORM_W = 1000; // width of the render space for normalized configs const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0)
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) 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' | 'openwall' | 'delroom'; type MarkupTool = 'draw' | 'merge' | 'split' | 'opening' | 'openwall' | 'delroom';
@@ -287,7 +288,6 @@ class HouseplanCard extends LitElement {
savedBusy?: boolean; savedBusy?: boolean;
savedAspect?: number; savedAspect?: number;
source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms source: 'file' | 'draw'; // draw = no background image, hand-drawn rooms
orientation: 'landscape' | 'portrait' | 'square';
showBorders: boolean; showBorders: boolean;
showNames: boolean; showNames: boolean;
roomColor: string; roomColor: string;
@@ -569,7 +569,7 @@ class HouseplanCard extends LitElement {
return !!(this._serverCfg && this._serverCfg.spaces.length); return !!(this._serverCfg && this._serverCfg.spaces.length);
} }
/** Spaces in render units (NORM_W × NORM_W/aspect). */ /** Spaces in render units (NORM_W × NORM_W — the canvas is square). */
/** Bumped by every config mutation — the model/geometry cache key (audit L1). */ /** Bumped by every config mutation — the model/geometry cache key (audit L1). */
private _cfgEpoch = 0; private _cfgEpoch = 0;
private _modelCache: { key: string; model: SpaceModel[] } | null = null; private _modelCache: { key: string; model: SpaceModel[] } | null = null;
@@ -579,7 +579,7 @@ class HouseplanCard extends LitElement {
const sp = this._serverCfg?.spaces || []; const sp = this._serverCfg?.spaces || [];
let s = sp.length + ':'; let s = sp.length + ':';
for (const x of sp as any[]) { for (const x of sp as any[]) {
s += (x.id || '') + ',' + (x.aspect || '') + ',' + (x.plan_url || '').length + ',' s += (x.id || '') + ',' + (x.plan_aspect || '') + ',' + (x.plan_url || '').length + ','
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';'; + (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
for (const r of x.rooms || []) { for (const r of x.rooms || []) {
// O(1) geometry roll-up per room: the count alone said nothing about // O(1) geometry roll-up per room: the count alone said nothing about
@@ -610,7 +610,7 @@ class HouseplanCard extends LitElement {
private _buildModel(): SpaceModel[] { 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; // the canvas is always square (v1.48.0)
const scale = (r: any) => ({ const scale = (r: any) => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
@@ -631,7 +631,7 @@ class HouseplanCard extends LitElement {
// so a signed url baked in here would freeze BEFORE the signature // so a signed url baked in here would freeze BEFORE the signature
// arrives and the plan would never load (bug found 2026-07-27). // arrives and the plan would never load (bug found 2026-07-27).
// _display() is called at render time instead. // _display() is called at render time instead.
bg: s.plan_url ? { href: s.plan_url, x: 0, y: 0, w: NORM_W, h: H } : null, bg: s.plan_url ? { href: s.plan_url, ...fitInSquare(s.plan_aspect, NORM_W) } : null,
rooms: s.rooms.map(scale), rooms: s.rooms.map(scale),
}; };
}); });
@@ -1071,8 +1071,7 @@ class HouseplanCard extends LitElement {
if (saved) { if (saved) {
if (this._norm) { if (this._norm) {
if (saved.s === d.space) { if (saved.s === d.space) {
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === d.space)?.aspect || 1; return { x: saved.x * NORM_W, y: saved.y * NORM_W };
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
} }
} else if (saved.s === undefined) { } else if (saved.s === undefined) {
return { x: saved.x, y: saved.y }; return { x: saved.x, y: saved.y };
@@ -1089,11 +1088,11 @@ class HouseplanCard extends LitElement {
const g = this._gridPitch; const g = this._gridPitch;
const gx = Math.round(x / g) * g; const gx = Math.round(x / g) * g;
const gy = Math.round(y / g) * g; const gy = Math.round(y / g) * g;
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === d.space)?.aspect || 1;
const prevK = (this._layout[d.id] as any)?.k; const prevK = (this._layout[d.id] as any)?.k;
this._layout = { this._layout = {
...this._layout, ...this._layout,
[d.id]: { s: d.space, x: gx / NORM_W, y: gy / (NORM_W / aspect), ...(prevK ? { k: prevK } : {}) }, [d.id]: { s: d.space, x: gx / NORM_W, y: gy / NORM_W, ...(prevK ? { k: prevK } : {}) },
}; };
} else { } else {
this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } }; this._layout = { ...this._layout, [d.id]: { x: Math.round(x), y: Math.round(y) } };
@@ -1567,7 +1566,7 @@ class HouseplanCard extends LitElement {
private get _spaceH(): number { private get _spaceH(): number {
const sp = this._curSpaceCfg; const sp = this._curSpaceCfg;
return sp ? NORM_W / sp.aspect : NORM_W; return NORM_W; // square canvas
} }
/** /**
@@ -1827,7 +1826,7 @@ class HouseplanCard extends LitElement {
} }
private get _decorH(): number { private get _decorH(): number {
return NORM_W / (this._curSpaceCfg?.aspect || 1); return NORM_W;
} }
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */ /** Begin a decor gesture. Returns true when the event is consumed (no pan). */
@@ -2111,7 +2110,7 @@ class HouseplanCard extends LitElement {
// //
// The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a // The key is the SPACE MODEL OBJECT ITSELF (HP-1454-04). It used to be a
// string of room ids and open_to links, which said nothing about geometry: // string of room ids and open_to links, which said nothing about geometry:
// change the space's aspect, or drag a vertex, and the shared segments were // change the plan, or drag a vertex, and the shared segments were
// recomputed for the outlines but the open boundaries — and the glow cuts // recomputed for the outlines but the open boundaries — and the glow cuts
// that follow them — kept their old coordinates until a full reload. // that follow them — kept their old coordinates until a full reload.
// `_model` is already rebuilt whenever the epoch or the config fingerprint // `_model` is already rebuilt whenever the epoch or the config fingerprint
@@ -2540,8 +2539,7 @@ class HouseplanCard extends LitElement {
// so that icons do not get reshuffled when the order in the HA registry changes. // so that icons do not get reshuffled when the order in the HA registry changes.
let added = 0; let added = 0;
if (boundArea) { if (boundArea) {
const aspect = this._serverCfg?.spaces.find((x: any) => x.id === this._space)?.aspect || 1; const H2 = NORM_W;
const H2 = NORM_W / aspect;
const next = { ...this._layout }; const next = { ...this._layout };
for (const d of this._devices) { for (const d of this._devices) {
if (d.area !== boundArea || d.space !== this._space) continue; if (d.area !== boundArea || d.space !== this._space) continue;
@@ -2996,8 +2994,7 @@ class HouseplanCard extends LitElement {
} }
private _normPos(space: string, x: number, y: number): { s: string; x: number; y: number } { private _normPos(space: string, x: number, y: number): { s: string; x: number; y: number } {
const aspect = this._serverCfg!.spaces.find((s: any) => s.id === space)?.aspect || 1; return { s: space, x: x / NORM_W, y: y / NORM_W };
return { s: space, x: x / NORM_W, y: y / (NORM_W / aspect) };
} }
// ================= SPACE MANAGEMENT ================= // ================= SPACE MANAGEMENT =================
@@ -3013,7 +3010,7 @@ class HouseplanCard extends LitElement {
const disp = spaceDisplayOf(sp); const disp = spaceDisplayOf(sp);
this._spaceDialog = { this._spaceDialog = {
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null, mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
source: sp.plan_url ? 'file' : 'draw', orientation: 'landscape', source: sp.plan_url ? 'file' : 'draw',
showBorders: disp.showBorders, showNames: disp.showNames, showBorders: disp.showBorders, showNames: disp.showNames,
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill, roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
tempMin: disp.tempMin, tempMax: disp.tempMax, tempMin: disp.tempMin, tempMax: disp.tempMax,
@@ -3027,7 +3024,7 @@ class HouseplanCard extends LitElement {
} else { } else {
this._spaceDialog = { this._spaceDialog = {
mode, title: '', planUrl: null, planFile: null, mode, title: '', planUrl: null, planFile: null,
source: 'file', orientation: 'landscape', source: 'file',
showBorders: false, showNames: false, showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
@@ -3162,7 +3159,6 @@ class HouseplanCard extends LitElement {
const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0; const wasFirst = d.mode === 'create' && (this._serverCfg?.spaces.length || 0) === 0;
this._spaceDialog = { ...d, busy: true }; this._spaceDialog = { ...d, busy: true };
try { try {
const drawAspect = d.orientation === 'portrait' ? 0.707 : d.orientation === 'square' ? 1 : 1.414;
const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!; const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!;
/* Upload BEFORE touching the config, and never hold a reference to a /* Upload BEFORE touching the config, and never hold a reference to a
@@ -3188,7 +3184,7 @@ class HouseplanCard extends LitElement {
id: spaceId, id: spaceId,
title: d.title.trim(), title: d.title.trim(),
plan_url: null, plan_url: null,
aspect: d.source === 'draw' ? drawAspect : 1.414,
view_box: [0, 0, 1, 1], view_box: [0, 0, 1, 1],
rooms: [], rooms: [],
}; };
@@ -3200,15 +3196,16 @@ class HouseplanCard extends LitElement {
} }
if (uploaded) { if (uploaded) {
sp.plan_url = uploaded.url; sp.plan_url = uploaded.url;
sp.aspect = uploaded.aspect; // the image's own proportions, so it can be centred before it loads
sp.plan_aspect = uploaded.aspect;
} else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) { } else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) {
// picked from the server list: no upload, just a reference // picked from the server list: no upload, just a reference
sp.plan_url = d.planUrl; sp.plan_url = d.planUrl;
if (d.savedAspect) sp.aspect = d.savedAspect; if (d.savedAspect) sp.plan_aspect = d.savedAspect;
} }
// switching an existing space to "draw" detaches its background image // switching an existing space to "draw" detaches its background image
// (the uploaded file stays on disk; only the reference is cleared) // (the uploaded file stays on disk; only the reference is cleared)
if (d.source === 'draw') sp.plan_url = null; if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; }
// per-space display settings; hand-drawn spaces get borders+names on by default // per-space display settings; hand-drawn spaces get borders+names on by default
const draw = d.source === 'draw'; const draw = d.source === 'draw';
sp.settings = { sp.settings = {
@@ -3320,7 +3317,7 @@ class HouseplanCard extends LitElement {
if (title === undefined) return; if (title === undefined) return;
this._spaceDialog = { this._spaceDialog = {
mode: 'create', title, planUrl: null, planFile: null, mode: 'create', title, planUrl: null, planFile: null,
source: 'file', orientation: 'landscape', source: 'file',
showBorders: false, showNames: false, showBorders: false, showNames: false,
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX,
@@ -4195,8 +4192,7 @@ class HouseplanCard extends LitElement {
private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } { private _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } {
const saved = this._layout['rl_' + (r.id || '')]; const saved = this._layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) { if (saved && saved.s === spaceId) {
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === spaceId)?.aspect || 1; return { x: saved.x * NORM_W, y: saved.y * NORM_W };
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
} }
const c = this._roomCenter(r); const c = this._roomCenter(r);
return { x: c[0], y: c[1] }; return { x: c[0], y: c[1] };
@@ -4272,10 +4268,10 @@ class HouseplanCard extends LitElement {
const room = sp.rooms.find((x) => x.id === roomId); const room = sp.rooms.find((x) => x.id === roomId);
if (!room) return; if (!room) return;
const p = this._labelPos(room, rs.space); const p = this._labelPos(room, rs.space);
const aspect = this._serverCfg!.spaces.find((x: any) => x.id === rs.space)?.aspect || 1;
this._layout = { this._layout = {
...this._layout, ...this._layout,
[rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / (NORM_W / aspect), k }, [rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / NORM_W, k },
}; };
} else { } else {
this._layout = { ...this._layout, [rs.id]: { ...rec, k } }; this._layout = { ...this._layout, [rs.id]: { ...rec, k } };
@@ -5243,15 +5239,6 @@ class HouseplanCard extends LitElement {
@change=${() => (this._spaceDialog = { ...d, source: 'draw' })} /> @change=${() => (this._spaceDialog = { ...d, source: 'draw' })} />
<span>${this._t('space.source_draw')}</span> <span>${this._t('space.source_draw')}</span>
</label> </label>
${d.source === 'draw' && d.mode === 'create'
? html`<label>${this._t('space.orientation')}</label>
<select class="areasel"
@change=${(e: Event) => (this._spaceDialog = { ...d, orientation: (e.target as HTMLSelectElement).value as any })}>
${[['landscape', 'orient.landscape'], ['portrait', 'orient.portrait'], ['square', 'orient.square']].map(
([v, k]) => html`<option value=${v} ?selected=${d.orientation === v}>${this._t(k as any)}</option>`,
)}
</select>`
: nothing}
<label>${this._t('space.scale_label')}</label> <label>${this._t('space.scale_label')}</label>
<div class="colorrow"> <div class="colorrow">
+22 -8
View File
@@ -6,16 +6,32 @@
import { declump, contentUrl } from './logic'; import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types'; import type { ServerConfig, SpaceModel, RoomCfg, DevItem } from './types';
export const NORM_W = 1000; // width of the render space for normalized configs export const NORM_W = 1000; // side of the render space — the canvas is square
/**
* Where a plan image sits inside the square canvas (v1.48.0).
*
* The canvas has no proportions of its own any more; the image keeps its own
* and is centred, so a wide plan gets margins above and below and a tall one
* gets them at the sides. `ratio` is the image's width/height; without it we
* assume square, which is only ever a brief guess before the file loads.
*/
export function fitInSquare(ratio: number | null | undefined, side: number) {
const r = Number(ratio);
const a = Number.isFinite(r) && r > 0 ? r : 1;
const w = a >= 1 ? side : side * a;
const h = a >= 1 ? side / a : side;
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
}
export type Pt = { x: number; y: number }; export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>; export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
/** Build render-space models (NORM_W × NORM_W/aspect) from a server config. */ /** Build render-space models (NORM_W × NORM_W) from a server config. */
export function spaceModels(cfg: ServerConfig | null): SpaceModel[] { export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
if (!cfg || !Array.isArray(cfg.spaces)) return []; if (!cfg || !Array.isArray(cfg.spaces)) return [];
return cfg.spaces.map((s: any) => { return cfg.spaces.map((s: any) => {
const H = NORM_W / s.aspect; const H = NORM_W; // square canvas
const scale = (r: any): RoomCfg => ({ const scale = (r: any): RoomCfg => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
@@ -35,7 +51,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: s.id, id: s.id,
title: s.title, title: s.title,
vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H], vb: [s.view_box[0] * NORM_W, s.view_box[1] * H, s.view_box[2] * NORM_W, s.view_box[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), x: 0, y: 0, w: NORM_W, h: H } : null, bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
rooms: (s.rooms || []).map(scale), rooms: (s.rooms || []).map(scale),
} as SpaceModel; } as SpaceModel;
}); });
@@ -91,8 +107,7 @@ export function defaultPositions(devs: DevItem[], model: SpaceModel, iconPct: nu
export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt { export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos: Record<string, Pt>, model: SpaceModel): Pt {
const saved = layout[d.id]; const saved = layout[d.id];
if (saved && saved.s === d.space) { if (saved && saved.s === d.space) {
const aspect = cfg.spaces.find((x: any) => x.id === d.space)?.aspect || 1; return { x: saved.x * NORM_W, y: saved.y * NORM_W };
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
} }
if (defPos[d.id]) return defPos[d.id]; if (defPos[d.id]) return defPos[d.id];
const vb = model.vb; const vb = model.vb;
@@ -103,8 +118,7 @@ export function markerPos(d: DevItem, layout: Layout, cfg: ServerConfig, defPos:
export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt { export function labelPos(r: RoomCfg, spaceId: string, layout: Layout, cfg: ServerConfig): Pt {
const saved = layout['rl_' + (r.id || '')]; const saved = layout['rl_' + (r.id || '')];
if (saved && saved.s === spaceId) { if (saved && saved.s === spaceId) {
const aspect = cfg.spaces.find((x: any) => x.id === spaceId)?.aspect || 1; return { x: saved.x * NORM_W, y: saved.y * NORM_W };
return { x: saved.x * NORM_W, y: saved.y * (NORM_W / aspect) };
} }
const c = roomCenter(r); const c = roomCenter(r);
return { x: c[0], y: c[1] }; return { x: c[0], y: c[1] };
+25 -12
View File
@@ -1,34 +1,47 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import {
NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, NORM_W, spaceModels, roomBounds, roomCenter, defaultPositions, markerPos, labelPos, fitInSquare,
} from '../test-build/space-geometry.js'; } from '../test-build/space-geometry.js';
const cfg = { const cfg = {
spaces: [{ spaces: [{
id: 'f1', title: '1st', aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1], id: 'f1', title: '1st', plan_aspect: 2, plan_url: '/plans/f1.svg', view_box: [0, 0, 1, 1],
rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }], rooms: [{ id: 'r1', name: 'Room', area: 'a1', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] }],
}, { }, {
id: 'yard', title: 'Yard', aspect: 1, view_box: [0, 0, 1, 1], rooms: [], id: 'yard', title: 'Yard', view_box: [0, 0, 1, 1], rooms: [],
}], }],
markers: [], settings: {}, markers: [], settings: {},
}; };
test('spaceModels: scales vb/rooms by NORM_W and H=NORM_W/aspect; bg only with plan_url', () => { test('spaceModels: the canvas is square; the image is centred by its own ratio', () => {
const m = spaceModels(cfg); const m = spaceModels(cfg);
assert.equal(m.length, 2); assert.equal(m.length, 2);
const f1 = m[0]; const f1 = m[0];
assert.deepEqual(f1.vb, [0, 0, 1000, 500]); // aspect 2 → H 500 assert.deepEqual(f1.vb, [0, 0, 1000, 1000]);
assert.equal(f1.bg.href, '/plans/f1.svg'); assert.equal(f1.bg.href, '/plans/f1.svg');
assert.deepEqual(f1.rooms[0].poly, [[100, 50], [500, 50], [500, 250], [100, 250]]); // a plan twice as wide as it is tall: full width, half height, margins above
// and below — the canvas has no proportions of its own any more (v1.48.0)
assert.deepEqual(f1.bg, { href: '/plans/f1.svg', x: 0, y: 250, w: 1000, h: 500 });
assert.deepEqual(f1.rooms[0].poly, [[100, 100], [500, 100], [500, 500], [100, 500]]);
assert.equal(m[1].bg, null); // no plan_url assert.equal(m[1].bg, null); // no plan_url
assert.equal(spaceModels(null).length, 0); assert.equal(spaceModels(null).length, 0);
}); });
test('fitInSquare: wide gets top/bottom margins, tall gets side margins', () => {
assert.deepEqual(fitInSquare(2, 1000), { x: 0, y: 250, w: 1000, h: 500 });
assert.deepEqual(fitInSquare(0.5, 1000), { x: 250, y: 0, w: 500, h: 1000 });
assert.deepEqual(fitInSquare(1, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
// unknown ratio (image not loaded yet, old config): assume square
for (const bad of [null, undefined, 0, -3, NaN, 'x']) {
assert.deepEqual(fitInSquare(bad, 1000), { x: 0, y: 0, w: 1000, h: 1000 });
}
});
test('roomBounds + roomCenter for a polygon', () => { test('roomBounds + roomCenter for a polygon', () => {
const r = spaceModels(cfg)[0].rooms[0]; const r = spaceModels(cfg)[0].rooms[0];
assert.deepEqual(roomBounds(r), { x: 100, y: 50, w: 400, h: 200 }); assert.deepEqual(roomBounds(r), { x: 100, y: 100, w: 400, h: 400 });
assert.deepEqual(roomCenter(r), [300, 150]); assert.deepEqual(roomCenter(r), [300, 300]);
}); });
test('markerPos: saved layout → default grid → space centre', () => { test('markerPos: saved layout → default grid → space centre', () => {
@@ -37,7 +50,7 @@ test('markerPos: saved layout → default grid → space centre', () => {
// saved layout (normalized) → render units // saved layout (normalized) → render units
assert.deepEqual( assert.deepEqual(
markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model), markerPos(dev, { d1: { s: 'f1', x: 0.2, y: 0.3 } }, cfg, {}, model),
{ x: 200, y: 150 }, // 0.2*1000, 0.3*(1000/2) { x: 200, y: 300 }, // 0.2*1000, 0.3*1000
); );
// default grid position (inside the room) // default grid position (inside the room)
const defPos = defaultPositions([dev], model, 2.5); const defPos = defaultPositions([dev], model, 2.5);
@@ -46,14 +59,14 @@ test('markerPos: saved layout → default grid → space centre', () => {
const b = roomBounds(model.rooms[0]); const b = roomBounds(model.rooms[0]);
assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h); assert.ok(defPos.d1.x >= b.x && defPos.d1.x <= b.x + b.w && defPos.d1.y >= b.y && defPos.d1.y <= b.y + b.h);
// no layout, no defPos → space centre // no layout, no defPos → space centre
assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 250 }); assert.deepEqual(markerPos(dev, {}, cfg, {}, model), { x: 500, y: 500 });
}); });
test('labelPos: saved rl_<id> → render units; else room centre', () => { test('labelPos: saved rl_<id> → render units; else room centre', () => {
const model = spaceModels(cfg)[0]; const model = spaceModels(cfg)[0];
const r = model.rooms[0]; const r = model.rooms[0];
assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 200 }); assert.deepEqual(labelPos(r, 'f1', { rl_r1: { s: 'f1', x: 0.3, y: 0.4 } }, cfg), { x: 300, y: 400 });
assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 150 }); // room centre assert.deepEqual(labelPos(r, 'f1', {}, cfg), { x: 300, y: 300 }); // room centre
}); });
test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => { test('defaultPositions: several devices in one room are spread (declumped, distinct)', () => {
+107 -4
View File
@@ -90,11 +90,19 @@ def test_room_schema_poly_or_rect():
v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]}) v.ROOM_SCHEMA({"id": "r4", "name": "D", "poly": [[0, 0], [1, 1]]})
def test_space_schema_aspect_range(): def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
ok = {"id": "f1", "title": "1", "aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []} """v1.48.0: the canvas is square; only the IMAGE keeps proportions.
v.SPACE_SCHEMA(ok)
A stale tab may still send `aspect`. It is dropped rather than trusted
the coordinates it arrives with were normalised against a different box, so
honouring the field would not make them right anyway.
"""
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
assert "aspect" not in v.SPACE_SCHEMA({**ok, "aspect": 1.4})
v.SPACE_SCHEMA({**ok, "plan_aspect": 1.4})
v.SPACE_SCHEMA({**ok, "plan_aspect": None})
with pytest.raises(vol.Invalid): with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "aspect": 0}) v.SPACE_SCHEMA({**ok, "plan_aspect": 0})
with pytest.raises(vol.Invalid): with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]}) v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
@@ -666,3 +674,98 @@ def test_only_a_staging_folder_ages_out(tmp_path):
assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour" assert not (files / "up_abandoned").exists(), "a cancelled dialog goes after an hour"
# ---------- square canvas migration (v1.48.0) ----------
gm = _load_pure("geometry_migration")
def _sq(space, layout=None):
cfg = {"spaces": [space]}
gm.migrate_config(cfg, layout if layout is not None else {})
return cfg["spaces"][0]
def test_a_wide_plan_gains_margins_above_and_below():
sp = _sq({
"id": "f1", "aspect": 2.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.0, "y": 0.0, "w": 1.0, "h": 1.0}],
})
r = sp["rooms"][0]
assert (r["x"], r["w"]) == (0.0, 1.0), "the width is untouched"
assert r["y"] == 0.25 and r["h"] == 0.5, "half the height, centred"
assert sp["cell_cm"] == 5, "the grid is tied to the width, which did not change"
assert "aspect" not in sp
def test_a_tall_plan_gains_margins_on_the_sides_and_rescales_the_grid():
sp = _sq({
"id": "f1", "aspect": 0.5, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "poly": [[0, 0], [1, 0], [1, 1], [0, 1]]}],
})
poly = sp["rooms"][0]["poly"]
assert [round(c, 6) for c in poly[0]] == [0.25, 0.0]
assert [round(c, 6) for c in poly[2]] == [0.75, 1.0], "half the width, centred"
assert sp["cell_cm"] == 10, "the canvas got twice as wide, so a cell is twice the cm"
def test_a_square_plan_is_left_alone():
before = {
"id": "f1", "aspect": 1.0, "cell_cm": 5, "view_box": [0, 0, 1, 1],
"rooms": [{"id": "r", "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4}],
}
sp = _sq({**before, "rooms": [dict(before["rooms"][0])]})
assert sp["rooms"][0] == before["rooms"][0]
assert sp["cell_cm"] == 5 and sp["view_box"] == [0, 0, 1, 1]
def test_migration_preserves_real_lengths_and_shapes():
"""A wall keeps its length in centimetres, and a square stays square."""
GRID = 1000.0
def wall_cm(space, p, q):
# render units per normalised unit is the canvas width, always 1000
dx = (q[0] - p[0]) * GRID
dy = (q[1] - p[1]) * GRID
pitch = GRID / 40 # whatever the grid is, the same constant both sides
return ((dx * dx + dy * dy) ** 0.5 / pitch) * float(space["cell_cm"])
for aspect in (2.0, 0.5, 0.8155784250916674, 1.4142):
# a square room, 0.2 x 0.2 of the OLD box, i.e. 200 x 200/aspect render
old = {"id": "f", "aspect": aspect, "cell_cm": 5,
"rooms": [{"id": "r", "poly": [[0.2, 0.2], [0.4, 0.2], [0.4, 0.4], [0.2, 0.4]]}]}
before_w = 0.2 * GRID
before_h = 0.2 * GRID / aspect
before_cm_w = (before_w / (GRID / 40)) * 5
sp = _sq(old)
poly = sp["rooms"][0]["poly"]
after_w = (poly[1][0] - poly[0][0]) * GRID
after_h = (poly[2][1] - poly[1][1]) * GRID
assert abs(after_w / after_h - before_w / before_h) < 1e-9, "shape preserved"
# cell_cm is stored rounded — a user reads it — so allow 0.01 cm on a
# 40 cm wall rather than pretending the scale is infinitely precise
assert abs(wall_cm(sp, poly[0], poly[1]) - before_cm_w) < 1e-2, "length in cm preserved"
def test_migration_moves_marker_positions_of_that_space_only():
layout = {
"a": {"s": "f1", "x": 0.5, "y": 0.5},
"b": {"s": "other", "x": 0.5, "y": 0.5},
"c": "not a dict",
}
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": []},
{"id": "other", "rooms": []}]}
assert gm.migrate_config(cfg, layout) is True
assert layout["a"] == {"s": "f1", "x": 0.5, "y": 0.5}, "x untouched for a wide plan"
assert layout["a"]["y"] == 0.5
assert layout["b"] == {"s": "other", "x": 0.5, "y": 0.5}, "another space is not touched"
def test_migration_runs_once_and_only_when_needed():
cfg = {"spaces": [{"id": "f1", "aspect": 2.0, "rooms": [], "cell_cm": 5}]}
assert gm.migrate_config(cfg, {}) is True
snapshot = repr(cfg)
assert gm.migrate_config(cfg, {}) is False, "already square: nothing to do"
assert repr(cfg) == snapshot