mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Infinite canvas: smoke, and the three smokes that pinned the square
demo/smoke_infinite_canvas.mjs: a plan at 1.5..3.0 renders whole with every room and marker on screen, a device placed at 3.4/2.9 and a room at 3.8 survive the WS write (and the payload is fed to the REAL voluptuous schema when it is installed), one stray at 90/90 neither commands the view nor hides itself, «Показать» fits it, zoom-out stops at exactly 3x, a marker keeps its pixel size at zoom 1/4/1-3, and an old small plan frames to the same rectangle as before. Adjusted, each with the reason in the smoke: - smoke_audit_1490: "editors see the whole canvas" rewritten into the intent HP-1490-03 actually had — there is room to draw outwards; - smoke_zoom_out: the zoom-out floor is 1/3 of the content, not 0.4; - smoke_hidden_flag: the static card frames content, so the auto-grid parity check reads its viewBox instead of assuming 0..1000. docs/TESTING.md: a manual checklist section for the feature.
This commit is contained in:
@@ -21,14 +21,27 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
const inPlan = c._baseVb();
|
||||
o.editorSeesWholeCanvas = inPlan[2] === 1000 && inPlan[3] === 1000;
|
||||
// docs/CANVAS.md: холста-квадрата больше нет, поэтому «редактор видит весь
|
||||
// холст» переписано в исходное НАМЕРЕНИЕ HP-1490-03 — «в редакторе есть куда
|
||||
// рисовать наружу». Рамка теперь по содержимому и в редакторе тоже, а место
|
||||
// даёт запас панорамирования (§5) и зум-аут до 3x.
|
||||
o.editorFrameIsContent = inPlan[2] < 999 && inPlan[2] > 0;
|
||||
o.editorCanPanPastContent = (() => {
|
||||
const fit = c._viewOr(c._baseVb());
|
||||
const moved = c._clampView({ x: fit.x + fit.w * 0.9, y: fit.y, w: fit.w, h: fit.h }, fit);
|
||||
return moved.x > fit.x + fit.w * 0.5; // ушли почти на экран вправо, зажима нет
|
||||
})();
|
||||
o.editorZoomOutGivesRoom = (() => {
|
||||
c._resetZoom(); c._applyView(1 / 3);
|
||||
const wide = c._view.w; c._resetZoom();
|
||||
return wide > inPlan[2] * 2.5; // видно втрое больше, чем нарисовано
|
||||
})();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.editorViewCoversCanvas = v.w >= 999; // старый cropped view не пережил смену
|
||||
// в редакторе можно ткнуть в дальний угол холста
|
||||
o.canReachFarCorner = (() => {
|
||||
const stage = (c.shadowRoot || c.renderRoot).querySelector('.stage');
|
||||
const pt = c._screenToVb(stage.clientWidth - 1, stage.clientHeight - 1);
|
||||
return pt[0] > 900 || pt[1] > 900;
|
||||
return pt[0] > inPlan[0] + inPlan[2] * 0.9 || pt[1] > inPlan[1] + inPlan[3] * 0.9;
|
||||
})();
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(r));
|
||||
|
||||
@@ -175,8 +175,14 @@ Object.assign(out, await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const st = [...card.renderRoot.querySelectorAll('[style*="left"]')]
|
||||
.find((el) => /%$/.test(el.style.left || '') && el.style.top);
|
||||
// статичная карточка рендерит от полного квадрата 0..1000
|
||||
const stVb = st && [parseFloat(st.style.left) * 10, parseFloat(st.style.top) * 10];
|
||||
// docs/CANVAS.md §4: статичная карточка теперь кадрирует СОДЕРЖИМОЕ, как и
|
||||
// полная, а не квадрат 0..1000 — координаты берём из её же viewBox
|
||||
const svb = (card.renderRoot.querySelector('svg')?.getAttribute('viewBox') || '0 0 1000 1000')
|
||||
.trim().split(/\s+/).map(Number);
|
||||
const stVb = st && [
|
||||
svb[0] + (parseFloat(st.style.left) / 100) * svb[2],
|
||||
svb[1] + (parseFloat(st.style.top) / 100) * svb[3],
|
||||
];
|
||||
o.autoGridParity = !!fullVb && !!stVb
|
||||
&& Math.abs(fullVb[0] - stVb[0]) < 6 && Math.abs(fullVb[1] - stVb[1]) < 6;
|
||||
if (!o.autoGridParity) console.log('full', fullVb, 'static', stVb);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// Infinite canvas (docs/CANVAS.md): a plan drawn far past the old unit square
|
||||
// renders whole, devices can be placed and saved out there, the opening view
|
||||
// follows the content, one far stray neither breaks the view nor hides itself,
|
||||
// zoom-out stops at 3x the content and icons keep their pixel size.
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const SHOTS = process.env.HP_SHOTS || '';
|
||||
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
|
||||
const out = {};
|
||||
const raf = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
|
||||
// A hand-drawn plan whose rooms live at 1.5..3.0 — entirely outside the old
|
||||
// canvas. Before the infinite canvas the -25%..125% envelope threw every one
|
||||
// of these vertices away and the card framed empty space.
|
||||
const FAR_CFG = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Ground floor', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [
|
||||
{ id: 'r1', name: 'Living room', area: 'living_room',
|
||||
poly: [[1.5, 1.5], [2.2, 1.5], [2.2, 2.2], [1.5, 2.2]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[2.2, 1.5], [3.0, 1.5], [3.0, 2.0], [2.2, 2.0]] },
|
||||
{ id: 'r3', name: 'Bedroom', area: 'bedroom',
|
||||
poly: [[2.2, 2.0], [3.0, 2.0], [3.0, 2.6], [2.2, 2.6]] },
|
||||
{ id: 'r4', name: 'Hallway', area: 'hallway',
|
||||
poly: [[1.5, 2.2], [2.2, 2.2], [2.2, 2.6], [1.5, 2.6]] },
|
||||
],
|
||||
openings: [{ id: 'o1', type: 'window', x: 1.8, y: 1.5, angle: 0, length: 0.15 }],
|
||||
decor: [{ id: 'd1', kind: 'line', x1: 1.4, y1: 2.7, x2: 3.1, y2: 2.7, color: '#8899aa' }],
|
||||
}, {
|
||||
id: 'garden', title: 'Garden', view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'g1', name: 'Garden', area: 'garden',
|
||||
poly: [[0.03, 0.178], [0.97, 0.178], [0.97, 0.822], [0.03, 0.822]] }],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const FAR_LAYOUT = {
|
||||
d_light1: { s: 'f1', x: 1.7, y: 1.7 }, d_lamp: { s: 'f1', x: 2.0, y: 1.9 },
|
||||
d_tv: { s: 'f1', x: 1.6, y: 2.1 }, d_temp: { s: 'f1', x: 1.9, y: 1.6 },
|
||||
d_kettle: { s: 'f1', x: 2.5, y: 1.7 }, d_leak: { s: 'f1', x: 2.9, y: 1.8 },
|
||||
d_bedlight: { s: 'f1', x: 2.6, y: 2.3 }, d_window: { s: 'f1', x: 2.9, y: 2.5 },
|
||||
d_lock: { s: 'f1', x: 1.6, y: 2.5 }, d_motion: { s: 'f1', x: 2.0, y: 2.4 },
|
||||
};
|
||||
|
||||
const loadFar = (cfg, layout) => page.evaluate(async ([c2, l2]) => {
|
||||
const c = window.__card;
|
||||
c._serverCfg = JSON.parse(JSON.stringify(c2));
|
||||
c._layout = JSON.parse(JSON.stringify(l2));
|
||||
c._modelCache = null;
|
||||
c._frame = null;
|
||||
c._showFar = false;
|
||||
c._space = 'f1';
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions();
|
||||
c._view = null;
|
||||
c._zoom = 1;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
c._resetZoom();
|
||||
await c.updateComplete;
|
||||
}, [cfg, layout]);
|
||||
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
|
||||
// ---- (b) the plan past the old square renders WHOLE ----------------------
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const b = c._baseVb();
|
||||
// the content is 1500..3000 x 1500..2700 render units, plus decor/openings
|
||||
o.frameHoldsFarPlan = b[0] < 1450 && b[0] + b[2] > 3050 && b[1] < 1450 && b[1] + b[3] > 2750;
|
||||
o.frameIsNotTheOldSquare = b[0] > 1000; // nothing is framed at the origin any more
|
||||
const v = c._viewOr(b);
|
||||
o.viewShowsFarPlan = v.x <= 1500 && v.x + v.w >= 3000;
|
||||
// every room polygon is inside the painted viewBox
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const polys = [...sr.querySelectorAll('polygon.room')];
|
||||
o.allRoomsDrawn = polys.length === 4;
|
||||
const st = sr.querySelector('.stage').getBoundingClientRect();
|
||||
o.roomsOnScreen = polys.every((p) => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.width > 4 && r.height > 4 && r.left >= st.left - 2 && r.right <= st.right + 2
|
||||
&& r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
// and the markers landed on the plan, not in a corner of a phantom canvas
|
||||
const devs = [...sr.querySelectorAll('.devlayer .dev')];
|
||||
o.markersOnPlan = devs.length >= 10 && devs.every((d) => {
|
||||
const r = d.getBoundingClientRect();
|
||||
return r.left >= st.left - 2 && r.right <= st.right + 2 && r.top >= st.top - 2 && r.bottom <= st.bottom + 2;
|
||||
});
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- a device can be PLACED past the old square, and it saves -------------
|
||||
const ws = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sent = [];
|
||||
const base = c.hass.callWS;
|
||||
c.hass = { ...c.hass, callWS: async (m) => { sent.push(m); return base(m); } };
|
||||
// drop a virtual marker at 3.4 / 2.9 — a long way outside the old canvas
|
||||
const far = { s: 'f1', x: 3.4, y: 2.9 };
|
||||
c._layout = { ...c._layout, d_gate: far };
|
||||
c._dirtyPos.add('d_gate');
|
||||
await c._persistLayout?.();
|
||||
// and a room extended even further out, saved through the config writer
|
||||
const cfg = JSON.parse(JSON.stringify(c._serverCfg));
|
||||
cfg.spaces[0].rooms.push({ id: 'r5', name: 'Workshop',
|
||||
poly: [[3.1, 2.0], [3.8, 2.0], [3.8, 2.6], [3.1, 2.6]] });
|
||||
c._serverCfg = cfg; c._modelCache = null; c._frame = null;
|
||||
await c._saveConfig?.();
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
return { sent: JSON.parse(JSON.stringify(sent)), layout: JSON.parse(JSON.stringify(c._layout)) };
|
||||
});
|
||||
out.farDeviceSaved = ws.layout.d_gate.x === 3.4 && ws.layout.d_gate.y === 2.9;
|
||||
const cfgMsg = ws.sent.filter((m) => m.type === 'houseplan/config/set').pop();
|
||||
const layMsg = ws.sent.filter((m) => m.type === 'houseplan/layout/update').pop();
|
||||
out.wsCarriedFarGeometry = !!cfgMsg
|
||||
&& JSON.stringify(cfgMsg.config).includes('3.8');
|
||||
out.wsCarriedFarPosition = !!layMsg && JSON.stringify(layMsg).includes('3.4');
|
||||
|
||||
// the payload the card sends must pass the REAL backend schema (docs/CANVAS.md
|
||||
// §3: ±5000). Skipped, not failed, where voluptuous is not installed.
|
||||
out.backendAcceptsFarPayload = (() => {
|
||||
if (!cfgMsg) return false;
|
||||
const f = '/tmp/hp_infinite_payload.json';
|
||||
writeFileSync(f, JSON.stringify({ config: cfgMsg.config, layout: (layMsg || {}).layout || ws.layout }));
|
||||
try {
|
||||
execFileSync('python3', ['-c', 'import voluptuous'], { stdio: 'ignore' });
|
||||
} catch { console.log('(voluptuous absent — backend schema check skipped)'); return true; }
|
||||
const code = [
|
||||
'import json,sys',
|
||||
'sys.path.insert(0, "custom_components/houseplan")',
|
||||
'import validation as v',
|
||||
`d=json.load(open(${JSON.stringify(f)}))`,
|
||||
'v.CONFIG_SCHEMA(d["config"]); v.LAYOUT_SCHEMA(d["layout"])',
|
||||
'print("VALID")',
|
||||
].join('\n');
|
||||
try {
|
||||
return execFileSync('python3', ['-c', code], { encoding: 'utf8' }).includes('VALID');
|
||||
} catch (e) { console.log('backend refused the payload:', String(e.stdout || e.message).slice(-400)); return false; }
|
||||
})();
|
||||
|
||||
// ---- an OUTLIER: the view ignores it, the hint offers it ------------------
|
||||
await loadFar({
|
||||
...FAR_CFG,
|
||||
spaces: FAR_CFG.spaces.map((s) => (s.id === 'f1' ? { ...s } : s)),
|
||||
}, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
o.outlierReported = c._outliers === 1;
|
||||
const b = c._baseVb();
|
||||
o.outlierDoesNotCommandView = b[2] < 4000 && b[0] + b[2] < 5000; // still the house, not 90 canvases
|
||||
o.outlierViewStillHoldsPlan = b[0] < 1500 && b[0] + b[2] > 3000;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const hint = sr.querySelector('.farhint');
|
||||
o.hintShown = !!hint && !!hint.querySelector('button');
|
||||
o.hintIsNotAModal = !sr.querySelector('.dialogwrap');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// «Показать» / «Вписать всё» takes the stray in
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
sr.querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
const o = {};
|
||||
const v = c._viewOr(c._baseVb());
|
||||
o.fitAllReachesTheStray = v.x + v.w > 90000 && v.y + v.h > 90000;
|
||||
o.fitAllStillHoldsPlan = v.x <= 1500 && v.y <= 1500;
|
||||
await c.updateComplete;
|
||||
o.hintGoneAfterShow = !sr.querySelector('.farhint');
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- zoom-out stops at 3x, zoom-in unchanged -----------------------------
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
Object.assign(out, await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
c._resetZoom();
|
||||
const fit = { ...c._viewOr(c._baseVb()) };
|
||||
c._applyView(0.001);
|
||||
o.zoomOutStopsAtThree = Math.abs(c._zoom - 1 / 3) < 1e-9
|
||||
&& Math.abs(c._view.w - fit.w * 3) < fit.w * 0.02;
|
||||
c._applyView(50);
|
||||
o.zoomInUnchanged = c._zoom === 8;
|
||||
c._resetZoom();
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- icons keep their PIXEL size across zooms (docs/CANVAS.md §6) --------
|
||||
out.iconSizeIsViewportRelative = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const size = () => {
|
||||
const el = sr.querySelector('.devlayer .dev');
|
||||
const r = el.getBoundingClientRect();
|
||||
return Math.round(r.width * 10) / 10;
|
||||
};
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const at1 = size();
|
||||
c._applyView(4); await c.updateComplete;
|
||||
const at4 = size();
|
||||
c._applyView(1 / 3); await c.updateComplete;
|
||||
const atOut = size();
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
return at1 > 4 && Math.abs(at4 - at1) <= 1 && Math.abs(atOut - at1) <= 1;
|
||||
});
|
||||
|
||||
// ---- an OLD plan (small, with a view_box) opens exactly as before ---------
|
||||
Object.assign(out, await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const o = {};
|
||||
const cfg = {
|
||||
spaces: [{ id: 'f1', title: 'Old', view_box: [0, 0, 1, 1], plan_url: null, plan_aspect: null,
|
||||
rooms: [{ id: 'r1', name: 'Living', area: 'living_room',
|
||||
poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
|
||||
{ id: 'r2', name: 'Kitchen', area: 'kitchen',
|
||||
poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] }] }],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
c._serverCfg = cfg; c._layout = { d_light1: { s: 'f1', x: 0.22, y: 0.22 } };
|
||||
c._modelCache = null; c._frame = null; c._showFar = false; c._view = null;
|
||||
c._maybeRebuildDevices?.();
|
||||
c._defPos = c._defaultPositions(); // the auto grid follows the new rooms
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
c._resetZoom(); await c.updateComplete;
|
||||
const b = c._baseVb();
|
||||
// identical to what contentBounds produced before: the content bbox
|
||||
// (40..960 x 140..580) plus 5% of the longer side (920 -> 46)
|
||||
o.legacyFrameUnchanged = Math.abs(b[0] - -6) < 1 && Math.abs(b[1] - 94) < 1
|
||||
&& Math.abs(b[2] - 1012) < 1 && Math.abs(b[3] - 532) < 1;
|
||||
o.legacyNoHint = c._outliers === 0;
|
||||
// an EMPTY space falls back to the stored view_box hint (§4)
|
||||
c._serverCfg = { spaces: [{ id: 'f1', title: 'E', view_box: [0.1, 0.2, 0.5, 0.4], rooms: [] }],
|
||||
markers: [], settings: {} };
|
||||
// a space with NOTHING in it at all — not even a marker parked in it
|
||||
c._layout = {}; c._modelCache = null; c._frame = null;
|
||||
c._devices = [];
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const e = c._baseVb();
|
||||
o.emptySpaceUsesViewBoxHint = JSON.stringify(e.map(Math.round)) === '[100,200,500,400]';
|
||||
return o;
|
||||
}));
|
||||
|
||||
// ---- screenshots ---------------------------------------------------------
|
||||
if (SHOTS) {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_plan.png` });
|
||||
// fit-all with a stray far away
|
||||
await loadFar(FAR_CFG, { ...FAR_LAYOUT, d_motion: { s: 'f1', x: 90, y: 90 } });
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_far_hint.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
(c.shadowRoot || c.renderRoot).querySelector('.farhint button').click();
|
||||
await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_fit_all.png` });
|
||||
// adaptive grid, zoomed far out in the plan editor
|
||||
await loadFar(FAR_CFG, FAR_LAYOUT);
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
c._resetZoom(); c._applyView(1 / 3); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_far.png` });
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._resetZoom(); c._applyView(4); await c.updateComplete;
|
||||
});
|
||||
await raf();
|
||||
await page.screenshot({ path: `${SHOTS}/canvas_grid_near.png` });
|
||||
await page.evaluate(async () => { window.__card._setMode('view'); });
|
||||
}
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -44,8 +44,11 @@ out.zoomOut = await page.evaluate(() => {
|
||||
out.zoomOutWorks = out.zoomOut.zoom === 0.5 && out.zoomOut.wider
|
||||
&& out.zoomOut.centredX && out.zoomOut.centredY;
|
||||
delete out.zoomOut;
|
||||
out.floorIsHalf = await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.1); return c._zoom; }) === 0.4; // clamped at the floor
|
||||
// docs/CANVAS.md §5: the zoom-out floor moved from a fixed 0.4 of the old
|
||||
// view_box to 1/3 of the CONTENT frame — three canvases of empty plane is as
|
||||
// far as it is useful to go when the canvas has no edges.
|
||||
out.floorIsThird = Math.abs(await page.evaluate(() => { window.__card._resetZoom(); const c = window.__card;
|
||||
c._applyView(0.01); return c._zoom; }) - 1 / 3) < 1e-9; // clamped at the floor
|
||||
await page.evaluate(() => window.__card._resetZoom());
|
||||
|
||||
// -- editor zoom is a working tool, not the viewing intent ----------------
|
||||
|
||||
@@ -960,3 +960,48 @@ require hands on real hardware — they remain for the human pass.
|
||||
(registry-wide climate, like hidden thermometers); the tick survives
|
||||
dialog recreation [auto: smoke_climate_temp; units: test/devices.test.mjs;
|
||||
backend: tests_backend/test_validation.py (use_climate_temp)]
|
||||
|
||||
## Infinite canvas (docs/CANVAS.md, dev)
|
||||
|
||||
- [ ] **A plan drawn past the old square opens whole**: a space whose rooms
|
||||
live at normalised 1.5..3.0 renders complete and centred (it used to
|
||||
frame empty canvas with the house off-screen) [auto:
|
||||
smoke_infinite_canvas; units: test/canvas.test.mjs]
|
||||
- [ ] **Nothing stops at an edge any more**: in the Plan / Devices / Decor
|
||||
editors a room, a marker and a decor shape can be drawn, dragged and
|
||||
SAVED far outside `0..1`, on any floor; reload keeps them there
|
||||
[backend: tests_backend/test_validation.py::test_infinite_canvas_range]
|
||||
- [ ] **A typical small plan is visually unchanged** — same framing, same
|
||||
room and label positions as before the feature. The ONE intended
|
||||
difference is icon size (below) [auto: smoke_infinite_canvas
|
||||
(legacyFrameUnchanged); the whole smoke suite is the regression net]
|
||||
- [ ] **Icons no longer grow with zoom** (§6, owner is aware): a marker keeps
|
||||
the same pixel size at zoom 1, 4 and at the zoom-out floor; the
|
||||
per-device size multiplier, kiosk icon/font scales, badges, LQI chips
|
||||
and presence rings all still scale from `--dev-size`
|
||||
- [ ] **Start view follows the content**: opening a space frames what is
|
||||
drawn plus a small margin, on every floor, with and without a backdrop
|
||||
image (with one the IMAGE sets the extent — it must not be cropped to
|
||||
the outlined rooms)
|
||||
- [ ] **A far stray does not break the view** (§4.1): a marker dragged an
|
||||
order of magnitude away leaves the opening view alone and raises the
|
||||
inline chip «Объектов далеко от плана: N» with «Показать». No modal.
|
||||
«Показать» fits the plan AND the stray; the chip then disappears
|
||||
- [ ] **«Вписать всё»** (middle zoom button): fits the content from any pan
|
||||
and any zoom, is never disabled, tooltip en/ru
|
||||
- [ ] **Zoom-out floor**: the wheel / the minus button stop at three times the
|
||||
content frame; zoom-in still stops at 800 %
|
||||
- [ ] **Pan has slack, not walls**: you can pan a full screen past the plan in
|
||||
every direction; when the plan is fully off screen a small arrow points
|
||||
home and one click fits it back
|
||||
- [ ] **Adaptive grid** (§7): in the Plan editor zoomed far out the grid does
|
||||
not merge into a grey wash — fine dots thin out, every 5th/10th node
|
||||
stays bigger; zoomed in the grid is the usual one and snapping still
|
||||
lands on the same nodes as before
|
||||
- [ ] **Everything else on a far-out plan**: sun wedges, glow radii, open
|
||||
boundaries, room resize handles/rulers, opening rulers, split/merge,
|
||||
vacuum trails, the static `houseplan-space-card` and kiosk carousel all
|
||||
behave exactly as on a plan inside `0..1`
|
||||
- [ ] **Real config regression**: a production config (e.g. the dacha, 3
|
||||
floors / 106 markers) frames bit-identically to the previous release —
|
||||
no outliers reported, no frame movement
|
||||
|
||||
Reference in New Issue
Block a user