mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 14:39:22 +00:00
v1.58.0-beta.1: backdrop transform, paper by rooms, decor tool fix
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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');
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
Vendored
+98
-32
File diff suppressed because one or more lines are too long
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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>`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user