v1.58.0-beta.1: backdrop transform, paper by rooms, decor tool fix

This commit is contained in:
Matysh
2026-08-04 14:13:38 +03:00
parent df233905c5
commit 2fd46493db
28 changed files with 1789 additions and 187 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.57.0"
VERSION = "1.58.0-beta.1"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.57.0"
"version": "1.58.0-beta.1"
}
+18
View File
@@ -125,6 +125,13 @@ _GEOM = vol.All(_finite, vol.Range(min=-CANVAS_LIMIT, max=CANVAS_LIMIT))
# than the old unit square — while staying strictly positive.
_EXTENT = vol.All(_finite, vol.Range(min=0.001, max=CANVAS_LIMIT))
# The backdrop's uniform scale (docs/BACKDROP.md). A MULTIPLIER, not a
# coordinate: strictly positive, and bounded by what a person could mean —
# a hundredth of the canvas is already a thumbnail, a hundred canvases is
# already absurd. Mirrored by PLAN_SCALE_MIN/MAX in src/space-geometry.ts.
PLAN_SCALE_MIN = 0.01
PLAN_SCALE_MAX = 100.0
def _view_box(value):
"""[x, y, w, h]: the first two are coordinates, the last two are sizes."""
@@ -253,6 +260,17 @@ SPACE_SCHEMA = vol.Schema(
vol.Optional("plan_aspect"): vol.Any(
None, vol.All(vol.Coerce(float), vol.Range(min=0.05, max=20))
),
# Backdrop placement (docs/BACKDROP.md): the picture may be moved and
# scaled UNIFORMLY on the canvas. All three are optional and their
# absence is the pre-v1.58.0 behaviour exactly — there is no migration,
# and an old config validates unchanged. The offset is a normalised
# coordinate like every other one (the ±CANVAS_LIMIT garbage guard);
# the scale is a positive multiplier in a range a human could mean.
vol.Optional("plan_x"): vol.Any(None, _COORD),
vol.Optional("plan_y"): vol.Any(None, _COORD),
vol.Optional("plan_scale"): vol.Any(
None, vol.All(_finite, vol.Range(min=PLAN_SCALE_MIN, max=PLAN_SCALE_MAX))
),
vol.Required("view_box"): _view_box,
vol.Required("rooms"): vol.All([ROOM_SCHEMA], vol.Length(max=MAX_ROOMS)),
vol.Optional("decor"): vol.All([DECOR_SCHEMA], vol.Length(max=MAX_DECOR)),
+67
View File
@@ -0,0 +1,67 @@
// Backdrop image (docs/BACKDROP.md) — two shots for the owner:
// backdrop_frame — the transform frame mid-gesture: dashed outline, four
// finger-sized corner handles, and the live "W × H" badge
// stating the picture's real size through cell_cm;
// backdrop_paper — the new paper rule on an image plan: the opaque sheet is
// the ROOM CONTOURS, so the scene colour is visible around
// them and under the parts of the picture nobody has drawn
// over. The picture is scaled down here so both are obvious.
// Usage: node demo/shot_backdrop.mjs <outdir>
import { launch } from './serve.mjs';
const outDir = process.argv[2] || '/tmp';
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
await page.emulateMedia({ reducedMotion: 'reduce' });
const settle = () => page.waitForTimeout(260);
// ---- 1) the frame, mid-drag ---------------------------------------------
await page.evaluate(async () => {
const c = window.__card;
c._setMode('decor');
c._decorTool = 'backdrop';
c.requestUpdate();
await c.updateComplete;
});
await settle();
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const stage = window.__card.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
{
// grab the bottom-right handle and hold it mid-gesture, so the shot carries
// the frame AND the live badge at once
const [ax, ay] = await screenPt(1000, 900);
const [bx, by] = await screenPt(820, 760);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 4 });
await settle();
await page.screenshot({ path: `${outDir}/backdrop_frame.png` });
await page.mouse.up();
}
await settle();
// ---- 2) the paper is the rooms ------------------------------------------
await page.evaluate(async () => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
// a picture pulled well inside the plan and an acid scene colour: what shows
// through around the rooms is the SCENE, which is the whole new rule
sp.plan_x = 0.16; sp.plan_y = 0.10; sp.plan_scale = 0.62;
c._serverCfg.settings = { ...(c._serverCfg.settings || {}), bg_color: '#7bd389' };
c._cfgEpoch++; c._frame = null;
c._setMode('view');
c.requestUpdate();
await c.updateComplete;
c._fitAll();
c.requestUpdate();
await c.updateComplete;
});
await settle();
await settle();
await page.screenshot({ path: `${outDir}/backdrop_paper.png` });
await browser.close();
console.log('shots written to ' + outDir + '/backdrop_frame.png, ' + outDir + '/backdrop_paper.png');
+329
View File
@@ -0,0 +1,329 @@
/**
* Backdrop image: move, uniform scale, and the new paper rule (docs/BACKDROP.md).
*
* The whole contract, on the demo's f1 — which IS an image plan, so every
* assertion here lands on exactly the case that used to behave differently:
* • the transform frame exists in the backdrop editor and nowhere else;
* • dragging the body moves the picture and writes plan_x/plan_y;
* • a corner handle scales UNIFORMLY about the opposite corner;
* • live badges state the real size in metres, through cell_cm;
* • position and size land on the grid, Shift steps off it;
* • the paper follows the ROOMS even here, and the picture is drawn above
* the paper and below the walls;
* • «Вписать всё» still finds a picture that has been dragged away;
* • the static card reads the same three fields.
*/
import { launch, check, finish } from './serve.mjs';
const { page, browser } = await launch();
const snap = await page.evaluate(() => JSON.stringify(window.__card._serverCfg));
const restore = () => page.evaluate((s) => {
const c = window.__card;
c._serverCfg = JSON.parse(s);
c._bdDrag = null;
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
}, snap);
const mode = (m) => page.evaluate((m) => {
const c = window.__card;
c._setMode(m); c.requestUpdate();
return c.updateComplete && true;
}, m);
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const q = (sel) => page.evaluate((s) => window.__card.renderRoot.querySelectorAll(s).length, sel);
const spaceCfg = () => page.evaluate(() => {
const sp = window.__card._serverCfg.spaces.find((s) => s.id === 'f1');
return { x: sp.plan_x ?? null, y: sp.plan_y ?? null, k: sp.plan_scale ?? null };
});
/** The image rect AS RENDERED (attributes of the <image> in the stage svg). */
const imageRect = () => page.evaluate(() => {
const im = window.__card.renderRoot.querySelector('.stage svg image');
if (!im) return null;
const a = (n) => Number(im.getAttribute(n));
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
});
const frameRect = () => page.evaluate(() => {
const b = window.__card.renderRoot.querySelector('.bdframe .bdbox');
if (!b) return null;
const a = (n) => Number(b.getAttribute(n));
return { x: a('x'), y: a('y'), w: a('width'), h: a('height') };
});
const badges = () => page.evaluate(() =>
[...window.__card.renderRoot.querySelectorAll('.measurelabel.bdmeasure')].map((e) => e.textContent.trim()));
/** Screen position of a render-unit point (the svg viewBox fills the stage). */
const screenPt = (x, y) => page.evaluate(([x, y]) => {
const stage = window.__card.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return [r.left + ((x - vx) / vw) * r.width, r.top + ((y - vy) / vh) * r.height];
}, [x, y]);
const near = (a, b, tol = 1e-4) => Math.abs(a - b) <= tol;
// f1: plan_aspect 1.25 → the centred default is 1000×800 at (0,100)
const BASE = { x: 0, y: 100, w: 1000, h: 800 };
const PITCH = 1000 / 240;
// "on a node", allowing for the 6 decimals the normalised config keeps
// (5e-7 normalised ≈ 5e-4 render units ≈ 1e-4 of a step)
const onGrid = (v, tol = 1e-3) => Math.abs(v / PITCH - Math.round(v / PITCH)) < tol;
/** Metres a render-unit length is worth on the demo plan (cell_cm = 5). */
const metres = (u) => (((u / PITCH) * 5) / 100).toFixed(2) + ' m';
// ---------- 1) the frame lives in the backdrop editor and nowhere else -----
await mode('view');
await settle();
check('no_frame_in_view', await q('.bdframe'), 0);
await mode('plan');
await settle();
check('no_frame_in_plan', await q('.bdframe'), 0);
const tool = (t) => page.evaluate((t) => {
const c = window.__card;
c._decorTool = t; c._decorDraft = null; c.requestUpdate();
return c.updateComplete && true;
}, t);
await mode('decor');
await settle();
// the frame is on screen the moment the backdrop editor opens…
check('frame_in_backdrop_editor', await q('.bdframe'), 1);
check('four_finger_handles', await q('.bdframe .bdhandle'), 4);
check('frame_hugs_the_picture', await frameRect(), BASE);
check('untouched_picture_is_where_it_always_was', await imageRect(), BASE);
// …and the picture has a tool of its own, offered because f1 HAS a picture
check('the_picture_has_a_tool', await q('.decorbar .btn.dtool'), 7);
check('no_grab_cursor_until_that_tool', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), false);
await tool('backdrop');
await settle();
check('grab_cursor', await page.evaluate(() =>
window.__card.renderRoot.querySelector('.stage').classList.contains('bdgrab')), true);
check('frame_still_there_under_its_own_tool', await q('.bdframe'), 1);
// a drawing tool owns the drag instead — no frame competing with it
await tool('rect');
await settle();
check('no_frame_under_a_drawing_tool', await q('.bdframe'), 0);
await tool('backdrop');
await settle();
// ---------- 2) drag the body: the picture moves, the plan does not --------
const roomsBefore = await page.evaluate(() =>
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms));
{
const [ax, ay] = await screenPt(500, 500);
// +102 / +52 render units: neither is a whole number of grid steps, so the
// snap has something to do (24 steps = 100, 12 steps = 50)
const [bx, by] = await screenPt(602, 552);
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
await settle();
const live = await badges();
check('live_badge_while_dragging', live.length, 1);
// the demo's cell_cm is 5 → the untouched picture is 240 cells wide, i.e.
// 12.00 m by 9.60 m. Moving does not change the size, and the badge says so.
check('live_badge_is_the_real_size', live[0], `${metres(BASE.w)} × ${metres(BASE.h)}`);
check('live_badge_is_metres', live[0], '12.00 m × 9.60 m');
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await settle();
}
check('badge_gone_after_release', (await badges()).length, 0);
{
const cfg = await spaceCfg();
const im = await imageRect();
// the gesture asked for +102/+52 render units; the pointer arrives in whole
// screen pixels, so the ASSERTION is the contract, not the arithmetic: the
// corner lands on a lattice node within one step of where the finger let go
check('plan_x_saved', near(cfg.x, im.x / 1000, 1e-6), true);
check('plan_y_saved', near(cfg.y, (im.y - BASE.y) / 1000, 1e-6), true);
check('scale_untouched_by_a_move', cfg.k, 1);
check('size_untouched_by_a_move', [im.w, im.h], [BASE.w, BASE.h]);
check('corner_lands_on_a_grid_node', onGrid(im.x) && onGrid(im.y), true);
// …at the node NEAREST the finger: 102 → 100 (24 steps), 152 → 150 (36).
// Exact, and it is meant to be — nothing may rescale the stage mid-gesture
// (the frame is frozen while _bdDrag is live; the toolbar does not reflow),
// or the picture would run away from the finger.
check('landed_on_the_node_nearest_the_finger', [im.x, im.y], [100, 150]);
check('frame_follows_the_picture', await frameRect(), im);
}
check('the_plan_geometry_never_moved', await page.evaluate(() =>
JSON.stringify(window.__card._serverCfg.spaces.find((s) => s.id === 'f1').rooms)), roomsBefore);
// ---------- 3) Shift steps off the grid ----------------------------------
await restore();
await settle();
{
const [ax, ay] = await screenPt(500, 500);
const [bx, by] = await screenPt(602, 552);
await page.keyboard.down('Shift');
await page.mouse.move(ax, ay);
await page.mouse.down();
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await page.keyboard.up('Shift');
await settle();
const im = await imageRect();
// Shift is not "a different snap", it is NO snap: the corner is wherever the
// finger left it, to the pixel — and a pixel is a fraction of a grid step,
// so at least one axis is bound to sit between the nodes.
check('shift_lands_where_the_finger_did',
Math.abs(im.x - 102) <= 1 && Math.abs(im.y - 152) <= 1, true);
check('shift_is_off_the_lattice', onGrid(im.x) && onGrid(im.y), false);
}
// ---------- 4) a corner handle scales UNIFORMLY about the opposite one ----
await restore();
await settle();
{
// the bottom-right corner of the default rect is (1000, 900); pull it in to
// (700, ...) — the top-left corner (0,100) must not budge and the ratio must
// stay 1.25, whatever the vertical component of the gesture says
const [ax, ay] = await screenPt(BASE.x + BASE.w, BASE.y + BASE.h);
const [bx, by] = await screenPt(700, 600);
await page.mouse.move(ax, ay);
await page.mouse.down();
await settle();
await page.mouse.move((ax + bx) / 2, (ay + by) / 2, { steps: 3 });
await settle();
check('live_badge_while_scaling', (await badges()).length, 1);
await page.mouse.move(bx, by, { steps: 3 });
await settle();
await page.mouse.up();
await settle();
const im = await imageRect();
check('opposite_corner_stayed_put', near(im.x, BASE.x, 1e-6) && near(im.y, BASE.y, 1e-6), true);
check('scaled_down', im.w < BASE.w, true);
check('uniform_no_stretch', near(im.w / im.h, BASE.w / BASE.h, 1e-9), true);
const cfg = await spaceCfg();
check('plan_scale_saved', near(cfg.k, im.w / BASE.w, 1e-5), true);
check('scaled_width_is_on_the_grid',
Math.abs((im.x + im.w) / PITCH - Math.round((im.x + im.w) / PITCH)) < 1e-6, true);
// …and the badge states the picture's REAL size through cell_cm
check('badge_matches_the_new_size', await page.evaluate(() => {
const c = window.__card;
c._bdDrag = { kind: 'move', pid: 1, sx: 0, sy: 0, base: { x: 0, y: 100, w: 1000, h: 800 },
p0: { dx: 0, dy: 0, k: 1 }, fx: 0, fy: 0, sgx: 0, sgy: 0, moved: false };
c.requestUpdate();
return c.updateComplete.then(() =>
c.renderRoot.querySelector('.measurelabel.bdmeasure')?.textContent.trim() ?? null);
}), `${metres(im.w)} × ${metres(im.h)}`);
await page.evaluate(() => { const c = window.__card; c._bdDrag = null; c.requestUpdate(); return c.updateComplete; });
}
// ---------- 5) the reset button ------------------------------------------
check('reset_button_offered_once_moved', await q('.decorbar .bdreset'), 1);
await page.evaluate(() => { const c = window.__card; c._bdReset?.(); c.requestUpdate(); return c.updateComplete; });
await settle();
check('reset_puts_the_picture_back', await imageRect(), BASE);
check('reset_clears_the_fields', await spaceCfg(), { x: null, y: null, k: null });
check('reset_button_gone_again', await q('.decorbar .bdreset'), 0);
// ---------- 5b) the SELECT tool still pans right over the picture ---------
// The body of the picture is most of the screen; claiming it outside its own
// tool would take away the one-finger pan (docs/BACKDROP.md §2), which is why
// moving is a tool. smoke_pan_any_zoom guards the same thing from the outside.
await tool('select');
await settle();
check('select_tool_still_pans_over_the_picture', await page.evaluate(async () => {
const c = window.__card;
const before = { ...c._viewOr(c._baseVb()) };
c._stagePointerDown({ pointerId: 91, clientX: 300, clientY: 300, target: c._stageEl, preventDefault() {} });
c._stagePointerMove({ pointerId: 91, clientX: 360, clientY: 350 });
const after = { ...c._viewOr(c._baseVb()) };
c._stagePointerUp({ pointerId: 91, clientX: 360, clientY: 350 });
await c.updateComplete;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
return Math.abs(after.x - before.x) > 1 && sp.plan_x == null;
}), true);
// ---------- 6) the paper is the ROOMS, and the picture sits on it ---------
await mode('view');
await settle();
const layers = await page.evaluate(() => {
const c = window.__card;
const m = c._spaceModel();
const svgEl = c.renderRoot.querySelector('.stage svg');
const kids = [...svgEl.children];
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
const papers = [...svgEl.querySelectorAll('.hp-paper')];
return {
paperShapes: papers.length,
rooms: m.rooms.length,
// not one paper shape the size of the picture any more
imageSizedPaper: papers.some((p) => p.tagName === 'rect'
&& Math.abs(Number(p.getAttribute('width')) - m.bg.w) < 1e-6
&& Math.abs(Number(p.getAttribute('height')) - m.bg.h) < 1e-6),
iPaper: idx('.hp-paper'), iImage: idx('image'), iRoom: idx('.room-outline, .room'),
};
});
check('paper_is_one_shape_per_room', layers.paperShapes, layers.rooms);
check('no_paper_the_size_of_the_picture', layers.imageSizedPaper, false);
check('picture_is_above_the_paper', layers.iImage > layers.iPaper && layers.iPaper >= 0, true);
check('picture_is_below_the_walls', layers.iImage < layers.iRoom, true);
// ---------- 7) «Вписать всё» keeps a picture that was dragged away --------
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.plan_x = 1.5; sp.plan_y = 1.5; sp.plan_scale = 0.5;
c._cfgEpoch++; c._frame = null; c.requestUpdate();
return c.updateComplete;
});
await settle();
const fit = await page.evaluate(() => {
const c = window.__card;
c._fitAll();
const b = c._baseVb();
const bg = c._spaceModel().bg;
return { b, bg };
});
check('the_frame_reaches_the_moved_picture',
fit.b[0] <= fit.bg.x && fit.b[1] <= fit.bg.y
&& fit.b[0] + fit.b[2] >= fit.bg.x + fit.bg.w
&& fit.b[1] + fit.b[3] >= fit.bg.y + fit.bg.h, true);
check('and_the_moved_picture_is_where_the_transform_says',
near(fit.bg.x, 1500) && near(fit.bg.y, 1600) && near(fit.bg.w, 500) && near(fit.bg.h, 400), true);
// ---------- 8) the static card reads the same three fields ---------------
const stat = await page.evaluate(async () => {
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
return { ok: true };
} };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage svg image') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
const root = card.renderRoot.querySelector('.hp-static-stage svg');
const im = root.querySelector('image');
const a = (n) => Number(im.getAttribute(n));
const kids = [...root.children];
const idx = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
return {
rect: { x: a('x'), y: a('y'), w: a('width'), h: a('height') },
papers: root.querySelectorAll('.hp-paper').length,
imageSizedPaper: [...root.querySelectorAll('.hp-paper')].some((p) => p.tagName === 'rect'
&& Math.abs(Number(p.getAttribute('width')) - a('width')) < 1e-6),
iPaper: idx('.hp-paper'), iImage: idx('image'),
};
});
check('static_card_applies_the_transform', stat.rect, { x: 1500, y: 1600, w: 500, h: 400 });
check('static_card_papers_the_rooms', stat.papers, 4);
check('static_card_has_no_picture_paper', stat.imageSizedPaper, false);
check('static_card_draws_the_picture_on_the_paper', stat.iImage > stat.iPaper, true);
await restore();
await finish(browser);
+19 -9
View File
@@ -144,9 +144,11 @@ checkAll(res);
// ---- 11) opaque plan paper (owner 2026-08-03) --------------------------
// The scene bg_color must never bleed through the plan itself: opaque
// .hp-paper shapes sit under everything the plan draws. An IMAGE plan papers
// the backdrop image rect (the canvas is the paper); a DRAWN plan papers the
// ROOM CONTOURS — one shape per room, never their bounding box (section 12).
// .hp-paper shapes sit under everything the plan draws. Since v1.58.0 the
// paper is the ROOM CONTOURS and ONLY them — one shape per room, never their
// bounding box (section 12), and never the backdrop image rect either
// (docs/BACKDROP.md §3). The demo's f1 IS an image plan, so this section now
// asserts the new rule on exactly the case that used to be the exception.
// The scene colour is visible ONLY around the paper; the 'daynight' night
// dims the paper via brightness only.
const paper = await page.evaluate(async () => {
@@ -158,20 +160,28 @@ const paper = await page.evaluate(async () => {
c._cfgRev = (c._cfgRev || 0) + 1;
await upd();
const m = c._spaceModel();
const p = sr().querySelector('.stage svg rect.hp-paper');
const shapes = () => [...sr().querySelectorAll('.stage svg .hp-paper')];
const p = shapes()[0];
out.paperExists = !!p;
out.imagePlanPapersTheRooms = shapes().length === m.rooms.length;
// …and NOT the image: no paper rect the size of the backdrop any more
out.noImageSizedPaper = ![...sr().querySelectorAll('.stage svg rect.hp-paper')].some((r) =>
Math.abs(Number(r.getAttribute('width')) - m.bg.w) < 1e-6
&& Math.abs(Number(r.getAttribute('height')) - m.bg.h) < 1e-6);
if (p) {
const a = (n) => Number(p.getAttribute(n));
out.paperHugsImage = Math.abs(a('x') - m.bg.x) < 1e-6 && Math.abs(a('y') - m.bg.y) < 1e-6
&& Math.abs(a('width') - m.bg.w) < 1e-6 && Math.abs(a('height') - m.bg.h) < 1e-6;
const cs = getComputedStyle(p);
out.paperOpaque = cs.fillOpacity === '1' && cs.opacity === '1'
&& cs.fill !== 'none' && !/rgba\(.*,\s*0\)/.test(cs.fill);
}
// …and the picture is drawn ON that paper: image after paper, before walls
const kids = [...sr().querySelector('.stage svg').children];
const idxOf = (sel) => kids.findIndex((n) => n.matches(sel) || n.querySelector?.(sel));
out.imageAbovePaper = idxOf('image') > idxOf('.hp-paper') && idxOf('image') >= 0;
out.imageBelowRooms = idxOf('image') < idxOf('.room, .room-outline');
// editors keep the paper too (their canvas ignores bg_color anyway)
c._setMode('decor');
await upd();
out.editorKeepsPaper = !!sr().querySelector('.stage svg rect.hp-paper');
out.editorKeepsPaper = !!sr().querySelector('.stage svg .hp-paper');
c._setMode('view');
await upd();
// daynight night: the paper dims via the brightness filter ONLY, never alpha
@@ -184,7 +194,7 @@ const paper = await page.evaluate(async () => {
} } };
await upd();
out.nightDimsByBrightness = (sr().querySelector('.zoomwrap').getAttribute('style') || '').includes('brightness(0.900');
const pn = sr().querySelector('.stage svg rect.hp-paper');
const pn = sr().querySelector('.stage svg .hp-paper');
out.nightPaperStaysOpaque = !!pn && getComputedStyle(pn).fillOpacity === '1' && getComputedStyle(pn).opacity === '1';
delete c._serverCfg.settings.bg_mode;
delete c._serverCfg.settings.north_deg;
+69 -1
View File
@@ -11,7 +11,9 @@ const res = await page.evaluate(async () => {
tabs[2].click(); await c.updateComplete;
out.decorMode = c._mode === 'decor';
out.decorBar = !!sr().querySelector('.editbar.decorbar');
out.toolBtns = sr().querySelectorAll('.decorbar .btn:not(.barclose)').length === 6;
// six drawing tools + «Картинка-подложка», which f1 offers because it HAS a
// picture (docs/BACKDROP.md §2); a hand-drawn space still shows six
out.toolBtns = sr().querySelectorAll('.decorbar .btn.dtool').length === 7;
// 2) нарисовать прямоугольник drag-ом (через прямые вызовы)
c._decorTool = 'rect'; c._decorStyle = { color: '#ff0000', width: 3, fill: true }; await c.updateComplete;
const g = c._gridPitch;
@@ -101,6 +103,72 @@ const res = await page.evaluate(async () => {
c._curSpaceCfg.decor = c._decorList.map((x) => x.id === m.id ? { ...x, x: m.orig.x + dx5 } : x);
out.moveKeepsSize = Math.abs(c._decorList.find((x) => x.id === rect.id).w - before.w) < 1e-9;
c._decorMove = null;
// 5b) инструмент рисования ВЛАДЕЕТ холстом: клик по существующей фигуре
// начинает новую фигуру в этой точке, а не выделяет старую (владелец
// 2026-08-04: «нельзя поставить начало линии на конец другой»).
const stageEl = () => sr().querySelector('.stage');
// экранные координаты точки холста (обратное _screenToVb)
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height };
};
const press = (el, x, y, pid) => {
const { clientX, clientY } = toScreen(x, y);
el.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true,
pointerId: pid, clientX, clientY, button: 0, isPrimary: true }));
};
// подопытная линия с известными концами
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; c._decorMove = null;
const decorBefore = c._decorList.slice();
c._curSpaceCfg.decor = [{ id: 'dcprobe', kind: 'line', x1: (g * 6) / 1000, y1: (g * 30) / 1000,
x2: (g * 18) / 1000, y2: (g * 30) / 1000, color: '#000000', width: 3 }];
c._cfgEpoch = (c._cfgEpoch || 0) + 1;
c.requestUpdate(); await c.updateComplete;
const probe = () => [...sr().querySelectorAll('.decorlayer line.dshape')]
.find((l) => Math.abs(+l.getAttribute('x1') - g * 6) < 0.01);
out.probeRendered = !!probe();
// — инструмент «линия»: жмём ровно на КОНЕЦ линии
c._decorTool = 'line'; await c.updateComplete;
// фигура вообще перестаёт быть мишенью для указателя
out.shapeInertUnderLineTool = getComputedStyle(probe()).pointerEvents === 'none';
press(probe(), g * 18, g * 30, 41);
await c.updateComplete;
out.lineToolStartsDraft = !!c._decorDraft && c._decorDraft.kind === 'line';
// ...и именно из этой точки (снап к концу старой линии)
out.draftStartsAtTheEnd = !!c._decorDraft
&& Math.abs(c._decorDraft.a[0] - g * 18) < 0.51 && Math.abs(c._decorDraft.a[1] - g * 30) < 0.51;
out.lineToolNoSelect = c._decorSel === null;
out.lineToolNoMove = !c._decorMove;
c._decorDraft = null; await c.updateComplete;
// — прямоугольник и овал ведут себя так же
for (const [tool, key] of [['rect', 'rectToolStartsDraft'], ['ellipse', 'ellipseToolStartsDraft']]) {
c._decorTool = tool; c._decorSel = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 42);
await c.updateComplete;
out[key] = !!c._decorDraft && c._decorDraft.kind === tool && c._decorSel === null;
c._decorDraft = null;
}
// — «текст» по фигуре открывает диалог новой надписи, а не выделяет
c._decorTool = 'text'; c._decorSel = null; c._decorTextDialog = null; await c.updateComplete;
press(probe(), g * 12, g * 30, 43);
await c.updateComplete;
out.textToolOpensDialog = !!c._decorTextDialog && !c._decorTextDialog.id && c._decorSel === null;
c._decorTextDialog = null;
// — а вот в «выборе» всё по-прежнему: клик выделяет и берёт на перетаскивание
c._decorTool = 'select'; c._decorSel = null; c._decorDraft = null; await c.updateComplete;
out.shapeLiveUnderSelect = getComputedStyle(probe()).pointerEvents !== 'none';
press(probe(), g * 12, g * 30, 44);
await c.updateComplete;
out.selectStillSelects = c._decorSel === 'dcprobe';
out.selectStillGrabs = !!c._decorMove && c._decorMove.id === 'dcprobe';
out.selectMakesNoDraft = !c._decorDraft;
c._decorMove = null; c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
out.decorRestored = c._decorList.length === decorBefore.length;
// 6) erase удаляет
const n0 = c._decorList.length;
c._decorTool = 'erase';
+4 -2
View File
@@ -34,9 +34,11 @@ const res = await page.evaluate(async () => {
// CARD_VERSION из собранного бандла (тот же текст уходит в console-баннер).
// terser либо инлайнит строку (v1.56.0), либо оставляет переменную (v${xx}) —
// во втором случае доразрешаем её по присваиванию xx="1.56.0".
// Версия — SemVer, у пре-релиза есть суффикс (1.58.0-beta.1), он тоже часть строки.
const bundle = readFileSync(new URL('./srv/assets/houseplan-card.js', import.meta.url), 'utf8');
const m = bundle.match(/HOUSEPLAN-CARD %c v(?:(\d+\.\d+\.\d+)|\$\{(\w+)\})/);
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(\\d+\\.\\d+\\.\\d+)"`))?.[1]);
const SEMVER = '\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?';
const m = bundle.match(new RegExp(`HOUSEPLAN-CARD %c v(?:(${SEMVER})|\\$\\{(\\w+)\\})`));
const BUNDLE_VERSION = m?.[1] ?? (m?.[2] && bundle.match(new RegExp(`[^\\w$]${m[2]}="(${SEMVER})"`))?.[1]);
check('bundleVersionFound', typeof BUNDLE_VERSION === 'string' && BUNDLE_VERSION.length > 0);
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
checkAll(res, {
File diff suppressed because one or more lines are too long
+98 -32
View File
File diff suppressed because one or more lines are too long
+6 -2
View File
@@ -109,7 +109,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
`.storage/houseplan.config` (Store):
```json
{ "spaces": [{ "id","title","plan_url","plan_aspect","view_box":[4],
{ "spaces": [{ "id","title","plan_url","plan_aspect",
"plan_x","plan_y","plan_scale", // optional, docs/BACKDROP.md
"view_box":[4],
"rooms":[{"id","name","area","poly|x/y/w/h","open_to","settings"}],
"openings":[…], "decor":[…], "settings":{…} }],
"markers": [{ "id","binding":"device:<id>|entity:<eid>|virtual","hidden",
@@ -122,7 +124,9 @@ references and duplicates by name|area. Manual files: transactional HTTP upload
All coordinates are **normalized (0..1 of the canvas)**; the canvas is always
**square** (v1.48.0), render space `NORM_W × NORM_W` (1000×1000). A space has no
proportions of its own — `plan_aspect` is the IMAGE's ratio, used to letterbox
it centred on the square. The schema bounds geometry to ±4 with strictly
it centred on the square; the optional `plan_x`/`plan_y`/`plan_scale` then move
and uniformly scale that rectangle (`planRect`, docs/BACKDROP.md), and their
absence is the centred default exactly. The schema bounds geometry to ±4 with strictly
positive sizes (HP-1501/1502). `device_overrides`/`virtual_devices` are long
gone — markers carry everything, `marker.hidden` is the explicit
"hide from plan" flag seeded once by the old filter (docs/FILTERING.md).
+251
View File
@@ -0,0 +1,251 @@
# The backdrop picture — the spec (source of truth)
*Owner-approved 2026-08-04. Companion to docs/CANVAS.md (the plane the
picture lives on) and docs/RESIZE.md (the other transform frame).*
Until v1.58.0 an uploaded plan image was nailed to the canvas: centred, at
its own proportions, filling the square. If it did not line up with the
rooms you had drawn — or with the rooms you were about to draw — the only
recourse was to edit the file. And because it filled the canvas, it also
decided where the opaque **paper** was, which quietly made a picture plan
and a drawn plan two different kinds of thing.
This closes both. The picture is now **one of the objects on the plane**:
it can be moved and scaled like anything else, it counts as content for
the frame, and it no longer makes paper of its own.
---
## §1 Model — three optional numbers
A space gains three optional fields, next to `plan_url` / `plan_aspect`:
| Field | Meaning | Units | Range |
| --- | --- | --- | --- |
| `plan_x` | offset of the image's **top-left corner** from its default place | normalised (as rooms, decor, openings) | ±`CANVAS_LIMIT` (±5000) |
| `plan_y` | the same, vertically | normalised | ±`CANVAS_LIMIT` |
| `plan_scale` | **one** uniform multiplier for both sides, anchored at the (already offset) top-left corner | multiplier | `0.01 … 100` |
There is **no rotation** and no independent width/height: the picture keeps
its own aspect ratio for ever, which is what makes "scale" a single number.
**The absence of all three is exactly the pre-v1.58.0 behaviour.** No
migration runs, nothing is written into anybody's store, and a plan that has
never been through the backdrop editor renders bit-identically. Junk (a
string, `NaN`, a zero or negative scale) is treated as absence, not as an
error — the same reading rule the rest of the geometry uses.
One function owns the arithmetic, `planRect()` in `src/space-geometry.ts`:
```
base = fitInSquare(plan_aspect, 1000) // the centred default
rect.x = base.x + plan_x * 1000
rect.y = base.y + plan_y * 1000
rect.w = base.w * plan_scale
rect.h = base.h * plan_scale
```
`spaceModels()` calls it, so **every** consumer — the full card, the static
`houseplan-space-card`, the kiosk, the content frame — reads the same
rectangle. The card's model cache key (`_cfgFingerprint`) carries the three
fields, so a drag invalidates the memo.
`validation.py` mirrors the ranges (`PLAN_SCALE_MIN` / `PLAN_SCALE_MAX`
alongside `CANVAS_LIMIT`). Detaching the image (space dialog → «Нарисую
комнаты вручную») deletes all three: there is nothing left for them to
describe, and a stale transform must not silently apply to the next upload.
---
## §2 The transform frame
**Where.** The **backdrop editor** (`mode: 'decor'`, «Редактор подложки»).
The frame is on screen as soon as that editor opens (tools **select** and
**«Картинка-подложка»**); the drawing tools own the drag, so the frame
steps aside for them. Nowhere else: View, the Plan editor, the Device
editor and the kiosk never touch the picture. A space with no image never
grows a frame, and its toolbar is unchanged.
**What.** A dashed outline on the picture's rectangle plus four
finger-sized corner handles (radius = 2 % of the visible view, so they stay
grabbable at any zoom — the same rule the vacuum fit frame uses). The
outline never takes a pointer; the handles do, with `setPointerCapture` and
`nwse-resize` / `nesw-resize` cursors. Under the picture's own tool the
stage cursor is `grab`, and `grabbing` while a gesture is live.
**Move** — press anywhere inside the picture and drag, **under the
«Картинка-подложка» tool**. Why a tool and not just the select tool: the
picture's body is most of the screen, and claiming it would take away the
one-finger pan of the whole plane, which the owner asked for on 2026-08-04
(«таскать план при любом масштабе») and `smoke_pan_any_zoom` guards. The
corner handles are precise targets and need no such protection — they are
live whenever the frame is. The gesture is also only started INSIDE the
rectangle, so pressing beside the picture pans even under its own tool.
**Nothing may rescale the stage mid-gesture.** Two things wanted to, and
both moved the picture away from the finger before they were stopped:
* the picture is a content item, so dragging it GROWS the editor's content
frame, which rescales the view — `_frameOf()` therefore returns the
memoized frame untouched while `_bdDrag` is live, and catches up on
release;
* «Вернуть картинку» appears the moment the picture has moved, and the
toolbar sits above the stage — so it is withheld until the gesture ends.
**Scale** — pull a corner. Uniform, about the **opposite** corner, which
stays exactly where it was (the mechanic borrowed from the vacuum
calibration panel). `k = max(|dx| / base.w, |dy| / base.h)`, so the gesture
follows whichever axis you pulled harder, and both sides then follow `k`.
**Nothing else moves.** Rooms, openings, devices, decor and room labels are
untouched by both gestures — this is a picture-only transform, and the
smoke asserts the room geometry is byte-identical afterwards.
**Undo** is the «Вернуть картинку» button in the backdrop toolbar: it
appears only once the picture HAS been moved, and it deletes the three
fields (back to centred, own size).
---
## §3 THE PAPER IS THE ROOMS — and only the rooms
*This is a behaviour CHANGE, deliberately.*
The opaque "paper" exists so the scene background — `bg_color`, or the
`daynight` sky (docs/SUN.md) — never bleeds through the plan itself. Until
v1.58.0 it had two forms: the ROOM CONTOURS for a drawn plan, and **the
image rectangle** for a picture plan.
The second form is gone. The paper is now `paperRoomShapes(space.rooms)`
in every case:
* one opaque shape per room, in exactly the room's own geometry — an
L-shaped house or a pair of detached buildings never grows a bounding
rectangle;
* an empty space has **no paper at all**, image or no image;
* its colour is unchanged (`styles.ts .hp-paper`): white on a hand-drawn
plan (`.stage.noplan`), the theme card background where an image is
attached. `daynight` dims it via the `.zoomwrap` brightness filter only —
its alpha stays 1.
**Layer order**, top to bottom, in both renderers:
```
devices / labels
sun wedges and rays
walls, openings, room fills, decor
THE PICTURE ← above the paper, below the geometry
the paper (room contours)
the scene background (bg_color / the daynight sky)
```
**The consequence, and the owner knows it:** a picture with transparency
over a space with no rooms drawn shows the scene background through itself.
That is the honest reading of "the paper belongs to the rooms" — draw the
rooms and the paper appears under them.
---
## §4 The picture is content
`contentItems()` has always counted the image rectangle as one content item
alongside the rooms; it now counts the **transformed** one. So the frame,
«Вписать всё», the zoom floor and the pan slack (docs/CANVAS.md §4–§5) all
follow the picture wherever it is dragged, and a picture scaled down to a
quarter tightens the frame around it instead of framing empty plane.
`iconUnit()` deliberately does **not** count it: an icon is a percentage of
the drawn plan (docs/CANVAS.md §6), and scaling the backdrop must not
resize every marker on it.
---
## §5 Snap
Both gestures obey the canvas snap contract (docs/CANVAS.md §9):
| Gesture | What is snapped |
| --- | --- |
| move | the resulting **top-left corner** — the anchor, not the delta, so one drag is enough to put a legacy off-grid picture onto the lattice |
| scale | the dragged corner **along the picture's longer side**; the scale is then read back off it |
The fixed corner of a scale keeps its (on-grid) place and the long side
lands on a node. The short side follows from the aspect ratio and generally
does **not** — that is what "uniform, no stretch" costs, and it is the
right trade: a picture whose proportions drift is a broken picture.
**Shift** suspends the snap for the duration of the gesture, exactly as
everywhere else (§9.4). The `±CANVAS_LIMIT` clamp rides along regardless —
`_snap()` and `clampCanvasN` are the only bound either gesture has.
---
## §6 Live measurements
While the picture is dragged or scaled, one badge rides its centre with the
picture's **real size**, width × height, through the space's `cell_cm` and
the HA unit system (metres or feet). It is the same `_fmtLen`
(`segmentCm` → `formatLength`) and the same `.measurelabel` element the
wall ruler, the opening shoulders and the room resize use — the card states
a length exactly one way.
---
## §6b One tool at a time — a drawing tool owns the canvas
The backdrop editor has seven tools, and they are **exclusive**: only
**select** talks to an existing shape, only **erase** deletes one, only
**«Картинка-подложка»** grabs the picture's body, and the four drawing
tools (**line**, **rect**, **ellipse**, **text**) do nothing but create.
The rule exists because of a concrete failure (owner, 2026-08-04): *«нельзя
поставить начало линии на конец другой — при клике на конец выделяется
первая линия, а не начинает рисоваться новая»*. The shape's own
`pointerdown` handler ran first, stopped the event and did nothing under a
drawing tool, so the press never reached the stage and no draft was born —
exactly on the one spot where a new line most wants to start, since §5's
snap anchors include every line end.
So, under a drawing tool (and under the picture tool):
* `.dshape` is `pointer-events: none` — an existing figure is not a target
at all, and the cursor is the stage's;
* `_decorShapeDown` returns before `stopPropagation` for any tool that is
not select/erase, so a synthetic press cannot swallow the gesture either;
* `_decorPointerDown` only looks for a shape under the pointer for
select/erase, and otherwise starts the draft at the pressed point;
* the transform frame's corner handles stay off (§2) — they were the first
thing taught not to swallow the first point of a line.
Selecting, moving and the text dialog's double-click therefore live in the
select tool only. `demo/smoke_decor.mjs` asserts both halves: a press on an
existing line starts a new line **from that end** under the line tool, and
still selects and grabs it under select.
---
## §7 What is deliberately NOT done
* **No rotation.** A skewed scan is a scan to re-take, not a plan to skew
the whole editor's maths for.
* **No non-uniform scale.** See above: the picture keeps its ratio.
* **No numeric fields** in the space dialog. The frame is the interface;
the numbers exist for the config, not for typing.
* **No per-room clipping of the picture.** It is one rectangle.
* **No transform for the sun's paper/wedges.** Those follow the rooms, and
now so does the paper — one rule, no second geometry.
---
## §8 Where it lives
| Concern | File |
| --- | --- |
| the rectangle (`planRect`, `PLAN_SCALE_MIN/MAX`), content items | `src/space-geometry.ts` |
| the frame, the gestures, the live badge, the reset | `src/houseplan-card.ts` (`_bd*`, `_renderBackdropFrame`) |
| the frame's look, the paper, the cursors | `src/styles.ts` (`.bdframe`, `.hp-paper`) |
| the static card's copy of the paper + picture order | `src/space-render.ts` |
| the stored ranges | `custom_components/houseplan/validation.py` |
| unit coverage | `test/backdrop.test.mjs` |
| DOM coverage | `demo/smoke_backdrop.mjs`, `demo/smoke_bg_color.mjs` §11 |
| screenshots | `demo/shot_backdrop.mjs` |
+3 -1
View File
@@ -354,6 +354,7 @@ wall runs diagonally is broken geometry, not a tidy plan:
| Element | Where |
| --- | --- |
| the backdrop picture: move (its top-left corner) and corner scale (the dragged corner, along the longer side) | `_bdMove` → `_snap` / `snapToGrid` (docs/BACKDROP.md §5) |
| room vertices (draw tool) | `_markupClick` → `_snap` |
| split tool's interior vertices | `_splitClick` → `_snap` |
| resize edge drag / corner scale | `_rszMove` → `_snap` |
@@ -460,7 +461,8 @@ it is a projection, and a projection is not invertible.
| static card `aspect-ratio` + `viewBox` from `space.vb` | the static card frames the square | `spaceFrame()` — same content frame as the full card |
| `validation.py` `+/-4`, `_EXTENT <= 4`, decor `-1..2`, opening `length <= 1` | the square plus slack | §3 |
| `safeViewBox` fallback `[0,0,1,1]` | a broken `view_box` means the square | kept — it is only the last-resort hint (§4) |
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item |
| `fitInSquare` (image placement) | image is centred in the square | **kept** — it defines the image's own rectangle in canvas units, which is exactly what §4 wants as a content item. Since v1.58.0 it is only the DEFAULT placement: `planRect()` adds the owner's `plan_x`/`plan_y`/`plan_scale` on top, and the moved rectangle is what §4 counts (docs/BACKDROP.md) |
| image plan papers the image rect | the picture IS the sheet | **removed** in v1.58.0 — the opaque paper is the room contours in every case, and the picture is drawn on top of it (docs/BACKDROP.md §3) |
| `_spaceH` / `_decorH` = `NORM_W` | the canvas is square | **kept** — this is the coordinate system's aspect, not a frame |
| `_gridPitch = NORM_W / GRID_N` | grid pitch is tied to the canvas unit | **kept** — the pitch is the real-world cell (`cell_cm`), it must not change with the plan's size |
| sun wedges / glow radii / resize maths | all in render units, relative to their own geometry | **unaffected** — verified: no `NORM_W`-relative constants |
+43
View File
@@ -1,5 +1,48 @@
# Changelog
## v1.58.0-beta.1 — 2026-08-04
First pre-release of the 1.58 line: the backdrop picture becomes a
movable, scalable object, the opaque plan sheet is redefined as the
room contours, and the decor drawing tools stop stealing clicks.
### The backdrop picture can be moved and scaled (docs/BACKDROP.md)
- **The plan image is no longer nailed down.** In the Background
editor the picture gets a transform frame: drag it by its body to
move it, pull a corner to resize it evenly (proportions kept, the
opposite corner stays put). A live badge states the picture's real
size in metres (or feet) while you drag, through the space's
`cell_cm`. Position and size land on the grid; Shift steps off it.
Nothing else moves — rooms, doors, devices and decor stay put — and
there is no rotation.
- Three new optional space fields: `plan_x`, `plan_y`, `plan_scale`.
Their absence is exactly the old behaviour, so **existing plans
render bit-identically and nothing is migrated**. «Вернуть картинку»
in the toolbar clears them.
- **The opaque plan sheet is now always the room contours.** It used
to be the image rectangle whenever a picture was attached; now the
picture is drawn ON the sheet — above it, below the walls. The
scene colour (`bg_color`, the `daynight` sky) therefore reaches the
exterior walls on an image plan too. Consequence, deliberate: a
transparent picture over a space with no rooms drawn shows the
scene background through itself.
- «Вписать всё» counts the moved and scaled picture, so it can no
longer be left off screen.
### Drawing tools no longer grab the shape under the cursor
- **A drawing tool owns the canvas.** With Line, Rectangle, Oval or
Text picked in the decor editor, clicking on an existing figure now
starts a NEW figure instead of selecting the old one — so a line can
begin exactly on the end of another line, and a rectangle can be
drawn on top of a filled one. Double-clicking a text under a drawing
tool no longer opens it for editing either. The same inertness
applies in the «Картинка-подложка» tool, where a decor shape lying
over the plan must not block the picture's own drag.
- «Выбрать» and «Стереть» are unchanged: shapes stay clickable there,
which is where selecting and deleting belong.
## v1.57.0 — 2026-08-04
Minor release: the canvas becomes infinite — no more "plan size", no
+43
View File
@@ -6,6 +6,49 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md).
## v1.58.0-beta.1 — 2026-08-04
Первый пре-релиз ветки 1.58: картинка-подложка становится объектом,
который можно двигать и масштабировать, непрозрачный лист плана
переопределён как контуры комнат, а рисующие инструменты декора
перестают перехватывать клик по чужой фигуре.
### Картинку-подложку можно двигать и масштабировать (docs/BACKDROP.md)
- **Картинка плана больше не прибита гвоздями.** В редакторе подложки
у неё появляется рамка трансформации: тянешь за тело — двигается,
тянешь за угол — меняет размер равномерно (пропорции сохраняются,
противоположный угол стоит на месте). Пока тянешь, плашка пишет
реальный размер картинки в метрах (или футах) — через `cell_cm`
пространства. Положение и размер ложатся на сетку, Shift снимает
привязку. Больше ничего не двигается: комнаты, двери, устройства и
декор остаются на месте; поворота нет.
- Три новых необязательных поля пространства: `plan_x`, `plan_y`,
`plan_scale`. Их отсутствие — ровно прежнее поведение, поэтому
**старые планы рисуются один в один и ничего не мигрируется**.
Кнопка «Вернуть картинку» в панели их удаляет.
- **Непрозрачный лист-бумага теперь всегда строится по контурам
комнат.** Раньше при картинке бумагой был её прямоугольник; теперь
картинка рисуется НА бумаге — над ней и под стенами. Цвет сцены
(`bg_color`, небо `daynight`) поэтому доходит до наружных стен и на
плане с картинкой. Осознанное следствие: у прозрачной картинки без
нарисованных комнат сквозь неё виден фон сцены.
- «Вписать всё» учитывает сдвинутую и отмасштабированную картинку —
потерять её за экраном больше нельзя.
### Рисующие инструменты больше не хватают фигуру под курсором
- **Рисующий инструмент владеет холстом.** Когда в редакторе декора
выбрана Линия, Прямоугольник, Овал или Текст, клик по существующей
фигуре начинает НОВУЮ фигуру, а не выделяет старую — поэтому линию
можно начать ровно на конце другой линии, а прямоугольник нарисовать
поверх залитого. Двойной клик по тексту под рисующим инструментом
тоже больше не открывает его на правку. Та же инертность работает в
инструменте «Картинка-подложка»: лежащая поверх плана фигура декора
не должна мешать тащить саму картинку.
- «Выбрать» и «Стереть» не изменились: там фигуры по-прежнему
кликабельны — выделять и удалять положено именно ими.
## v1.57.0 — 2026-08-04
Минорный релиз: холст становится бесконечным — больше нет «размера
+4 -3
View File
@@ -15,12 +15,13 @@
| Item | State |
|---|---|
| Version | **v1.57.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance. Minor release "the infinite canvas": no more plan/grid size (docs/CANVAS.md — unbounded canvas with a ±5000 garbage guard, view_box demoted to a first-frame hint, content-derived start view with outlier rejection, pan at any zoom in every editor, 3× zoom-out, "fit everything", "home is that way" arrow, adaptive grid), cover "Open/close" tap action with icon morphing + travel pulse (unprotected classes only, cover found among all marker entities, never a coloured plate), sun polish (0.30 opacity, −30% length, hard sides, fade along the ray only, 3° threshold with a 2 s fade, day/night sky catch-up), opening rulers while PLACING, warm re-mount with no veil and WS outages that never blank the plan, default room border/name #55606c, About block in general settings, 5° icon angle step |
| 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) |
| Version | **v1.58.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — the FIRST PRE-RELEASE of the new owner process: tag on `dev`, GitHub Release with `prerelease=true`, `main` untouched, no direct deploy (the home instance picks it up through HACS on the beta channel). Contents: the backdrop picture becomes a movable/scalable object (docs/BACKDROP.md — transform frame in the «Картинка-подложка» tool, even corner scaling, live size in metres, grid snap with a Shift bypass, new optional fields `plan_x`/`plan_y`/`plan_scale`, counted by "fit everything"); **NEW PAPER RULE — the opaque plan sheet is always the room contours**, the picture is drawn on top of the sheet and under the geometry (the "paper = image rectangle" branch is gone); decor drawing tools (line/rect/oval/text) and the backdrop tool no longer select the shape under the cursor — existing figures are inert there, select/erase unchanged. Last stable: v1.57.0 "the infinite canvas" |
| 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. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
| 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 |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), deployed **v1.57.0** via direct copy (HACS custom repo also installed) |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0-beta.1 on it updates itself through HACS by tag (no scp) |
| 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 |
| Vacuums | Live robot vacuums shipped (docs/VACUUM.md): puck, server-side trails with display modes, fit-panel calibration. Verified on a live Dreame X50 Master |
+65
View File
@@ -1139,3 +1139,68 @@ require hands on real hardware — they remain for the human pass.
snap to nodes in ONE write, openings stay on their walls, and pressing the
button a second time reports nothing to do. Cancel does nothing at all
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
## Backdrop picture: move & scale (docs/BACKDROP.md, dev)
- [ ] **The frame is there, and only there** (owner 2026-08-04): open a space
that HAS an uploaded plan image → **Редактор подложки**. A dashed frame
hugs the picture straight away, with four round corner handles, and the
toolbar has gained a «Картинка-подложка» tool. Switch to Line /
Rectangle / Oval / Text / Erase — the frame disappears (those tools own
the drag). Leave for View, the Plan editor, the Device editor and the
kiosk — no frame anywhere. A space with NO image never shows one, and
its toolbar has no extra button [auto: smoke_backdrop, smoke_decor]
- [ ] **Dragging the picture moves the picture, and nothing else** (owner
2026-08-04): pick the «Картинка-подложка» tool (the cursor over the
picture becomes a hand), grab the picture by its body and pull it aside.
It follows the finger the whole way — it must NOT drift away from the
cursor or jump when the toolbar changes — while the rooms, walls, doors,
windows, devices, room names and decor stay exactly where they were.
Release, reload the page — the picture is still where you left it
[auto: smoke_backdrop]
- [ ] **One-finger pan survives** (regression, DEV-B58 «таскать план при любом
масштабе»): back on the «Выбрать» tool, drag across the middle of the
picture — the PLANE pans, the picture does not move. Same at 50 % and
33 % zoom [auto: smoke_backdrop, smoke_pan_any_zoom]
- [ ] **The corners scale it evenly**: pull a corner handle. The picture grows
and shrinks in BOTH directions at once, keeps its proportions (nothing
is stretched), and the OPPOSITE corner does not move a pixel. Try all
four corners; the cursor over a handle is a diagonal resize arrow. On a
tablet the handles are big enough to hit with a finger [auto: smoke_backdrop]
- [ ] **Live size in metres**: while dragging or scaling, a badge in the middle
of the picture states its real size, «Ш × В», in the same units the wall
ruler uses (metres, or feet on an imperial HA). Change the space's
`cell_cm` in its settings and the numbers change with it. The badge
disappears on release [auto: smoke_backdrop]
- [ ] **Snap, and Shift**: after a plain drag the picture's corner sits on a
grid node (zoom in on the corner — it is on a crossing, not between
two). After a corner scale one side of the picture ends on a node too.
Hold **Shift** during the gesture and it lands exactly where the cursor
is instead [auto: smoke_backdrop]
- [ ] **«Вернуть картинку»**: the button appears in the backdrop toolbar only
after the picture has been moved or scaled. Press it — the picture goes
back to centred, at its own size, and the button disappears again
[auto: smoke_backdrop]
- [ ] **NEW PAPER RULE — the sheet is the rooms** (owner 2026-08-04, changes
the old behaviour): set a loud `bg_color` (or `daynight`) on a space
that has BOTH a picture and drawn rooms, then shrink the picture with a
corner handle. The opaque white/card-coloured sheet follows the ROOM
CONTOURS — it is no longer a rectangle the size of the picture. The
scene colour is visible around the rooms, including in the pocket of an
L-shaped house and between detached buildings. The picture is drawn ON
that sheet: above it, below the walls, doors, decor and devices. A space
with a picture and NO rooms has no sheet at all, so a transparent PNG
shows the scene through itself — deliberate
[auto: smoke_backdrop + smoke_bg_color, still: demo/shot_backdrop.mjs]
- [ ] **«Вписать всё» does not lose the picture**: drag the picture well away
from the rooms (or scale it right down), leave the editor, press «Вписать
всё». The view frames the rooms AND the picture; the "home is that way"
arrow points at them together [auto: smoke_backdrop]
- [ ] **The static card and the kiosk agree**: put a `houseplan-space-card` for
the same space on a dashboard and open the kiosk view. Both draw the
picture at the same offset and size, with the same room-contour paper
[auto: smoke_backdrop, smoke_render_parity]
- [ ] **Old plans are untouched**: a space whose picture has never been moved
renders exactly as before the update — same place, same size. Nothing is
written to its config until the first drag [auto: unit test/backdrop.test.mjs
+ tests_backend/test_validation.py]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.57.0",
"version": "1.58.0-beta.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.57.0",
"version": "1.58.0-beta.1",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.57.0",
"version": "1.58.0-beta.1",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+332 -49
View File
@@ -52,15 +52,16 @@ import './editor';
import './space-card';
import { cardStyles } from './styles';
import {
fitInSquare, contentBounds, spaceModels, contentFrame, contentItems, spaceFrame,
fitInSquare, planRect, contentBounds, spaceModels, contentFrame, contentItems, spaceFrame,
spaceCenter, iconUnit, iconCqw, gridLevels, itemOf, snapPt,
MIN_ZOOM, PAN_SLACK, CANVAS_LIMIT, GRID_N, GRID_PITCH, GRID_STEP_N,
PLAN_SCALE_MIN, PLAN_SCALE_MAX,
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { alignAllToGrid, type AlignReport } from './align-grid';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.57.0';
const CARD_VERSION = '1.58.0-beta.1';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -214,12 +215,33 @@ class HouseplanCard extends LitElement {
* starts in view. */
private _mode: 'view' | 'plan' | 'devices' | 'decor' = 'view';
// ---- decor (background) editor ----
private _decorTool: 'select' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase' = 'select';
private _decorTool: 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase' = 'select';
private _decorStyle: { color: string; width: number; fill: boolean } = { color: '#607d8b', width: 3, fill: false };
private _decorDraft: { kind: 'line' | 'rect' | 'ellipse'; a: number[]; b: number[]; pid: number } | null = null;
private _decorMove: { id: string; start: number[]; orig: any; pid: number; moved: boolean } | null = null;
private _decorSel: string | null = null;
private _decorTextDialog: { id?: string; x: number; y: number; text: string; size: 's' | 'm' | 'l'; color: string } | null = null;
/**
* The live backdrop gesture (docs/BACKDROP.md §2): moving the picture by its
* body, or scaling it UNIFORMLY by a corner handle about the opposite corner.
* `base` is the untransformed, centred rectangle the transform is measured
* from, so a gesture never accumulates rounding of its own.
*/
private _bdDrag: {
kind: 'move' | 'scale';
pid: number;
/** pointer down, render units */
sx: number; sy: number;
/** the centred default rect (render units) — the transform's origin */
base: Rect;
/** transform at pointer down */
p0: { dx: number; dy: number; k: number };
/** the corner that STAYS PUT while scaling (render units) */
fx: number; fy: number;
/** which way the dragged corner points from the fixed one (±1) */
sgx: number; sgy: number;
moved: boolean;
} | null = null;
/** Edit tabs are offered to admins only (hass.user missing → assume admin). */
private get _canEdit(): boolean {
@@ -588,6 +610,7 @@ class HouseplanCard extends LitElement {
_decorDraft: { state: true },
_decorSel: { state: true },
_decorTextDialog: { state: true },
_bdDrag: { state: true },
_kioskDialog: { state: true },
_vacFit: { state: true },
_kioskDots: { state: true },
@@ -900,6 +923,10 @@ class HouseplanCard extends LitElement {
let s = sp.length + ':';
for (const x of sp as any[]) {
s += (x.id || '') + ',' + (x.plan_aspect || '') + ',' + (x.plan_url || '').length + ','
// the backdrop transform is geometry: without it in the key a drag of
// the picture would leave the memoized model (and the content frame
// built from it) showing the old rectangle (docs/BACKDROP.md §5)
+ (x.plan_x ?? '') + ',' + (x.plan_y ?? '') + ',' + (x.plan_scale ?? '') + ','
+ (x.rooms?.length || 0) + ',' + (x.openings?.length || 0) + ',' + (x.decor?.length || 0) + ';';
for (const r of x.rooms || []) {
// O(1) geometry roll-up per room: the count alone said nothing about
@@ -1945,6 +1972,12 @@ class HouseplanCard extends LitElement {
// far away in the Plan editor kept View framing the empty ground it left
// behind, until some unrelated model change happened to invalidate memo.
const grow = this._mode !== 'view';
// A LIVE BACKDROP GESTURE FREEZES THE FRAME (docs/BACKDROP.md §2). The
// picture is a content item, so dragging it grows the frame — which
// rescales the view, which changes how many plan units a screen pixel is
// worth, mid-gesture: the picture then runs away from the finger and no
// drag lands where it was aimed. The frame catches up on release.
if (f && f.id === m.id && this._bdDrag) return f;
if (f && f.id === m.id && f.model === m && f.layout === this._layout
&& f.devs === this._devices && f.far === this._showFar && f.grow === grow) return f;
const cf = contentFrame(this._contentItems(m));
@@ -2022,24 +2055,6 @@ class HouseplanCard extends LitElement {
</button>`;
}
/**
* Opaque plan "paper" (owner 2026-08-03): the scene background — bg_color or
* the 'daynight' sky — must never bleed through the plan itself, so opaque
* shapes sit under everything the plan draws. With a backdrop image the
* paper is the image rect (the canvas IS the paper) — this helper returns
* it. For a hand-drawn plan the paper follows the ROOM CONTOURS instead
* (paperRoomShapes in logic.ts): one shape per room in exactly the room's
* own geometry, so an L-shaped house or detached buildings never grow a
* white bounding rectangle — the scene colour is visible right up to the
* exterior walls, and an empty drawn space has no paper at all. Its colour
* is the pre-bg_color canvas (styles.ts .hp-paper): white for drawn plans,
* the theme card background under an image. At night 'daynight' dims it via
* the zoomwrap brightness filter ONLY — its alpha stays 1 (docs/SUN.md).
*/
private _paperRect(m: SpaceModel): { x: number; y: number; w: number; h: number } | null {
return m.bg ? { x: m.bg.x, y: m.bg.y, w: m.bg.w, h: m.bg.h } : null;
}
/** Aspect ratio of the scene (width/height, px). */
private _stageAspect(): number {
const s = this._stageEl;
@@ -2311,6 +2326,10 @@ class HouseplanCard extends LitElement {
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._bdDrag?.pid === ev.pointerId) {
this._bdMove(ev);
return;
}
if (this._decorDraft?.pid === ev.pointerId) {
this._decorDraft = { ...this._decorDraft, b: this._snap(this._svgPoint(ev), ev) };
return;
@@ -2409,6 +2428,10 @@ class HouseplanCard extends LitElement {
}
}
}
if (this._bdDrag?.pid === ev.pointerId) {
this._bdUp();
return;
}
if (this._decorDraft?.pid === ev.pointerId) {
this._decorCommitDraft();
return;
@@ -2674,6 +2697,7 @@ class HouseplanCard extends LitElement {
this._decorDraft = null;
this._decorSel = null;
this._decorTool = 'select';
this._bdDrag = null;
this._saveNav();
}
@@ -3202,7 +3226,14 @@ class HouseplanCard extends LitElement {
/** Begin a decor gesture. Returns true when the event is consumed (no pan). */
private _decorPointerDown(ev: PointerEvent): boolean {
const t = this._decorTool;
const onShape = (ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null;
// A DRAWING tool owns the whole canvas. Pressing on top of an existing
// shape must start a NEW figure at that very point — otherwise a line can
// never begin at the end of another line, because the old line grabs the
// press first (owner, 2026-08-04). Only select/erase talk to shapes, and
// only for them does the shape's own handler get to deal with the event.
const onShape = (t === 'select' || t === 'erase')
? ((ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null)
: null;
if (onShape) return true; // the shape's own handler deals with it
if (t === 'line' || t === 'rect' || t === 'ellipse') {
ev.preventDefault();
@@ -3220,6 +3251,17 @@ class HouseplanCard extends LitElement {
return true;
}
this._decorSel = null; // select/erase on empty space clears the selection
// …and under its own tool the picture is grabbable by its body
// (docs/BACKDROP.md §2). Only INSIDE the image rect: press beside the
// picture and the plane still pans with one finger.
if (this._bdMovable) {
const r = this._bdRect!;
const p = this._svgPoint(ev);
if (p[0] >= r.x && p[0] <= r.x + r.w && p[1] >= r.y && p[1] <= r.y + r.h) {
ev.preventDefault();
return this._bdStart(ev);
}
}
return false; // pan is allowed
}
@@ -3256,9 +3298,15 @@ class HouseplanCard extends LitElement {
/** Select tool: pointerdown on a shape starts moving it. */
private _decorShapeDown(ev: PointerEvent, shape: any): void {
if (this._mode !== 'decor') return;
// Under any other tool the shape is not a target at all: the press has to
// reach the stage, where the drawing tool starts a new figure (or the
// backdrop tool grabs the picture). Swallowing it here was the bug — the
// click on a line end did nothing but keep the old selection alive.
const t = this._decorTool;
if (t !== 'select' && t !== 'erase') return;
ev.stopPropagation();
ev.preventDefault();
if (this._decorTool === 'erase') {
if (t === 'erase') {
const sp = this._curSpaceCfg;
sp.decor = this._decorList.filter((x) => x.id !== shape.id);
if (this._decorSel === shape.id) this._decorSel = null;
@@ -3266,7 +3314,6 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
return;
}
if (this._decorTool !== 'select') return;
this._decorSel = shape.id;
this._decorMove = {
id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)),
@@ -3314,7 +3361,7 @@ class HouseplanCard extends LitElement {
/** Double click on a text shape (select tool) re-opens its dialog. */
private _decorShapeDbl(shape: any): void {
if (this._mode !== 'decor' || shape.kind !== 'text') return;
if (this._mode !== 'decor' || this._decorTool !== 'select' || shape.kind !== 'text') return;
this._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y,
text: shape.text, size: shape.size || 'm', color: shape.color };
}
@@ -3345,6 +3392,206 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
}
// ============ backdrop transform frame (docs/BACKDROP.md) ============
/** The centred, UNTRANSFORMED rectangle of the current backdrop image. */
private get _bdBase(): Rect | null {
const sp = this._curSpaceCfg;
return sp?.plan_url ? { ...fitInSquare(sp.plan_aspect, NORM_W) } : null;
}
/** Where the backdrop image sits right now, render units (null: no image). */
private get _bdRect(): Rect | null {
const sp = this._curSpaceCfg;
return sp?.plan_url ? planRect(sp, NORM_W) : null;
}
/** The stored transform of the current space (defaults = the old behaviour). */
private get _bdParams(): { dx: number; dy: number; k: number } {
const sp = this._curSpaceCfg;
const dx = Number(sp?.plan_x), dy = Number(sp?.plan_y), k = Number(sp?.plan_scale);
return {
dx: Number.isFinite(dx) ? dx : 0,
dy: Number.isFinite(dy) ? dy : 0,
k: Number.isFinite(k) && k > 0 ? k : 1,
};
}
/**
* Is the transform frame on screen? Only in the BACKDROP editor, and only
* under the two tools that are not busy drawing something — the frame's
* handles must never swallow the first point of a line. Every other mode
* (view, plan, devices, kiosk) leaves the picture alone entirely.
*/
private get _bdActive(): boolean {
return this._mode === 'decor' && !!this._bdRect
&& (this._decorTool === 'select' || this._decorTool === 'backdrop');
}
/**
* …and may the picture be dragged by its BODY? Only under its own tool.
*
* The corner handles are precise targets and are live as soon as the frame
* is, but the body is the whole picture, i.e. most of the screen — claiming
* it under the select tool would take away the one-finger pan the owner
* asked for on 2026-08-04 («таскать план при любом масштабе»), which
* smoke_pan_any_zoom guards. So moving the picture is a tool, exactly like
* drawing a line is.
*/
private get _bdMovable(): boolean {
return this._mode === 'decor' && this._decorTool === 'backdrop' && !!this._bdRect;
}
/** Write a transform into the space config — live, without saving. */
private _bdApply(dx: number, dy: number, k: number): void {
const sp = this._curSpaceCfg;
if (!sp) return;
// 6 decimals: the same precision the rest of the normalised config keeps,
// and enough for a 1000-unit canvas to be exact to a thousandth of a pixel
sp.plan_x = Number(clampCanvasN(dx).toFixed(6));
sp.plan_y = Number(clampCanvasN(dy).toFixed(6));
sp.plan_scale = Number(Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, k)).toFixed(6));
this._cfgEpoch++;
this.requestUpdate();
}
/**
* Begin a backdrop gesture. `corner` is the DRAGGED corner as a pair of
* signs (-1 = the low side of the axis, +1 = the high one); absent = the
* body, i.e. a move. Returns false when there is nothing to grab.
*/
private _bdStart(ev: PointerEvent, corner?: number[]): boolean {
const base = this._bdBase, r = this._bdRect;
if (!base || !r) return false;
const p = this._svgPoint(ev);
const sgx = corner ? corner[0] : 0;
const sgy = corner ? corner[1] : 0;
// the corner that stays put is the OPPOSITE one
const fx = sgx > 0 ? r.x : r.x + r.w;
const fy = sgy > 0 ? r.y : r.y + r.h;
this._bdDrag = {
kind: corner ? 'scale' : 'move',
pid: ev.pointerId,
sx: p[0], sy: p[1],
base, p0: this._bdParams,
fx, fy, sgx, sgy,
moved: false,
};
capturePointer(ev);
return true;
}
/**
* One step of the gesture.
*
* MOVE — the resulting TOP-LEFT CORNER is snapped, not the delta, so one
* drag is enough to put a legacy off-grid picture on the lattice
* (docs/CANVAS.md §9.3, the same rule decor already follows).
*
* SCALE — uniform about the fixed corner. The dragged corner is snapped
* along the picture's LONGER side and the scale is read back off it, so the
* fixed corner keeps its (on-grid) place and the picture's long side lands
* on a node. The short side follows from the aspect ratio and generally does
* not, which is what "uniform, no rotation, no stretch" costs.
*
* Shift suspends the snap, as everywhere (docs/CANVAS.md §9.4).
*/
private _bdMove(ev: PointerEvent): void {
const d = this._bdDrag;
if (!d) return;
const p = this._svgPoint(ev);
const b = d.base;
if (d.kind === 'move') {
const x0 = b.x + d.p0.dx * NORM_W;
const y0 = b.y + d.p0.dy * NORM_W;
const at = this._snap([x0 + (p[0] - d.sx), y0 + (p[1] - d.sy)], ev);
if (Math.abs(at[0] - x0) > 1e-9 || Math.abs(at[1] - y0) > 1e-9) d.moved = true;
this._bdApply((at[0] - b.x) / NORM_W, (at[1] - b.y) / NORM_W, d.p0.k);
return;
}
const w0 = b.w || 1, h0 = b.h || 1;
let k = Math.max(Math.abs(p[0] - d.fx) / w0, Math.abs(p[1] - d.fy) / h0);
if (!ev.shiftKey) {
// snap the dragged corner along the dominant axis, then read k back
const alongX = w0 >= h0;
const raw = alongX ? d.fx + d.sgx * k * w0 : d.fy + d.sgy * k * h0;
const snapped = snapToGrid(raw, this._gridPitch);
const span = Math.abs(snapped - (alongX ? d.fx : d.fy));
const kk = span / (alongX ? w0 : h0);
if (kk > 0) k = kk;
}
k = Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, k));
if (Math.abs(k - d.p0.k) > 1e-9) d.moved = true;
const x = d.sgx > 0 ? d.fx : d.fx - k * w0;
const y = d.sgy > 0 ? d.fy : d.fy - k * h0;
this._bdApply((x - b.x) / NORM_W, (y - b.y) / NORM_W, k);
}
/** Has this space's picture been moved or scaled at all? */
private get _bdMoved(): boolean {
if (this._mode !== 'decor' || !this._bdRect) return false;
const p = this._bdParams;
return p.dx !== 0 || p.dy !== 0 || p.k !== 1;
}
/** Put the picture back where an untouched plan has it: centred, own size. */
private _bdReset(): void {
const sp = this._curSpaceCfg;
if (!sp) return;
delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale;
this._bdDrag = null;
this._saveConfig();
this._showToast(this._t('decor.backdrop_reset_done'));
this.requestUpdate();
}
/** Release: persist only when something actually moved. */
private _bdUp(): void {
const d = this._bdDrag;
this._bdDrag = null;
if (d?.moved) this._saveConfig();
this.requestUpdate();
}
/**
* Live size badge while the picture is dragged or scaled (owner 2026-08-04):
* its REAL width × height through `cell_cm`, in the HA unit system — the
* same `_fmtLen` (`segmentCm`/`formatLength`) and the same `.measurelabel`
* the wall ruler and the room resize use, so there is one way the card ever
* states a length.
*/
private get _bdLive(): { x: number; y: number; text: string } | null {
if (!this._bdDrag) return null;
const r = this._bdRect;
if (!r) return null;
return {
x: r.x + r.w / 2,
y: r.y + r.h / 2,
text: `${this._fmtLen([0, 0], [r.w, 0])} × ${this._fmtLen([0, 0], [0, r.h])}`,
};
}
/** The transform frame itself: a dashed outline and four corner handles. */
private _renderBackdropFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing {
const r = this._bdRect;
if (!this._bdActive || !r) return nothing;
// finger-sized in SCREEN terms: a fraction of the visible view, so the
// handle stays grabbable at any zoom (the same rule the vacuum fit uses)
const hr = Math.max(view.w, view.h) * 0.02;
const corners: [number, number, string][] = [
[-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'],
];
return svg`<g class="bdframe">
<rect class="bdbox" x="${r.x}" y="${r.y}" width="${r.w}" height="${r.h}"></rect>
${corners.map(([sx, sy, cur]) => svg`<circle
class="bdhandle bd-${cur}" data-corner="${sx + ',' + sy}"
cx="${sx < 0 ? r.x : r.x + r.w}" cy="${sy < 0 ? r.y : r.y + r.h}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => {
e.stopPropagation(); e.preventDefault(); this._bdStart(e, [sx, sy]);
}}></circle>`)}
</g>` as unknown as TemplateResult;
}
private _renderDecorLayer(): TemplateResult {
const W = NORM_W, H = this._decorH;
const TXT = { s: 14, m: 20, l: 30 } as Record<string, number>;
@@ -3395,6 +3642,9 @@ class HouseplanCard extends LitElement {
private _renderDecorBar(): TemplateResult {
const tools = [
['select', 'mdi:cursor-default-outline', 'decor.select'],
// moving the picture is a TOOL (docs/BACKDROP.md §2) — offered only when
// there IS a picture, so a hand-drawn space's bar is unchanged
...(this._bdRect ? [['backdrop', 'mdi:image-move', 'decor.backdrop'] as const] : []),
['line', 'mdi:vector-line', 'decor.line'],
['rect', 'mdi:rectangle-outline', 'decor.rect'],
['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'],
@@ -3404,8 +3654,8 @@ class HouseplanCard extends LitElement {
return html`<div class="editbar decorbar">
<ha-icon icon="mdi:draw" class="warn"></ha-icon>
${tools.map(
([t, ic, k]) => html`<button class="btn ${this._decorTool === t ? 'on' : ''}"
@click=${() => { this._decorTool = t; this._decorDraft = null; }}
([t, ic, k]) => html`<button class="btn dtool ${this._decorTool === t ? 'on' : ''}"
@click=${() => { this._decorTool = t as typeof this._decorTool; this._decorDraft = null; }}
title=${this._t(k)}>
<ha-icon icon=${ic}></ha-icon><span class="ml">${this._t(k)}</span>
</button>`,
@@ -3422,7 +3672,21 @@ class HouseplanCard extends LitElement {
<label class="dfill"><input type="checkbox" .checked=${this._decorStyle.fill}
@change=${(e: Event) => (this._decorStyle = { ...this._decorStyle, fill: (e.target as HTMLInputElement).checked })} />
${this._t('decor.fill')}</label>
${''/* the picture's own affordance: only offered once it HAS been moved,
so an untouched plan gains no button and no explaining to do.
NOT while a gesture is live — this bar sits above the stage, and a
button appearing mid-drag changes the stage's height, i.e. how
many plan units a screen pixel is worth, under the finger. */}
${this._bdMoved && !this._bdDrag
? html`<button class="btn bdreset" title=${this._t('decor.backdrop_reset')}
@click=${() => this._bdReset()}>
<ha-icon icon="mdi:image-refresh-outline"></ha-icon><span class="ml">${this._t('decor.backdrop_reset')}</span>
</button>`
: nothing}
<span class="spacer"></span>
${this._bdMovable
? html`<span class="bdhint">${this._t('decor.backdrop_hint')}</span>`
: nothing}
<button class="btn barclose" title=${this._t('title.close_editor')}
@click=${() => this._setMode('view')}>
<ha-icon icon="mdi:close"></ha-icon>
@@ -4761,8 +5025,14 @@ class HouseplanCard extends LitElement {
sp.plan_aspect = pickedAspect;
}
// switching an existing space to "draw" detaches its background image
// (the uploaded file stays on disk; only the reference is cleared)
if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; }
// (the uploaded file stays on disk; only the reference is cleared).
// Its transform goes with it — there is nothing left for plan_x/plan_y/
// plan_scale to describe, and a stale one would silently apply to the
// NEXT picture uploaded here (docs/BACKDROP.md §1).
if (d.source === 'draw') {
sp.plan_url = null; sp.plan_aspect = null;
delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale;
}
// per-space display settings; hand-drawn spaces get borders+names on by default
const draw = d.source === 'draw';
sp.settings = {
@@ -5806,6 +6076,7 @@ class HouseplanCard extends LitElement {
// opening rulers: the drag of an existing one OR the placement preview
const opMeasure = this._opMeasureView;
const decorMeasure = this._decorMeasure;
const bdLive = this._bdLive;
return html`
<ha-card>
@@ -5881,7 +6152,7 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupBar() : this._mode === 'devices' ? this._renderDevicesBar() : this._mode === 'decor' ? this._renderDecorBar() : nothing}
</div>
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'openwall' && this._openWallHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayNight ? ' daynight' : ''}${dayNight && this._skySnap ? ' skysnap' : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}"
style="height:${this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${stageBg ? `;background:${stageBg}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@@ -5892,25 +6163,27 @@ class HouseplanCard extends LitElement {
<div class="zoomwrap ${this._slide ? 'slide-' + this._slide : ''}"
style="${dayNight ? `filter:brightness(${(1 - planDim).toFixed(3)})` : ''}">
<svg viewBox="${view.x} ${view.y} ${view.w} ${view.h}" preserveAspectRatio="xMidYMid meet">
${(() => {
// opaque paper: the image rect for image plans, the room
// contours for drawn ones (owner: no white square around an
// L-shaped house — the scene bg reaches the exterior walls).
// `space` comes from _renderCfg, so a live resize preview
// (_rszPreview) moves the paper together with the rooms.
// One <g> around ALL paper shapes: the daynight drop shadow
// (styles.ts) is composited once for the whole sheet, so
// adjacent rooms never cast seams onto each other's paper.
if (space.bg) {
const pp = this._paperRect(space)!;
return svg`<g class="hp-paperg"><rect class="hp-paper" x="${pp.x}" y="${pp.y}" width="${pp.w}" height="${pp.h}" pointer-events="none"></rect></g>`;
}
return svg`<g class="hp-paperg">${paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
)}</g>`;
})()}
${''/* THE PAPER IS THE ROOMS (docs/BACKDROP.md §3, owner
2026-08-04). Opaque shapes stop the scene background —
bg_color or the 'daynight' sky — from bleeding through the
plan. They follow the ROOM CONTOURS and nothing else: one
shape per room in exactly the room's own geometry, so an
L-shaped house or a pair of detached buildings never grows a
white bounding rectangle, and an empty space has no paper at
all. A backdrop image no longer makes paper of its own — it
is drawn ON this sheet, one layer below the geometry, so a
picture with transparency and no rooms under it shows the
scene through, which is the deliberate consequence.
`space` comes from _renderCfg, so a live resize preview
(_rszPreview) moves the paper together with the rooms.
One <g> around ALL paper shapes: the daynight drop shadow
(styles.ts) is composited once for the whole sheet, so
adjacent rooms never cast seams onto each other's paper. */}
${svg`<g class="hp-paperg">${paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
)}</g>`}
${this._editing ? this._renderMarkupDefs(vb) : nothing}
${''/* the grid is a property of the plane, not of a box: it follows
the VIEW so it is there wherever you pan (docs/CANVAS.md §7) */}
@@ -6019,6 +6292,11 @@ class HouseplanCard extends LitElement {
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
${''/* editor chrome, not plan content: the backdrop frame sits on
top of everything the plan draws so its handles stay
grabbable (docs/BACKDROP.md §2). It exists only in the
backdrop editor, where rooms and devices are pointer-inert. */}
${this._renderBackdropFrame(view)}
</svg>
${''/* docs/CANVAS.md §6: an icon is a percentage of the PLAN and
scales with it when you zoom — the behaviour the card always
@@ -6058,6 +6336,11 @@ class HouseplanCard extends LitElement {
class="measurelabel dmeasure ${decorMeasure.on45 ? 'on45' : ''}"
style="left:${(((decorMeasure.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((decorMeasure.y - view.y) / view.h) * 100).toFixed(2)}%">${decorMeasure.text}</div></div>`
: nothing}
${bdLive
? html`<div class="measurelayer"><div
class="measurelabel bdmeasure"
style="left:${(((bdLive.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((bdLive.y - view.y) / view.h) * 100).toFixed(2)}%">${bdLive.text}</div></div>`
: nothing}
</div>
${this._zoom > 1
? html`<div class="zoombadge">${Math.round(this._zoom * 100)}%</div>`
+4
View File
@@ -277,6 +277,10 @@
"decor.size_s": "Small",
"decor.size_m": "Medium",
"decor.size_l": "Large",
"decor.backdrop": "Backdrop image",
"decor.backdrop_hint": "Drag the picture to move it, pull a corner to resize it. Shift — off the grid.",
"decor.backdrop_reset": "Reset the picture",
"decor.backdrop_reset_done": "The picture is back at its original place and size",
"marker.icon_auto": "Auto: {icon} (by icon rules; pick one to override)",
"mode.plan_tip": "Plan editor — the geometry of the home: draw and split/merge rooms, bind them to HA areas, place doors and windows, move room cards, set the scale",
"mode.devices_tip": "Device editor — everything about icons: drag to position, click to edit binding/icon/display, add virtual devices, icon rules",
+4
View File
@@ -277,6 +277,10 @@
"decor.size_s": "Мелкий",
"decor.size_m": "Средний",
"decor.size_l": "Крупный",
"decor.backdrop": "Картинка-подложка",
"decor.backdrop_hint": "Тяните картинку — двигается, тяните угол — меняет размер. Shift — мимо сетки.",
"decor.backdrop_reset": "Вернуть картинку",
"decor.backdrop_reset_done": "Картинка вернулась на своё место и в свой размер",
"marker.icon_auto": "Авто: {icon} (по правилам иконок; выберите свою, чтобы заменить)",
"mode.plan_tip": "Редактор плана — геометрия дома: рисование и объединение/разделение комнат, привязка к зонам HA, двери и окна, карточки комнат, масштаб",
"mode.devices_tip": "Редактор устройств — всё про значки: перетаскивание, клик — настройка привязки/иконки/отображения, виртуальные устройства, правила иконок",
+46 -3
View File
@@ -24,6 +24,44 @@ export function fitInSquare(ratio: number | null | undefined, side: number) {
return { x: (side - w) / 2, y: (side - h) / 2, w, h };
}
/** Uniform backdrop scale bounds — mirrors validation.py (docs/BACKDROP.md). */
export const PLAN_SCALE_MIN = 0.01;
export const PLAN_SCALE_MAX = 100;
/**
* WHERE THE BACKDROP IMAGE SITS (docs/BACKDROP.md).
*
* `fitInSquare` is only the DEFAULT placement: the image centred in the square
* canvas at its own proportions. On top of it a space may carry three optional
* numbers, the ones the owner produces by dragging the transform frame in the
* backdrop editor:
*
* plan_x, plan_y — offset of the image's top-left corner from that default,
* in NORMALISED units (the very units rooms, openings and
* decor use, bounded by ±CANVAS_LIMIT);
* plan_scale — ONE uniform multiplier for both sides, anchored at the
* (already offset) top-left corner. There is no rotation.
*
* All three are OPTIONAL and their absence is exactly the pre-v1.58.0
* behaviour, so every plan written before renders bit-identically and there is
* no migration to run.
*/
export function planRect(space: any, side = NORM_W): Rect {
const base = fitInSquare(space?.plan_aspect, side);
const raw = Number(space?.plan_scale);
const k = Number.isFinite(raw) && raw > 0
? Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, raw))
: 1;
const dx = Number(space?.plan_x);
const dy = Number(space?.plan_y);
return {
x: base.x + (Number.isFinite(dx) ? clampCanvasN(dx) : 0) * side,
y: base.y + (Number.isFinite(dy) ? clampCanvasN(dy) : 0) * side,
w: base.w * k,
h: base.h * k,
};
}
export type Pt = { x: number; y: number };
export type Layout = Record<string, { s?: string; x: number; y: number } | undefined>;
@@ -78,7 +116,9 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
id: s.id,
title: s.title,
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...fitInSquare(s.plan_aspect, NORM_W) } : null,
// the image's own placement — the centred default plus whatever the
// backdrop frame has stored (plan_x/plan_y/plan_scale, docs/BACKDROP.md)
bg: s.plan_url ? { href: contentUrl(s.plan_url), ...planRect(s, NORM_W) } : null,
rooms: (s.rooms || []).map(scale),
} as SpaceModel;
});
@@ -180,8 +220,11 @@ export function contentItems(
): ContentItem[] {
const out: ContentItem[] = [];
for (const r of space.rooms || []) { const it = roomItem(r); if (it) out.push(it); }
// With a backdrop the IMAGE sets the extent: cropping to the outlined rooms
// would hide the parts of the picture nobody has drawn over yet.
// The backdrop image is ONE OF the objects of the space, exactly like a room
// (docs/BACKDROP.md §4): cropping to the outlined rooms would hide the parts
// of the picture nobody has drawn over yet, and — since v1.58.0 — the
// rectangle here is the MOVED and SCALED one, so «Вписать всё» follows the
// picture wherever the owner has dragged it.
if (space.bg) out.push({ minX: space.bg.x, minY: space.bg.y, maxX: space.bg.x + space.bg.w, maxY: space.bg.y + space.bg.h });
for (const e of extra || []) {
if (Array.isArray(e)) { const it = itemOf([e as any]); if (it) out.push(it); }
+11 -16
View File
@@ -176,26 +176,21 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
}
const stageBg = sunBg || stageBgOf(o.cfg?.settings, disp);
// Opaque plan paper (owner 2026-08-03), same contract as the full card: the
// scene background never bleeds through the plan. With a backdrop image the
// paper is the image rect; a hand-drawn plan papers the ROOM CONTOURS
// (paperRoomShapes) — never their bounding box, so the scene colour reaches
// the exterior walls of an L-shaped house and fills the gaps between
// detached buildings. An empty drawn space has no paper at all.
const paper = space.bg
? { x: space.bg.x, y: space.bg.y, w: space.bg.w, h: space.bg.h }
: null;
// Opaque plan paper, same contract as the full card (docs/BACKDROP.md §3):
// the paper is ALWAYS the ROOM CONTOURS and only them — never their bounding
// box, and (since v1.58.0) never the backdrop image rect either. The scene
// colour therefore reaches the exterior walls of an L-shaped house, fills the
// gaps between detached buildings, and an empty space has no paper at all,
// image or no image. The picture is drawn ON the paper, one layer above.
return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${paper
? svg`<rect class="hp-paper" x="${paper.x}" y="${paper.y}" width="${paper.w}" height="${paper.h}"></rect>`
: paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${paperRoomShapes(space.rooms).map((sh) =>
'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
${bgHref
? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing}
+48
View File
@@ -886,6 +886,16 @@ export const cardStyles = css`
cursor: pointer;
}
.stage.mode-decor.dtool-select .decorlayer .dshape { cursor: move; }
/* …but a DRAWING tool owns the canvas: existing shapes stop being targets
entirely, so a new line can start exactly on the end of an old one
instead of grabbing it (owner, 2026-08-04). Same for the picture tool —
a shape lying over the plan must not block the picture's own drag.
Only select (move) and erase (delete) keep the shapes clickable. */
.stage.mode-decor.dtool-line .decorlayer .dshape,
.stage.mode-decor.dtool-rect .decorlayer .dshape,
.stage.mode-decor.dtool-ellipse .decorlayer .dshape,
.stage.mode-decor.dtool-text .decorlayer .dshape,
.stage.mode-decor.dtool-backdrop .decorlayer .dshape { pointer-events: none; }
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
@@ -904,6 +914,37 @@ export const cardStyles = css`
outline: 2px solid #26a69a;
outline-offset: -2px;
}
/* backdrop transform frame (docs/BACKDROP.md §2). Editor chrome: the
outline never takes a pointer, the four corner handles do — and they are
finger-sized (r = 2 % of the visible view), because this is dragged on a
tablet as often as with a mouse. */
.bdframe .bdbox {
fill: none;
stroke: var(--hp-accent);
stroke-width: 2;
stroke-dasharray: 10 7;
vector-effect: non-scaling-stroke;
pointer-events: none;
opacity: 0.9;
}
.bdframe .bdhandle {
fill: var(--hp-accent);
stroke: #fff;
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
pointer-events: all;
touch-action: none;
}
.bdframe .bd-nwse { cursor: nwse-resize; }
.bdframe .bd-nesw { cursor: nesw-resize; }
/* the picture itself is the drag target for a move (grab, then grabbing) */
.stage.mode-decor.bdgrab { cursor: grab; }
.stage.mode-decor.bdgrabbing,
.stage.mode-decor.bdgrabbing .bdframe .bdhandle { cursor: grabbing; }
.measurelabel.bdmeasure {
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
.stage.mode-decor.dtool-line, .stage.mode-decor.dtool-rect,
.stage.mode-decor.dtool-ellipse, .stage.mode-decor.dtool-text {
cursor: crosshair;
@@ -925,6 +966,13 @@ export const cardStyles = css`
background: var(--hp-bg2, transparent); color: var(--hp-txt); border: 1px solid var(--hp-muted);
padding: var(--sp-2) var(--sp-3);
}
.decorbar .bdhint {
font-size: var(--fs-s);
color: var(--hp-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.decorbar .dfill {
display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-s); cursor: pointer;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* The backdrop transform and the paper contract (docs/BACKDROP.md).
*
* Two questions only, and both are pure geometry:
* 1. where the picture ends up once it has been moved and scaled, and
* 2. that the frame ("Вписать всё") counts THAT rectangle, not the old one.
* The third half of the feature — the paper is the rooms and only the rooms —
* lives in the renderers, so it is asserted in the DOM by demo/smoke_backdrop.mjs.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
NORM_W, planRect, fitInSquare, spaceModels, contentItems, contentBounds,
PLAN_SCALE_MIN, PLAN_SCALE_MAX, CANVAS_LIMIT,
} from '../test-build/space-geometry.js';
import { paperRoomShapes } from '../test-build/logic.js';
const wide = { id: 's', title: 's', plan_url: '/p.svg', plan_aspect: 2, view_box: [0, 0, 1, 1], rooms: [] };
const cfgOf = (space) => ({ spaces: [space], markers: [], settings: {} });
test('planRect: no fields at all is EXACTLY the centred default (no migration)', () => {
assert.deepEqual(planRect(wide), fitInSquare(2, NORM_W));
assert.deepEqual(planRect(wide), { x: 0, y: 250, w: 1000, h: 500 });
// an explicitly neutral transform is the same rectangle, to the bit
assert.deepEqual(planRect({ ...wide, plan_x: 0, plan_y: 0, plan_scale: 1 }),
{ x: 0, y: 250, w: 1000, h: 500 });
// …and so is every kind of junk a legacy store could hold. A non-number is
// no transform at all; for the SCALE zero and negatives are junk too (it is
// a multiplier), while for the OFFSET they are perfectly good coordinates.
for (const bad of [null, undefined, 'x', NaN, Infinity])
assert.deepEqual(planRect({ ...wide, plan_scale: bad, plan_x: bad, plan_y: bad }),
{ x: 0, y: 250, w: 1000, h: 500 }, `plan_* = ${String(bad)}`);
for (const bad of [0, -3])
assert.deepEqual(planRect({ ...wide, plan_scale: bad }),
{ x: 0, y: 250, w: 1000, h: 500 }, `plan_scale = ${String(bad)}`);
});
test('planRect: the offset is normalised, the scale is uniform about the top-left', () => {
// 0.25 of the canvas right, 0.1 down
assert.deepEqual(planRect({ ...wide, plan_x: 0.25, plan_y: 0.1 }),
{ x: 250, y: 350, w: 1000, h: 500 });
// half size: BOTH sides halve, the top-left corner stays put — no rotation,
// no stretch, one number
assert.deepEqual(planRect({ ...wide, plan_scale: 0.5 }),
{ x: 0, y: 250, w: 500, h: 250 });
// offset and scale compose: the offset moves the (already scaled) corner
assert.deepEqual(planRect({ ...wide, plan_x: -0.5, plan_y: 0.2, plan_scale: 2 }),
{ x: -500, y: 450, w: 2000, h: 1000 });
// the aspect ratio is preserved by construction, whatever the scale
const r = planRect({ ...wide, plan_scale: 3.7 });
assert.ok(Math.abs(r.w / r.h - 2) < 1e-12);
});
test('planRect: the stored numbers are clamped, never trusted', () => {
const far = planRect({ ...wide, plan_x: 1e9, plan_y: -1e9 });
assert.equal(far.x, 0 + CANVAS_LIMIT * NORM_W);
assert.equal(far.y, 250 - CANVAS_LIMIT * NORM_W);
assert.equal(planRect({ ...wide, plan_scale: 1e9 }).w, 1000 * PLAN_SCALE_MAX);
assert.equal(planRect({ ...wide, plan_scale: 1e-9 }).w, 1000 * PLAN_SCALE_MIN);
});
test('spaceModels: the model carries the TRANSFORMED image rectangle', () => {
const [m] = spaceModels(cfgOf({ ...wide, plan_x: 0.5, plan_y: 0.5, plan_scale: 0.5 }));
assert.deepEqual(m.bg, { href: '/p.svg', x: 500, y: 750, w: 500, h: 250 });
// and an untouched space is bit-identical to what it rendered before
const [old] = spaceModels(cfgOf(wide));
assert.deepEqual(old.bg, { href: '/p.svg', x: 0, y: 250, w: 1000, h: 500 });
});
test('the content frame follows the picture (Вписать всё never loses it)', () => {
const moved = spaceModels(cfgOf({
...wide, plan_x: 2, plan_y: 2, plan_scale: 0.5,
rooms: [{ id: 'r', name: 'r', poly: [[0.4, 0.4], [0.5, 0.4], [0.5, 0.5], [0.4, 0.5]] }],
}))[0];
// the picture is still ONE content item, exactly like a room
const items = contentItems(moved);
assert.equal(items.length, 2);
const img = items[1];
assert.deepEqual(img, { minX: 2000, minY: 2250, maxX: 2500, maxY: 2500 });
const b = contentBounds(moved);
assert.ok(b.x <= 400 && b.x + b.w >= 2500, 'the frame reaches the moved picture');
assert.ok(b.y <= 400 && b.y + b.h >= 2500);
// …and shrinking the picture shrinks the frame with it
const small = spaceModels(cfgOf({ ...wide, plan_scale: 0.25 }))[0];
const sb = contentBounds(small);
assert.ok(sb.w < 400, `a quarter-scale picture frames tight, got ${sb.w}`);
});
test('the paper is the ROOMS, image or no image (docs/BACKDROP.md §3)', () => {
// paperRoomShapes knows nothing about plan_url — which is the whole point:
// there is no longer a branch where the picture makes paper of its own.
const rooms = [{ id: 'r', name: 'r', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] }];
assert.equal(paperRoomShapes(rooms).length, 1);
assert.equal(paperRoomShapes([]).length, 0, 'an empty space has no paper at all');
});
+29
View File
@@ -107,6 +107,35 @@ def test_space_schema_drops_the_old_aspect_and_bounds_the_image_ratio():
v.SPACE_SCHEMA({**ok, "view_box": [0, 0, 1]})
def test_space_backdrop_transform_is_optional_and_bounded():
"""plan_x / plan_y / plan_scale — the backdrop placement (docs/BACKDROP.md).
Optional to the letter: a space that has never been through the backdrop
editor validates exactly as it did before, and the keys stay absent (there
is no migration and no default written into anybody's store).
"""
ok = {"id": "f1", "title": "1", "view_box": [0, 0, 1, 1], "rooms": []}
out = v.SPACE_SCHEMA(ok)
assert "plan_x" not in out and "plan_y" not in out and "plan_scale" not in out
v.SPACE_SCHEMA({**ok, "plan_x": 0.25, "plan_y": -1.5, "plan_scale": 2.5})
v.SPACE_SCHEMA({**ok, "plan_x": None, "plan_y": None, "plan_scale": None})
# the offset is a coordinate: the whole canvas range, both signs
v.SPACE_SCHEMA({**ok, "plan_x": v.CANVAS_LIMIT, "plan_y": -v.CANVAS_LIMIT})
# the scale is a MULTIPLIER: strictly positive, human-sized
v.SPACE_SCHEMA({**ok, "plan_scale": v.PLAN_SCALE_MIN})
v.SPACE_SCHEMA({**ok, "plan_scale": v.PLAN_SCALE_MAX})
for bad in ({"plan_x": v.CANVAS_LIMIT + 1}, {"plan_y": -v.CANVAS_LIMIT - 1},
{"plan_x": float("nan")}, {"plan_y": float("inf")},
{"plan_scale": 0}, {"plan_scale": -1},
{"plan_scale": v.PLAN_SCALE_MIN / 2},
{"plan_scale": v.PLAN_SCALE_MAX * 2},
{"plan_scale": float("nan")}):
with pytest.raises(vol.Invalid):
v.SPACE_SCHEMA({**ok, **bad})
def test_marker_schema():
v.MARKER_SCHEMA({"id": "m1", "binding": "device:abc"})
v.MARKER_SCHEMA({"id": "m2", "binding": "virtual", "name": "X",