mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
v1.59.0-beta.1: warm remount keeps your view and dialogs, sun ray rim
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.58.0"
|
||||
VERSION = "1.59.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.58.0"
|
||||
"version": "1.59.0-beta.1"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Stills for the rim (docs/SUN.md, «The rim»). Two scenes — white paper and
|
||||
// the dark glow canvas — and, in each, the SAME frame at several rim peaks so
|
||||
// the value can be picked by looking rather than by arguing. The alphas other
|
||||
// than the shipped one are applied by rewriting the `hp-sunrim-*` stops in the
|
||||
// DOM, which is why a rebuild is not needed to compare them.
|
||||
// node demo/shot_sun_rim.mjs <outdir> [alphas, default 0,0.3,0.42,0.5]
|
||||
// `0` is the "before" frame: the wedge alone, with no rim at all.
|
||||
import { launch } from './serve.mjs';
|
||||
const outDir = process.argv[2] || '/tmp';
|
||||
const ALPHAS = (process.argv[3] || '0,0.3,0.42,0.5').split(',').map(Number);
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // still shots, no fades
|
||||
|
||||
const setup = async (scene) => {
|
||||
await page.evaluate(async (sc) => {
|
||||
const c = window.__card;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
sp.openings = [
|
||||
{ id: 'wN', type: 'window', x: 0.30, y: 0.14, angle: 0, length: 0.08 },
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wW2', type: 'window', x: 0.04, y: 0.72, angle: 90, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = null; // plain paper, nothing to hide behind
|
||||
sp.settings = { ...(sp.settings || {}), fill_mode: sc === 'dark' ? 'glow' : 'none' };
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, sun_rays: true,
|
||||
bg_mode: 'static', bg_color: sc === 'dark' ? '#101720' : '#f2f2f0' };
|
||||
c._cfgEpoch++;
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'light.ceiling': { ...c.hass.states['light.ceiling'], state: sc === 'dark' ? 'on' : 'off' },
|
||||
'sun.sun': { entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 250, elevation: 35 } } } };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
}, scene);
|
||||
await page.waitForTimeout(300);
|
||||
};
|
||||
|
||||
// Rescale every rim gradient to `peak`, always from the stops as BUILT — a
|
||||
// scale relative to the current values would collapse to zero the moment the
|
||||
// 0 frame is taken and never come back.
|
||||
const setAlpha = async (peak) => {
|
||||
await page.evaluate((a) => {
|
||||
for (const g of window.__card.shadowRoot.querySelectorAll('linearGradient[id^=hp-sunrim-]')) {
|
||||
const stops = [...g.querySelectorAll('stop')];
|
||||
if (!g.__base) g.__base = stops.map((s) => Number(s.getAttribute('stop-opacity')));
|
||||
const p0 = g.__base[0] || 1;
|
||||
stops.forEach((s, i) => s.setAttribute('stop-opacity', (a * (g.__base[i] / p0)).toFixed(4)));
|
||||
}
|
||||
}, peak);
|
||||
await page.waitForTimeout(120);
|
||||
};
|
||||
|
||||
for (const scene of ['light', 'dark']) {
|
||||
await setup(scene);
|
||||
for (const a of ALPHAS) {
|
||||
await setAlpha(a);
|
||||
const stage = await page.evaluateHandle(() => window.__card.shadowRoot.querySelector('.stage'));
|
||||
await stage.asElement().screenshot({ path: `${outDir}/rim_${scene}_${String(a).replace('.', '')}.png` });
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
console.log('shots written to ' + outDir);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { launch, check, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
@@ -25,14 +25,78 @@ const res = await page.evaluate(async () => {
|
||||
out.dialogOpens = !!c._spaceDialog;
|
||||
out.tabNotSwitched = c._space === cur;
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
// кнопка "+" по-прежнему только в Плане
|
||||
out.noAddInView = !sr().querySelector('.tab.tabadd');
|
||||
c._setMode('plan'); await c.updateComplete;
|
||||
out.addInPlan = !!sr().querySelector('.tab.tabadd');
|
||||
// «+» — навигационное действие, а не инструмент редактора плана
|
||||
// (владелец 2026-08-04): кнопка есть во ВСЕХ режимах и кликабельна.
|
||||
const addBtn = () => sr().querySelector('.tab.tabadd');
|
||||
const hittable = () => {
|
||||
const b = addBtn();
|
||||
if (!b) return 'нет кнопки';
|
||||
const r = b.getBoundingClientRect();
|
||||
if (r.width < 8 || r.height < 8) return `кнопка ${r.width}x${r.height}px`;
|
||||
const hit = sr().elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return hit && hit.closest && hit.closest('.tab.tabadd') === b ? true : 'перекрыта';
|
||||
};
|
||||
out.addInView = hittable();
|
||||
for (const m of ['plan', 'devices', 'decor']) {
|
||||
c._setMode(m); await c.updateComplete;
|
||||
out['addIn_' + m] = hittable();
|
||||
}
|
||||
c._setMode('view'); await c.updateComplete;
|
||||
// …и открывает именно диалог СОЗДАНИЯ
|
||||
const b0 = addBtn();
|
||||
if (b0) { b0.click(); await c.updateComplete; }
|
||||
out.addOpensCreate = !!c._spaceDialog && c._spaceDialog.mode === 'create';
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
return out;
|
||||
});
|
||||
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
|
||||
checkAll(res, {
|
||||
"alignDelta": 0,
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
// киоск — витрина: редактирование недоступно, кнопки «+» нет ВООБЩЕ
|
||||
// (display:none у .hdr не считается: скрытый в DOM узел всё ещё кликабелен
|
||||
// программно и всё ещё ловится автотестами как «есть»)
|
||||
const kio = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card', kiosk: true, cycle: 0 });
|
||||
c.hass = window.__mkHass();
|
||||
c.style.cssText = 'position:fixed;left:0;top:0;width:900px;height:700px;z-index:99';
|
||||
document.body.appendChild(c);
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
c.hass = { ...c.hass }; await c.updateComplete;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
out.kioskNoAddButton = !sr.querySelector('.tab.tabadd');
|
||||
c.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(kio)) check(k, v);
|
||||
|
||||
// мобильный вид: ряд вкладок переносится, а не выезжает за карточку
|
||||
await page.setViewportSize({ width: 390, height: 760 });
|
||||
const narrow = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
c.requestUpdate(); await c.updateComplete;
|
||||
const sr = c.shadowRoot || c.renderRoot;
|
||||
const head = sr.querySelector('.head');
|
||||
const btn = sr.querySelector('.tab.tabadd');
|
||||
out.narrowAddPresent = !!btn;
|
||||
out.narrowNoHOverflow = head.scrollWidth <= head.clientWidth + 1
|
||||
? true : `scrollW=${head.scrollWidth} > clientW=${head.clientWidth}`;
|
||||
const cr = sr.querySelector('ha-card').getBoundingClientRect();
|
||||
const br = btn ? btn.getBoundingClientRect() : null;
|
||||
out.narrowAddInsideCard = !br ? 'нет кнопки'
|
||||
: br.right <= cr.right + 1 && br.left >= cr.left - 1 ? true
|
||||
: `btn ${Math.round(br.left)}..${Math.round(br.right)} vs card ${Math.round(cr.left)}..${Math.round(cr.right)}`;
|
||||
out.narrowAddHittable = !br ? 'нет кнопки' : (() => {
|
||||
const hit = sr.elementFromPoint(br.left + br.width / 2, br.top + br.height / 2);
|
||||
return hit && hit.closest && hit.closest('.tab.tabadd') === btn ? true : 'перекрыта';
|
||||
})();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(narrow)) check(k, v);
|
||||
|
||||
await finish(browser, { ...res, ...kio, ...narrow });
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// «Уже загруженные» — прокручиваемый список превью внутри диалога пространства.
|
||||
// Проверяем НЕ наличие узлов в DOM, а ГЕОМЕТРИЮ: scrollable-контейнер как
|
||||
// flex-item в .dialog .body (flex column) схлопывается до бордера (min-height
|
||||
// auto = 0 при overflow != visible), и владелец видит тонкую полоску вместо
|
||||
// сетки миниатюр. Ровно этот баг уже ловили в .candlist (v1.53.1) — смок там
|
||||
// смотрел только на DOM и пропустил его.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: 'desktop', width: 900, height: 1000 },
|
||||
{ name: 'mobile', width: 390, height: 780 },
|
||||
];
|
||||
|
||||
const out = {};
|
||||
for (const vp of VIEWPORTS) {
|
||||
const { page, browser } = await launch({ width: vp.width, height: vp.height }, 1);
|
||||
const res = await page.evaluate(async (mode) => {
|
||||
const o = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const base = c.hass.callWS;
|
||||
const mk = (i) => ({
|
||||
name: `plan${i}.png`,
|
||||
url: `/api/houseplan/content/plans/_/plan${i}.png`,
|
||||
size: 100000 + i * 1000,
|
||||
modified: i,
|
||||
used_by: [],
|
||||
});
|
||||
let serverPlans = [mk(1), mk(2), mk(3), mk(4), mk(5), mk(6)];
|
||||
c.hass = { ...c.hass, callWS: async (m) => {
|
||||
if (m.type === 'houseplan/plans/list') return { plans: serverPlans };
|
||||
if (m.type === 'houseplan/content/sign') {
|
||||
const urls = {}; for (const p of m.paths) urls[p] = p; return { urls };
|
||||
}
|
||||
return base(m);
|
||||
} };
|
||||
|
||||
const openPicker = async () => {
|
||||
c._spaceDialog = { ...c._spaceDialog, source: 'file' };
|
||||
await c.updateComplete;
|
||||
await c._toggleServerPlans();
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
await c.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(() => r()));
|
||||
};
|
||||
const geom = (tag) => {
|
||||
const box = sr().querySelector('.savedplans');
|
||||
const rows = [...sr().querySelectorAll('.savedplan')];
|
||||
o[tag + 'Rows'] = rows.length;
|
||||
if (!box || !rows.length) { o[tag + 'BoxTall'] = false; o[tag + 'FirstVisible'] = false; return; }
|
||||
const b = box.getBoundingClientRect();
|
||||
const r0 = rows[0].getBoundingClientRect();
|
||||
o[tag + 'BoxH'] = Math.round(b.height);
|
||||
// 2-3 ряда миниатюр: ряд ~40px + отступы
|
||||
o[tag + 'BoxTall'] = b.height >= 100;
|
||||
// первая миниатюра целиком внутри контейнера и не нулевая
|
||||
o[tag + 'FirstVisible'] = r0.height >= 32 && r0.top >= b.top - 1 && r0.bottom <= b.bottom + 1;
|
||||
// при 6 планах список не влезает целиком — должна быть прокрутка
|
||||
o[tag + 'Scrolls'] = box.scrollHeight - box.clientHeight > 8;
|
||||
box.scrollTop = 9999;
|
||||
o[tag + 'ScrollWorks'] = box.scrollTop > 8;
|
||||
box.scrollTop = 0;
|
||||
};
|
||||
|
||||
// 1. диалог создания пространства
|
||||
c._openSpaceDialog('create');
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
geom('create');
|
||||
|
||||
// 2. диалог настроек существующего пространства
|
||||
c._spaceDialog = null; await c.updateComplete;
|
||||
c._openSpaceDialog('edit', 'f1');
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
geom('edit');
|
||||
|
||||
// 3. пусто: аккуратная надпись, а не схлопнутая полоска
|
||||
serverPlans = [];
|
||||
c._spaceDialog = { ...c._spaceDialog, pickSaved: false };
|
||||
await c.updateComplete;
|
||||
await openPicker();
|
||||
const empty = sr().querySelector('.savedplans');
|
||||
o.emptyHasText = !!empty && empty.textContent.trim().length > 0;
|
||||
o.emptyTall = !!empty && empty.getBoundingClientRect().height >= 24;
|
||||
o.mode = mode;
|
||||
return o;
|
||||
}, vp.name);
|
||||
for (const [k, v] of Object.entries(res)) out[vp.name + '.' + k] = v;
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 1));
|
||||
checkAll(out, {
|
||||
'desktop.createRows': 6,
|
||||
'desktop.editRows': 6,
|
||||
'desktop.createBoxH': out['desktop.createBoxH'],
|
||||
'desktop.editBoxH': out['desktop.editBoxH'],
|
||||
'desktop.mode': 'desktop',
|
||||
'mobile.createRows': 6,
|
||||
'mobile.editRows': 6,
|
||||
'mobile.createBoxH': out['mobile.createBoxH'],
|
||||
'mobile.editBoxH': out['mobile.editBoxH'],
|
||||
'mobile.mode': 'mobile',
|
||||
});
|
||||
await finish(null);
|
||||
@@ -0,0 +1,243 @@
|
||||
// THE RIM (owner 2026-08-04): «тонкая (1px) чёрная граница по бокам
|
||||
// светящегося сектора, которая также плавно уходит в ноль вместе с самим
|
||||
// градиентом». The owner rejected the «shade instead of light» model of
|
||||
// docs/SUN-CONTRAST.md and asked for this one line instead — because a wedge
|
||||
// of added luminance simply cannot read on white paper, while its BOUNDARY
|
||||
// can (docs/SUN.md, «The rim»).
|
||||
//
|
||||
// Everything this smoke pins down:
|
||||
// * exactly TWO rim lines per wedge, on the two SIDE edges — the ones that
|
||||
// run from the ends of the glass along the ray. Never the glass edge,
|
||||
// never the far edge; the coordinates are checked against the wedge's own
|
||||
// vertices, so a rim drawn around the whole polygon fails here;
|
||||
// * the stroke is `url(#hp-sunrim-N)`, a gradient with BLACK stops on the
|
||||
// SAME axis and the SAME offsets as the fill's `hp-sun-N`, monotone,
|
||||
// dead from 85 % on — the rim cannot outlive the light it outlines;
|
||||
// * `vector-effect: non-scaling-stroke` at width 1 — one screen pixel at
|
||||
// any zoom;
|
||||
// * no rim below the 3° threshold (after the 2 s layer fade) and none in an
|
||||
// editor;
|
||||
// * and the point of the whole change: on WHITE paper the pixels along the
|
||||
// wedge's side are measurably darker than the paper on either hand.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 900, height: 900 }, 2);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' }); // no 2 s layer fade in the way
|
||||
|
||||
const RAY_FADE_MS = 2000;
|
||||
|
||||
const res = await page.evaluate(async (fadeMs) => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const cfg = c._serverCfg;
|
||||
const sp = cfg.spaces.find((s) => s.id === 'f1');
|
||||
// r1 is [[0.04,0.14],[0.55,0.14],[0.55,0.58],[0.04,0.58]] — a west window
|
||||
// at y = 0.30 casts a wedge that stays well inside it, so the unclipped
|
||||
// side edges are the whole story; the second window is deliberately on a
|
||||
// wall the same sun cannot reach, to prove no rim appears without a wedge.
|
||||
sp.openings = [
|
||||
{ id: 'wW', type: 'window', x: 0.04, y: 0.30, angle: 90, length: 0.08 },
|
||||
{ id: 'wE', type: 'window', x: 0.96, y: 0.60, angle: 90, length: 0.08 },
|
||||
];
|
||||
sp.plan_url = null; // plain white paper under the wedge
|
||||
cfg.settings = { ...(cfg.settings || {}), north_deg: 0, bg_mode: 'static', sun_rays: true };
|
||||
c._cfgEpoch++;
|
||||
const upd = async () => { c.requestUpdate(); await c.updateComplete; };
|
||||
const setSun = async (az, el) => {
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon', attributes: { azimuth: az, elevation: el } } } };
|
||||
await upd();
|
||||
};
|
||||
const rimsOf = () => [...sr().querySelectorAll('.sunlayer line.sunrim')];
|
||||
const gradOf = (id) => sr().querySelector('linearGradient[id="' + id + '"]');
|
||||
const stopsOf = (g) => [...g.querySelectorAll('stop')].map((s) => ({
|
||||
off: parseFloat(s.getAttribute('offset')),
|
||||
color: s.getAttribute('stop-color'),
|
||||
a: Number(s.getAttribute('stop-opacity')),
|
||||
}));
|
||||
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
|
||||
|
||||
await setSun(270, 60); // a western sun, square into the west window
|
||||
const rays = c._sunRaysCache.rays;
|
||||
out.oneWedgeDrawn = rays.length === 1;
|
||||
out.wedgeIsUnclipped = rays.length === 1 && rays[0].polys.length === 1
|
||||
&& rays[0].polys[0].length === 4;
|
||||
|
||||
// ---- 1) exactly two rim lines, on the two SIDE edges -------------------
|
||||
const rims = rimsOf();
|
||||
out.rimDrawn = rims.length > 0;
|
||||
out.twoRimsPerWedge = rims.length === rays.length * 2;
|
||||
out.rimStrokeIsAGradient = rims.length > 0
|
||||
&& rims.every((l) => /^url\(#hp-sunrim-\d+\)$/.test(l.getAttribute('stroke') || ''));
|
||||
out.rimIsOnePixelAtAnyZoom = rims.length > 0 && rims.every((l) =>
|
||||
l.getAttribute('vector-effect') === 'non-scaling-stroke'
|
||||
&& Number(l.getAttribute('stroke-width')) === 1);
|
||||
|
||||
// every rim line must BE a side edge: its two ends are vertices of the
|
||||
// wedge polygon, it runs along `dir`, and it starts at an end of the glass
|
||||
out.rimSidesMatchTheWedge = true;
|
||||
out.rimIsNeverTheGlassOrTheFarEdge = true;
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const r = rays[i];
|
||||
const mine = rims.filter((l) => l.getAttribute('stroke') === `url(#hp-sunrim-${i})`);
|
||||
if (mine.length !== 2) { out.rimSidesMatchTheWedge = false; continue; }
|
||||
const verts = r.polys.flat();
|
||||
const isVertex = (p) => verts.some((v) => near(v[0], p[0], 1e-3) && near(v[1], p[1], 1e-3));
|
||||
const ends = mine.map((l) => [
|
||||
[+l.getAttribute('x1'), +l.getAttribute('y1')],
|
||||
[+l.getAttribute('x2'), +l.getAttribute('y2')],
|
||||
]);
|
||||
// the four expected corners: the glass ends and where their rays stop
|
||||
const far = (s) => [s[0] + r.dir[0] * r.len, s[1] + r.dir[1] * r.len];
|
||||
const want = [[r.a, far(r.a)], [r.b, far(r.b)]];
|
||||
for (const [src, tip] of want) {
|
||||
const hit = ends.some(([p, q]) =>
|
||||
(near(p[0], src[0], 1e-3) && near(p[1], src[1], 1e-3)
|
||||
&& near(q[0], tip[0], 1e-3) && near(q[1], tip[1], 1e-3))
|
||||
|| (near(q[0], src[0], 1e-3) && near(q[1], src[1], 1e-3)
|
||||
&& near(p[0], tip[0], 1e-3) && near(p[1], tip[1], 1e-3)));
|
||||
if (!hit) out.rimSidesMatchTheWedge = false;
|
||||
}
|
||||
for (const [p, q] of ends) {
|
||||
if (!isVertex(p) || !isVertex(q)) out.rimSidesMatchTheWedge = false;
|
||||
// parallel to the ray (the glass edge and the far edge are not)
|
||||
const dx = q[0] - p[0], dy = q[1] - p[1];
|
||||
const L = Math.hypot(dx, dy) || 1;
|
||||
if (Math.abs((dx / L) * r.dir[1] - (dy / L) * r.dir[0]) > 1e-6)
|
||||
out.rimIsNeverTheGlassOrTheFarEdge = false;
|
||||
// and it must not be the glass: both ends on the a-b line would mean it
|
||||
const onGlass = (z) => Math.abs((z[0] - r.a[0]) * r.dir[0] + (z[1] - r.a[1]) * r.dir[1]) < 1e-6;
|
||||
if (onGlass(p) && onGlass(q)) out.rimIsNeverTheGlassOrTheFarEdge = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 2) the rim gradient: same axis, same curve, black ------------------
|
||||
const fills = [...sr().querySelectorAll('linearGradient[id^=hp-sun-]')];
|
||||
out.rimGradientBlack = true;
|
||||
out.rimGradientSharesTheAxis = true;
|
||||
out.rimGradientSharesTheCurve = true;
|
||||
out.rimGradientMonotone = true;
|
||||
out.rimGradientDeadAt85 = true;
|
||||
out.rimGradientBrightAtTheGlass = true;
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const rim = gradOf(`hp-sunrim-${i}`);
|
||||
const fill = fills[i];
|
||||
if (!rim || !fill) { out.rimGradientBlack = false; continue; }
|
||||
for (const k of ['x1', 'y1', 'x2', 'y2']) {
|
||||
if (!near(+rim.getAttribute(k), +fill.getAttribute(k), 1e-9)) out.rimGradientSharesTheAxis = false;
|
||||
}
|
||||
const rs = stopsOf(rim);
|
||||
const fs = stopsOf(fill);
|
||||
if (rs.length !== fs.length) out.rimGradientSharesTheCurve = false;
|
||||
const peak = rs[0].a;
|
||||
rs.forEach((s, j) => {
|
||||
const black = /^#0{3,8}$/i.test(s.color || '') || /rgb\(0,\s*0,\s*0\)/.test(s.color || '');
|
||||
if (!black) out.rimGradientBlack = false;
|
||||
if (!fs[j] || !near(s.off, fs[j].off, 1e-9)) out.rimGradientSharesTheCurve = false;
|
||||
// the same normalised curve as the fill, only scaled to its own peak
|
||||
if (fs[j] && !near(s.a / (peak || 1), fs[j].a / (fs[0].a || 1), 1e-3))
|
||||
out.rimGradientSharesTheCurve = false;
|
||||
if (j > 0 && s.a > rs[j - 1].a + 1e-9) out.rimGradientMonotone = false;
|
||||
if (s.off >= 85 - 1e-9 && s.a !== 0) out.rimGradientDeadAt85 = false;
|
||||
if (s.off < 85 - 1e-9 && !(s.a > 0)) out.rimGradientDeadAt85 = false;
|
||||
});
|
||||
if (!(peak >= 0.3 && peak <= 0.55)) out.rimGradientBrightAtTheGlass = false;
|
||||
}
|
||||
|
||||
// ---- 3) screen coords for the pixel probe, off the real geometry --------
|
||||
// sample ACROSS the a-side edge at 22 % of the reach, where both the fill
|
||||
// and the rim are still strong
|
||||
const svgEl = sr().querySelector('.sunlayer')?.ownerSVGElement || sr().querySelector('.stage svg');
|
||||
const ctm = svgEl.getScreenCTM();
|
||||
const toScr = (x, y) => {
|
||||
const q = svgEl.createSVGPoint(); q.x = x; q.y = y;
|
||||
const p = q.matrixTransform(ctm); return [p.x, p.y];
|
||||
};
|
||||
if (rays.length) {
|
||||
const r = rays[0];
|
||||
const u = 0.22 * r.len;
|
||||
const on = toScr(r.a[0] + r.dir[0] * u, r.a[1] + r.dir[1] * u);
|
||||
// one render unit INTO the wedge (toward b) → the screen-space normal
|
||||
const inw = [r.b[0] - r.a[0], r.b[1] - r.a[1]];
|
||||
const iL = Math.hypot(inw[0], inw[1]) || 1;
|
||||
const q = toScr(r.a[0] + r.dir[0] * u + (inw[0] / iL) * 10, r.a[1] + r.dir[1] * u + (inw[1] / iL) * 10);
|
||||
const vx = q[0] - on[0], vy = q[1] - on[1];
|
||||
const vL = Math.hypot(vx, vy) || 1;
|
||||
out.probe = { on, perp: [vx / vL, vy / vL], dpr: window.devicePixelRatio };
|
||||
}
|
||||
|
||||
// ---- 4) below the threshold and in an editor there is no rim -----------
|
||||
await setSun(270, 2); // under RAY_ELEVATION_MIN
|
||||
await new Promise((rs) => setTimeout(rs, fadeMs + 300)); // let the layer leave
|
||||
await upd();
|
||||
out.noRimBelowThreshold = rimsOf().length === 0;
|
||||
out.noWedgeBelowThreshold = sr().querySelectorAll('.sunlayer polygon').length === 0;
|
||||
|
||||
await setSun(270, 60);
|
||||
out.rimIsBackAboveThreshold = rimsOf().length === 2;
|
||||
c._mode = 'plan';
|
||||
await upd();
|
||||
out.noRimInTheEditor = rimsOf().length === 0;
|
||||
c._mode = 'view';
|
||||
await upd();
|
||||
out.rimIsBackInViewMode = rimsOf().length === 2;
|
||||
|
||||
// ---- 5) a fully overcast sky takes the rim with the light --------------
|
||||
cfg.settings = { ...cfg.settings, weather_entity: 'weather.home' };
|
||||
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||
'weather.home': { entity_id: 'weather.home', state: 'pouring', attributes: {} } } };
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.noRimInTheRain = rimsOf().length === 0;
|
||||
delete cfg.settings.weather_entity;
|
||||
c._cfgEpoch++;
|
||||
await upd();
|
||||
out.rimIsBackInTheSun = rimsOf().length === 2;
|
||||
return out;
|
||||
}, RAY_FADE_MS);
|
||||
|
||||
// ---- 6) the whole point: on WHITE paper the side of the wedge is DARK ----
|
||||
const probe = res.probe;
|
||||
delete res.probe;
|
||||
if (probe) {
|
||||
const shot = (await page.screenshot()).toString('base64');
|
||||
const px = await page.evaluate(async ({ shot, probe }) => {
|
||||
const img = new Image();
|
||||
img.src = 'data:image/png;base64,' + shot;
|
||||
await img.decode();
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = img.width; cv.height = img.height;
|
||||
const ctx = cv.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const d = probe.dpr || 1;
|
||||
const lum = (t) => {
|
||||
const x = Math.round((probe.on[0] + probe.perp[0] * t) * d);
|
||||
const y = Math.round((probe.on[1] + probe.perp[1] * t) * d);
|
||||
const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
};
|
||||
// t > 0 goes INTO the wedge, t < 0 onto the untouched paper beside it
|
||||
const band = [];
|
||||
for (let t = -2.5; t <= 2.5; t += 0.25) band.push(lum(t));
|
||||
const darkest = Math.min(...band);
|
||||
const inside = Math.min(lum(7), lum(9), lum(11)); // lit floor, still bright
|
||||
const outside = Math.min(lum(-7), lum(-9), lum(-11)); // plain white paper
|
||||
return {
|
||||
paperIsLight: outside > 200,
|
||||
wedgeInteriorIsLight: inside > 200,
|
||||
rimIsDarkOnTheEdge: darkest < Math.min(inside, outside) - 25,
|
||||
rimProfile: [Math.round(darkest), Math.round(inside), Math.round(outside)],
|
||||
};
|
||||
}, { shot, probe });
|
||||
res.rimProfile = px.rimProfile;
|
||||
delete px.rimProfile;
|
||||
Object.assign(res, px);
|
||||
} else {
|
||||
res.paperIsLight = false;
|
||||
res.wedgeInteriorIsLight = false;
|
||||
res.rimIsDarkOnTheEdge = false;
|
||||
}
|
||||
const profile = res.rimProfile;
|
||||
delete res.rimProfile;
|
||||
console.log('rim/inside/outside luminance:', JSON.stringify(profile));
|
||||
await finish(browser, checkAll(res));
|
||||
@@ -0,0 +1,120 @@
|
||||
// DEV-B703-03: «карточка перезагружается при возврате на вкладку» — часть 2.
|
||||
// Тёплый ре-маунт (v1.58.0) убрал вуаль, но не сохранял ни ПАН, ни зум
|
||||
// редактора, ни открытые диалоги: Lovelace пересоздаёт элемент, состояние
|
||||
// диалога живёт в экземпляре и умирает вместе с ним. Смок воспроизводит
|
||||
// пересоздание (remove + create на той же странице) и требует:
|
||||
// 1) вид восстановлен БИТ-В-БИТ — ни один кадр не отличается от прежнего;
|
||||
// 2) открытый диалог пережил пересоздание вместе с черновиком;
|
||||
// 3) осознанно закрытый (Esc) — НЕ воскресает;
|
||||
// 4) подтверждение «Выровнять всё» — НЕ воскресает никогда;
|
||||
// 5) воскрешение одноразовое: третий экземпляр диалога уже не видит.
|
||||
// ПАДАЕТ на сборке до DEV-B703-03 (вид рецентрировался, диалоги терялись).
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 });
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
customElements.get('houseplan-card')?._warmBootReset?.();
|
||||
localStorage.removeItem('houseplan_card_zoom_v1');
|
||||
localStorage.removeItem('houseplan_card_nav_v1');
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;left:0;top:0;width:800px;z-index:99;background:#000';
|
||||
document.body.appendChild(wrap);
|
||||
const mk = () => {
|
||||
const c = document.createElement('houseplan-card');
|
||||
c.setConfig({ type: 'custom:houseplan-card' }); // Lovelace: setConfig ДО вставки
|
||||
c.hass = window.__mkHass();
|
||||
wrap.appendChild(c);
|
||||
return c;
|
||||
};
|
||||
const settle = async (c) => {
|
||||
const t0 = performance.now();
|
||||
while (c._booting && performance.now() - t0 < 2500) await sleep(30);
|
||||
await sleep(350);
|
||||
};
|
||||
const rect = (c) => (c._view ? [c._view.x, c._view.y, c._view.w, c._view.h] : null);
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
/** покадрово: ни один кадр после пересоздания не отличается от эталона */
|
||||
const watchView = async (c, zoom0, v0, ms = 700) => {
|
||||
const bad = [];
|
||||
const t1 = performance.now();
|
||||
await new Promise((done) => {
|
||||
const s = () => {
|
||||
if (Math.abs(c._zoom - zoom0) > 1e-6 || !same(rect(c), v0)) {
|
||||
bad.push({ t: Math.round(performance.now() - t1), zoom: c._zoom, v: rect(c) });
|
||||
}
|
||||
if (performance.now() - t1 < ms) requestAnimationFrame(s); else done();
|
||||
};
|
||||
requestAnimationFrame(s);
|
||||
});
|
||||
return bad.length === 0 ? true : `кадр ${bad[0].t}мс: zoom=${bad[0].zoom} view=${JSON.stringify(bad[0].v)} (ждали ${zoom0} / ${JSON.stringify(v0)})`;
|
||||
};
|
||||
|
||||
// ================= A. просмотр: пан+зум и настройки пространства ==========
|
||||
let c = mk();
|
||||
await settle(c);
|
||||
c._applyView(2.4, 260, 720); // зум в угол — вид точно не по центру
|
||||
c._saveZoom();
|
||||
c.requestUpdate(); await c.updateComplete; await sleep(100);
|
||||
const zoomA = c._zoom, viewA = rect(c);
|
||||
out.aPanned = viewA[0] > 1 || viewA[1] > 1; // sanity: вид действительно смещён
|
||||
c._openSpaceDialog('edit', c._space); await c.updateComplete;
|
||||
c._spaceDialog = { ...c._spaceDialog, title: 'ЧЕРНОВИК-42' }; // недосохранённая правка
|
||||
await c.updateComplete;
|
||||
out.aDialogOpenBefore = !!c._spaceDialog;
|
||||
|
||||
c.remove(); await sleep(20); // ← Lovelace выбрасывает элемент
|
||||
c = mk(); // ← и создаёт новый
|
||||
out.aViewBitExact = await watchView(c, zoomA, viewA);
|
||||
out.aDialogSurvived = !!c._spaceDialog;
|
||||
out.aDraftSurvived = c._spaceDialog?.title === 'ЧЕРНОВИК-42';
|
||||
out.aStillSameSpaceMode = c._mode === 'view';
|
||||
|
||||
// ---- воскрешение одноразовое: ещё одно пересоздание диалог не вернёт ----
|
||||
c._spaceDialog = null; await c.updateComplete; // (осознанно закрыли)
|
||||
c.remove(); await sleep(20);
|
||||
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
|
||||
out.aNoZombieAfterClose = !c._spaceDialog;
|
||||
|
||||
// ================= B. редактор устройств: зум редактора + карточка =======
|
||||
c._setMode('devices'); await c.updateComplete; await sleep(120);
|
||||
c._applyView(3.4, 430, 380); c.requestUpdate(); await c.updateComplete; await sleep(100);
|
||||
const zoomB = c._zoom, viewB = rect(c);
|
||||
const dev = c._devices.find((d) => d.space === c._space);
|
||||
c._openMarkerDialog(dev); await c.updateComplete;
|
||||
c._markerDialog = { ...c._markerDialog, name: 'ИМЯ-ЧЕРНОВИК' };
|
||||
await c.updateComplete;
|
||||
out.bDialogOpenBefore = !!c._markerDialog;
|
||||
|
||||
c.remove(); await sleep(20);
|
||||
c = mk();
|
||||
out.bModeRestored = c._mode === 'devices';
|
||||
out.bViewBitExact = await watchView(c, zoomB, viewB);
|
||||
out.bDialogSurvived = !!c._markerDialog;
|
||||
out.bDraftSurvived = c._markerDialog?.name === 'ИМЯ-ЧЕРНОВИК';
|
||||
|
||||
// ================= C. Esc = осознанное закрытие ==========================
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
await c.updateComplete; await sleep(30);
|
||||
out.cClosedByEsc = !c._markerDialog;
|
||||
c.remove(); await sleep(20);
|
||||
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
|
||||
out.cNoResurrectionAfterEsc = !c._markerDialog;
|
||||
|
||||
// ================= D. «Выровнять всё» не воскресает НИКОГДА ==============
|
||||
c._setMode('view'); await c.updateComplete; await sleep(60);
|
||||
c._alignDialog = { report: { moved: 3, maxShiftCm: 2, maxSpace: c._space }, spaces: [], layout: {}, cm: 2, where: '', busy: false };
|
||||
await c.updateComplete;
|
||||
out.dAlignOpenBefore = !!c._alignDialog;
|
||||
c.remove(); await sleep(20);
|
||||
c = mk(); await sleep(120); await c.updateComplete; await sleep(60);
|
||||
out.dAlignNotRevived = !c._alignDialog;
|
||||
|
||||
c.remove(); wrap.remove();
|
||||
return out;
|
||||
});
|
||||
for (const [k, v] of Object.entries(res)) check(k, v);
|
||||
await finish(browser, res);
|
||||
+168
-151
File diff suppressed because one or more lines are too long
Vendored
+168
-151
File diff suppressed because one or more lines are too long
@@ -1,5 +1,80 @@
|
||||
# Changelog
|
||||
|
||||
## v1.59.0-beta.1 — 2026-08-04
|
||||
|
||||
Minor pre-release: the card survives a Lovelace re-mount bit-for-bit —
|
||||
your pan, your zoom and even the dialog you had open come back with it —
|
||||
sun rays get a hairline edge so they read on white paper, the «+» that
|
||||
adds a space leaves the Plan editor for the tab row, and the list of
|
||||
plans already uploaded to the server stops collapsing into a stripe.
|
||||
|
||||
- **The card no longer twitches — and no longer loses your open dialog —
|
||||
when you come back to the tab (docs/WARM-REMOUNT.md).** Lovelace
|
||||
re-creates the card element on a websocket reconnect; v1.58.0 removed
|
||||
the preloader flash, but the new instance still had to guess what the
|
||||
dead one had been looking at. Measured: the PAN never left the instance
|
||||
(a view parked in a corner came back re-centred: x=50 → x=250 at
|
||||
zoom 2.2), and the EDITOR zoom is deliberately not persisted while the
|
||||
editor MODE is — so a re-mount inside an editor came back at 1.0
|
||||
instead of 3.0. The warm memo now carries the whole viewport (space,
|
||||
mode, zoom, the `_view` rect itself, the view-mode snapshot, the
|
||||
«show far objects» frame, the selected tool and selection, the local
|
||||
«show hidden» toggle), so the restore is the same RECTANGLE, not the
|
||||
same zoom number — bit-for-bit, verified frame by frame.
|
||||
|
||||
- **An open dialog survives the re-creation with its draft.** The memo is
|
||||
module state and is never serialised, so the live draft object — a
|
||||
half-filled device dialog with its uploaded PDFs included — moves over
|
||||
for free. The rule is «revive the draft, never revive the decision»:
|
||||
the confirmations for «Align everything to the grid» and for a room
|
||||
merge are deliberately NOT restored (a modal whose whole content is
|
||||
«press OK to rewrite your plan» must not greet a returning user), nor
|
||||
is a tap confirmation (it closes over the dead instance), nor the floor
|
||||
import wizard (it reopens itself), nor any dialog with a save in flight.
|
||||
Revival requires the same space and the same mode, happens at most once
|
||||
(the snapshot is consumed), and only if the previous instance died
|
||||
within the last 10 s. A dialog closed on purpose — Esc, Cancel or
|
||||
Save — writes `null` into the memo on the very next render, so it can
|
||||
never come back (smoke_warm_dialogs).
|
||||
|
||||
- **Sun rays get an edge (docs/SUN.md, «The rim»).** On a white plan a
|
||||
wedge of light was nearly invisible, and no amount of opacity could fix
|
||||
it: painting light means adding luminance, and white paper has none
|
||||
left to give. Every lit wedge now carries a 1 px black hairline along
|
||||
its two SIDE edges — the ones running inward from the ends of the
|
||||
window. It fades to nothing on exactly the same axis, the same curve
|
||||
and the same 85 % threshold as the fill (a second gradient built on the
|
||||
fill's own stops), stays one screen pixel at any zoom
|
||||
(`non-scaling-stroke`), is cut by the room like the wedge itself, and
|
||||
lives in the same layer — so the 3° threshold, the two-second fade,
|
||||
cloud cover, night and the editors govern it without a line of extra
|
||||
logic. Peak opacity 0.42, picked against a white sheet and the dark
|
||||
glow canvas alike. The «shade instead of light» model of
|
||||
docs/SUN-CONTRAST.md was rejected in favour of this; that file now
|
||||
records the decision and keeps the analysis behind it.
|
||||
|
||||
- **The «+» that adds a space is not an editor tool.** The button next to
|
||||
the floor names only existed inside the Plan editor, so adding a second
|
||||
floor meant first opening an editor you did not want. Adding a space is
|
||||
navigation, not markup: the «+» now sits in the tab row in every mode —
|
||||
View and all three editors — exactly where the per-space gear already
|
||||
lives, and under the same admin rule. The kiosk is a shop window: the
|
||||
button is not rendered there at all (its header is `display:none`, and a
|
||||
hidden-but-present node is still clickable from script). The tab row
|
||||
wraps as before at 390 px (smoke_gear_tabs measures the overflow).
|
||||
|
||||
- **«Already uploaded» shows the plans again.** In both space dialogs
|
||||
(new space and space settings) the list of plans stored on the server
|
||||
collapsed into a thin rounded stripe: the rows were rendered, the box
|
||||
itself was 14 px tall. A scrolling box is a flex item whose automatic
|
||||
minimum size is zero, and the dialog body is a flex column with a
|
||||
66 vh cap — so the picker was the one child that could be squeezed to
|
||||
nothing. It no longer shrinks and keeps a floor of its own; up to five
|
||||
thumbnails are visible at once and the rest scroll. The empty and
|
||||
loading states stay readable instead of clipping their own text
|
||||
(smoke_plan_picker measures the heights — the old smoke only counted
|
||||
DOM nodes and passed).
|
||||
|
||||
## v1.58.0 — 2026-08-04
|
||||
|
||||
Minor release: the backdrop picture becomes a movable, scalable object,
|
||||
|
||||
@@ -6,6 +6,84 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.59.0-beta.1 — 2026-08-04
|
||||
|
||||
Минорный пре-релиз: карточка переживает пересоздание в Lovelace
|
||||
бит-в-бит — панорамирование, зум и даже открытый диалог возвращаются
|
||||
вместе с ней, — у солнечных лучей появляется тонкая кромка, за счёт
|
||||
которой их видно на белой бумаге, «+» для добавления пространства
|
||||
уходит из редактора Плана в ряд вкладок, а список уже загруженных на
|
||||
сервер планов перестаёт схлопываться в полоску.
|
||||
|
||||
- **Карточка больше не дёргается — и не теряет открытый диалог — при
|
||||
возврате на вкладку (docs/WARM-REMOUNT.md).** Lovelace пересоздаёт
|
||||
элемент карточки при переподключении websocket; v1.58.0 убрала мигание
|
||||
прелоадера, но новому экземпляру всё ещё приходилось угадывать, на что
|
||||
смотрел умерший. Измерено: ПАН не переживал экземпляр вообще (вид,
|
||||
отъеханный в угол, возвращался в центр: x=50 → x=250 при зуме 2.2), а
|
||||
зум РЕДАКТОРА намеренно не сохраняется, тогда как РЕЖИМ редактора —
|
||||
сохраняется; поэтому ре-маунт внутри редактора возвращался на 1.0
|
||||
вместо 3.0. Тёплая памятка теперь несёт весь вьюпорт (пространство,
|
||||
режим, зум, сам прямоугольник `_view`, снимок вида для выхода из
|
||||
редактора, кадр «показать дальние», выбранный инструмент и выделение,
|
||||
локальный переключатель «показать скрытые») — восстанавливается тот же
|
||||
ПРЯМОУГОЛЬНИК, а не то же число зума: бит-в-бит, проверено покадрово.
|
||||
|
||||
- **Открытый диалог переживает пересоздание вместе с черновиком.**
|
||||
Памятка — состояние модуля, её никто не сериализует, поэтому живой
|
||||
объект-черновик (в том числе наполовину заполненный диалог устройства с
|
||||
уже загруженными PDF) переезжает бесплатно. Правило: **воскрешаем
|
||||
черновик, не воскрешаем решение** — подтверждения «Выровнять всё по
|
||||
сетке» и объединения комнат намеренно НЕ восстанавливаются (модалка, всё
|
||||
содержимое которой «нажмите OK, и я перепишу ваш план», не должна
|
||||
встречать вернувшегося пользователя), как и подтверждение действия по
|
||||
тапу (оно держит замыкание на мёртвый экземпляр), мастер импорта этажей
|
||||
(он открывается сам) и любой диалог с записью в полёте. Воскрешение
|
||||
требует того же пространства и того же режима, происходит не более
|
||||
одного раза (снимок съедается) и только если прошлый экземпляр умер не
|
||||
раньше чем 10 с назад. Осознанно закрытый диалог — Esc, «Отмена» или
|
||||
«Сохранить» — записывает в памятку `null` на ближайшей же отрисовке,
|
||||
поэтому вернуться уже не может (smoke_warm_dialogs).
|
||||
|
||||
- **У солнечных лучей появилась кромка (docs/SUN.md, «The rim»).** На
|
||||
белом плане клин света был почти не виден, и прозрачностью это не
|
||||
лечится: рисовать свет — значит добавлять яркость, а у белой бумаги её
|
||||
больше нет. Теперь у каждого клина по двум БОКОВЫМ рёбрам — тем, что
|
||||
уходят вглубь комнаты от концов окна, — идёт тонкая чёрная линия в
|
||||
1 px. Она гаснет ровно вместе с заливкой: та же ось градиента, та же
|
||||
кривая, тот же порог 85 % (второй градиент собран на стопах первого),
|
||||
остаётся ровно в один экранный пиксель при любом зуме
|
||||
(`non-scaling-stroke`), обрезается комнатой так же, как сам клин, и
|
||||
живёт в том же слое — поэтому порог 3°, двухсекундная анимация,
|
||||
облачность, ночь и редакторы управляют ею сами, без единой строки
|
||||
отдельной логики. Максимальная непрозрачность 0.42 — подобрана и на
|
||||
белом листе, и на тёмном холсте glow. Модель «тень вместо света» из
|
||||
docs/SUN-CONTRAST.md отклонена в пользу этой кромки; тот файл теперь
|
||||
фиксирует решение и хранит анализ, из которого оно выросло.
|
||||
|
||||
- **«+» для добавления пространства — не инструмент редактора.** Кнопка
|
||||
рядом с названиями этажей существовала только внутри редактора Плана,
|
||||
поэтому добавить второй этаж означало сначала открыть редактор, который
|
||||
тебе не нужен. Добавление пространства — это навигация, а не разметка:
|
||||
теперь «+» стоит в ряду вкладок во ВСЕХ режимах — в Просмотре и во всех
|
||||
трёх редакторах, ровно там же, где уже живёт шестерёнка пространства, и
|
||||
по тому же правилу (только администратор). Киоск — витрина: там кнопка
|
||||
не отрисовывается вовсе (шапка у него `display:none`, а скрытый, но
|
||||
присутствующий узел всё ещё кликабелен из скрипта). Ряд вкладок
|
||||
по-прежнему переносится на 390 px (smoke_gear_tabs меряет переполнение).
|
||||
|
||||
- **«Уже загруженные» снова показывают планы.** В обоих диалогах
|
||||
пространства (создание и настройки) список планов, сохранённых на
|
||||
сервере, схлопывался в тонкую скруглённую полоску: строки
|
||||
отрисовывались, а сам контейнер имел высоту 14 px. Прокручиваемый
|
||||
блок — это flex-элемент с автоматическим минимальным размером ноль, а
|
||||
тело диалога — flex-колонка с потолком 66 vh, так что сжать до нуля
|
||||
можно было именно список. Теперь он не сжимается и держит собственный
|
||||
минимум: одновременно видно до пяти миниатюр, остальные прокручиваются.
|
||||
Состояния «пусто» и «загрузка» больше не режут собственный текст
|
||||
(smoke_plan_picker меряет высоты — старый смок считал только узлы DOM
|
||||
и проходил).
|
||||
|
||||
## v1.58.0 — 2026-08-04
|
||||
|
||||
Минорный релиз: картинка-подложка становится объектом, который можно
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.58.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable release, tag `v1.58.0` on `main`, GitHub Release with `prerelease=false`; **no direct deploy** — the home instance picks it up through HACS by tag. 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; the two P2 fixes from the beta.1 audit of «Выровнять всё по сетке» — the confirmation now promises a true upper bound measured per space in its own `cell_cm` (AUD-158B1-01), and an opening whose only error is its angle is a real change that can be applied (AUD-158B1-02). Shipped through v1.58.0-beta.1 (pre-release, 2026-08-04). Previous stable: v1.57.0 "the infinite canvas" |
|
||||
| Version | **v1.59.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.1` on **`dev`**, 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. Contents: **warm re-mount** (docs/WARM-REMOUNT.md — a Lovelace re-creation of the card element restores the whole viewport bit-for-bit: space, mode, zoom, the `_view` rect, the view-mode snapshot, the far-objects frame, the tool, the selection and the local «show hidden»; an open dialog is revived WITH its live draft, while destructive confirmations, tap confirmations, the floor-import wizard and any dialog with a save in flight are deliberately not revived; same space + same mode, at most once, within 10 s); **the sun-ray rim** (docs/SUN.md «The rim» — a 1 px black hairline on the two SIDE edges of every lit wedge, fading on the same axis/curve/85 % threshold as the fill, `non-scaling-stroke`, peak alpha 0.42 — light finally reads on white paper; the «shade instead of light» model of docs/SUN-CONTRAST.md was REJECTED and that file now records the decision); the «+» that adds a space moved out of the Plan editor into the tab row in **every** mode (admin-only, absent in kiosk); and the «Already uploaded» plan list no longer collapses to 14 px (a scrolling flex child needs `min-height:0` overridden — docs/TESTING.md now requires new scrollable dialog containers to be smoke-tested BY GEOMETRY, not by node count). Previous stable: v1.58.0 "the backdrop you can move" |
|
||||
|
||||
| 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) |
|
||||
|
||||
+53
-93
@@ -1,10 +1,16 @@
|
||||
# Sunlight that reads on a light plan — the spec
|
||||
# Sunlight that reads on a light plan — the rejected model
|
||||
|
||||
Status: **approved in principle, not implemented.** Planned for the release
|
||||
after v1.58.0. Owner asked for the spec first (2026-08-04); nothing in this
|
||||
document is code yet. Builds on docs/SUN.md, which describes what ships today.
|
||||
Status: **REJECTED by the owner on 2026-08-04.** The model below was
|
||||
approved in principle a few hours earlier and never became code. The owner
|
||||
cancelled it in favour of one line out of it — the rim — which IS
|
||||
implemented and specified in docs/SUN.md, section «The rim».
|
||||
|
||||
## The problem
|
||||
This file is kept for one reason: the analysis of *why* a wedge of light
|
||||
cannot read on white paper is correct, it is what the rim answers, and it
|
||||
is the first thing anyone will have to re-derive the next time somebody
|
||||
proposes «просто сделай лучи поярче».
|
||||
|
||||
## The problem (this part still holds)
|
||||
|
||||
Today a lit window casts a warm translucent wedge (docs/SUN.md). It reads
|
||||
beautifully on a dark scene — glow fill, night, a dark theme — and almost
|
||||
@@ -14,100 +20,54 @@ makes a clear day **white**, and a hand-drawn plan's paper is white too.
|
||||
The cause is not opacity, it is physics. Painting light means adding
|
||||
luminance, and white has none left to give. Raising the alpha does not add
|
||||
contrast, it only tints the paper towards beige until the whole room looks
|
||||
dirty. Any fix has to take its contrast from something other than brightness.
|
||||
dirty. Any fix has to take its contrast from something other than
|
||||
brightness.
|
||||
|
||||
## The model: paint the shade, not the light
|
||||
## What was proposed: paint the shade, not the light
|
||||
|
||||
On a light scene the wedges invert. The lit areas stay untouched paper; the
|
||||
**rest of the room** gets a light, cool veil. Contrast now comes from the
|
||||
unlit part, which has plenty of room to go darker, and the wedge becomes a
|
||||
window of clean paper inside a softly shaded room.
|
||||
On a light scene the wedges would invert. The lit areas stay untouched
|
||||
paper; the **rest of the room** gets a light, cool veil, so the contrast
|
||||
comes from the unlit part, which has plenty of room to go darker, and the
|
||||
wedge becomes a window of clean paper inside a softly shaded room. This is
|
||||
how architects draw insolation on white sheets, and it is physically
|
||||
honest: a sunlit room really is brighter where the shaft lands.
|
||||
|
||||
This is how architects draw insolation on white sheets, and it stays
|
||||
physically honest: a sunlit room really is brighter where the shaft lands.
|
||||
It also rhymes with the mode we already have — glow paints a dark house with
|
||||
pools of lamp light; this paints a light house with the shade between shafts.
|
||||
Geometrically it is the same wedge polygon used as a hole — a veil over the
|
||||
room's polygon, minus the wedges (even-odd, or a mask). `computeSunRays()`
|
||||
would not change at all; only what we do with the result. Around it the
|
||||
spec needed: a luminance threshold to choose the model automatically, a
|
||||
cross-fade whenever that threshold is crossed (theme switch, day turning to
|
||||
night), a rule keeping the veil *under* the room fills so temperature and
|
||||
LQI colours keep their identity, and an open question about offering
|
||||
«свет / тень / авто» explicitly.
|
||||
|
||||
Geometrically it is the same wedge polygon, used as a hole: a veil rectangle
|
||||
over the room's polygon, minus the wedges (an even-odd path, or a mask). The
|
||||
maths of `computeSunRays()` does not change at all — only what we do with the
|
||||
result.
|
||||
**Why the owner dropped it.** It is a second rendering model for one
|
||||
feature: two code paths, an automatic chooser that must be a pure function
|
||||
of a background nobody controls, a cross-fade between them, and a veil that
|
||||
has to coexist with the meaning-carrying room fills without being mistaken
|
||||
for one — all to solve a problem that a single hairline solves.
|
||||
|
||||
## Switching between the two
|
||||
The spec's other amplifier — a mark on the lit window itself (a thicker
|
||||
warm stroke plus a short arrow along the sun direction, answering «в какие
|
||||
окна сейчас светит солнце» without reading the floor) — was not rejected on
|
||||
its merits. It is simply out of scope; this paragraph is where the idea
|
||||
stays written down.
|
||||
|
||||
Automatic, by the luminance of what the wedge is drawn on (the paper under
|
||||
the room, or the scene colour where a picture backdrop shows through):
|
||||
## What was implemented instead (2026-08-04)
|
||||
|
||||
- luminance below the threshold → **light model** (today's warm wedge);
|
||||
- above it → **shade model** (veil with wedge-shaped holes).
|
||||
A **rim**: a 1 px black hairline along the two SIDE edges of the wedge,
|
||||
fading to zero on exactly the same gradient axis, the same curve and the
|
||||
same 85 % threshold as the fill, clipped by the room like the wedge itself,
|
||||
and living inside the same layer — so the 3° threshold, the two-second
|
||||
fade, cloud cover and the editors govern it for free.
|
||||
|
||||
The threshold sits around mid-grey; the exact value is a visual decision to be
|
||||
made against real plans, dark and light themes, and the day/night palette.
|
||||
Whether this is fully automatic or also offered as an explicit choice is an
|
||||
open question (below).
|
||||
Light is invisible on white paper; its boundary is not. A boundary costs
|
||||
one stroke, works on any background, and needs no second model, no
|
||||
luminance threshold and no cross-fade.
|
||||
|
||||
## Two amplifiers, both models
|
||||
|
||||
**A rim on the wedge.** Light is invisible but its boundary is not: a hairline
|
||||
warm stroke along the two side edges of the shaft, fading out with the shaft
|
||||
itself. It costs one stroke, works on any background, and gives the shape a
|
||||
"beam" reading even when the fill is nearly transparent. In the shade model
|
||||
the same rim marks the edge of the veil hole.
|
||||
|
||||
**A mark on the window.** Independent of the wedges: a lit window gets a
|
||||
thicker warm stroke and a short arrow along the sun direction. It answers the
|
||||
question "which windows is the sun in right now" without reading the floor,
|
||||
and it survives everything the wedge does not — furniture-heavy plans, room
|
||||
fills, small kiosk scale, a wedge clipped to almost nothing by a narrow room.
|
||||
|
||||
## Colour
|
||||
|
||||
The veil is cool and desaturated (a blue-grey), never black: a black veil
|
||||
reads as a printing defect and fights the room fills. Its opacity is small —
|
||||
the target is a perceptible step, not a dimmed room; the value is to be tuned
|
||||
visually, in the same order as the wedge alpha today.
|
||||
|
||||
The light model keeps its warm ramp near the horizon (docs/SUN.md), but should
|
||||
lean on **hue rather than luminance** on medium backgrounds: an amber tint
|
||||
reads as colour even where it cannot read as brightness.
|
||||
|
||||
## What must not break
|
||||
|
||||
- **Room fills.** Temperature and LQI fills carry meaning; the veil must not
|
||||
be mistaken for them, and must not shift their perceived colour more than
|
||||
marginally. The veil goes under the fills, not over.
|
||||
- **The 3° threshold and the two-second fade** (docs/SUN.md) apply unchanged
|
||||
to whichever model is active. Crossing the luminance threshold — a theme
|
||||
switch, day turning to night — must cross-fade between models, never pop.
|
||||
- **Cloudiness** keeps dimming the effect, in the shade model by thinning the
|
||||
veil.
|
||||
- **Editors** stay neutral, as today: no sun, no day/night.
|
||||
- **The static card and kiosk** must pick the same model as the full card on
|
||||
the same plan; the choice is a pure function of the background.
|
||||
- **prefers-reduced-motion**: no cross-fade, switch instantly.
|
||||
- **Performance**: one veil path per room per render at most, memoised on the
|
||||
same key as the wedges.
|
||||
|
||||
## Open questions for the owner
|
||||
|
||||
1. Automatic switching only, or also an explicit setting «свет / тень / авто»
|
||||
in the space dialog? (Automatic is one less knob; explicit lets someone on
|
||||
a light theme keep the warm wedges if they like them.)
|
||||
2. Does the veil cover the whole room, or only the part of the room the sun
|
||||
could reach at all (the room's window-facing side)? Whole-room is simpler
|
||||
and reads as "this room is in shade"; partial is subtler.
|
||||
3. Should the window mark be always on, or follow the same «Solar rays»
|
||||
toggle?
|
||||
|
||||
## Testing notes (for when this is built)
|
||||
|
||||
Unit: the model chooser is a pure function of a colour → assert the switch at
|
||||
the threshold, both sides, plus junk input. The veil-with-holes geometry:
|
||||
holes equal the wedges, an unlit room gets a plain veil, a room with no
|
||||
exterior window gets nothing.
|
||||
|
||||
Browser: on a white paper plan a lit wedge is measurably lighter than its
|
||||
surroundings (sample pixels inside and outside the wedge — the current
|
||||
implementation would fail this, which is the point); on a dark scene nothing
|
||||
changes versus today; crossing the threshold cross-fades; room fills keep
|
||||
their identity; the window mark appears exactly on lit windows.
|
||||
Full contract and the tuned peak opacity: **docs/SUN.md, «The rim»**.
|
||||
Implementation: `rayRimEdges()`, `rimStops()`, `rimPeakAlpha()` and
|
||||
`RIM_MAX_ALPHA` in `src/sun.ts`, plus the `hp-sunrim-N` gradient in
|
||||
`src/houseplan-card.ts`. Browser proof, including the pixel probe showing
|
||||
the wedge's side is measurably darker than the paper beside it:
|
||||
`demo/smoke_sun_rim.mjs`.
|
||||
|
||||
+56
-2
@@ -219,6 +219,48 @@ left to draw an edge with.
|
||||
|
||||
Clipping by the room is unchanged; only the visible edge changed.
|
||||
|
||||
### The rim — a hairline along the sides (owner 2026-08-04)
|
||||
|
||||
The fill above is honest and nearly invisible on a light plan. Painting
|
||||
light means ADDING luminance, and white paper has none left to give:
|
||||
raising `RAY_MAX_ALPHA` does not buy contrast, it only tints the room
|
||||
beige. That analysis is kept in docs/SUN-CONTRAST.md, whose «shade
|
||||
instead of light» answer the owner **rejected** on 2026-08-04 in favour
|
||||
of its cheap half, verbatim: «тонкая (1px) чёрная граница по бокам
|
||||
светящегося сектора, которая также плавно уходит в ноль вместе с самим
|
||||
градиентом». Light is invisible on paper; its BOUNDARY is not.
|
||||
|
||||
The contract:
|
||||
|
||||
- **Two side edges only.** The rim runs along the two edges that leave
|
||||
the ends of the glass and travel inward with the ray — `a → a+dir·len`
|
||||
and `b → b+dir·len`. Never the glass edge `a-b` (that is the source,
|
||||
not a boundary) and never the far edge (there is nothing left to
|
||||
outline there — the fill is already at zero, see below).
|
||||
- **One screen pixel at any zoom**: `stroke-width="1"` plus
|
||||
`vector-effect="non-scaling-stroke"`, so the hairline is a hairline on
|
||||
a phone, on a 4K kiosk and at any zoom level of the infinite canvas.
|
||||
- **Black**, and it dies exactly with the fill. A second gradient
|
||||
`hp-sunrim-N` is emitted next to `hp-sun-N` with **the same
|
||||
`x1,y1,x2,y2`** (the wall's inward normal, `depth` long) and **the same
|
||||
normalised curve** — `rimStops()` returns `rayStops()` by identity, not
|
||||
by copy, so the two can never drift apart. Only the colour and the peak
|
||||
differ: `RIM_MAX_ALPHA` = 0.42 at the glass, tuned on the demo rig
|
||||
against both extremes (below ~0.3 the line vanishes on paper at kiosk
|
||||
scale, above ~0.5 it reads as an ink contour over the dark glow
|
||||
canvas). Zero from `RAY_FADE_END` = 85 % on, like the fill.
|
||||
- **Clipped by the room like the wedge**, and for free: `rayRimEdges()`
|
||||
cuts the sides out of the ALREADY clipped polygons — a boundary segment
|
||||
belongs to a side iff both of its ends lie on that side's line —
|
||||
merging collinear pieces so an unclipped wedge yields exactly two
|
||||
lines. No `clip-path` enters the sun layer, and light still cannot
|
||||
cross a wall.
|
||||
- **The same life as the wedge.** The rim lives inside the same
|
||||
`<g class="sunlayer">`, so the 3° threshold, the 2 s layer fade,
|
||||
`prefers-reduced-motion`, cloud cover (`rimPeakAlpha(cloud)`, zero in
|
||||
the rain), night, the editors and the memo key all apply to it without
|
||||
a line of extra logic.
|
||||
|
||||
### The 3° threshold and the 2-second fade
|
||||
|
||||
Wedge opacity does NOT depend on elevation any more — the old ramp-in
|
||||
@@ -284,8 +326,8 @@ Backend validation: string or null.
|
||||
## Files
|
||||
|
||||
- `src/sun.ts` — pure logic (angles, day phase, exterior walls, wedge
|
||||
quads + clipping, cloud factor, settings inheritance); unit-tested
|
||||
in `test/sun.test.mjs`.
|
||||
quads + clipping, the rim edges and its stops, cloud factor, settings
|
||||
inheritance); unit-tested in `test/sun.test.mjs`.
|
||||
- `src/houseplan-card.ts` — the memoised wedge layer, the day/night
|
||||
stage background, both settings dialogs (compass dial included).
|
||||
- `src/space-render.ts` — the static card's background only.
|
||||
@@ -301,8 +343,20 @@ Backend validation: string or null.
|
||||
DEV-EB173-01 grazing repro end to end (west window 80, elevation 90,
|
||||
azimuth 190): equal sides of the nominal length, peak alpha at BOTH
|
||||
ends of the glass, and no wedge at all below `RAY_MIN_COS`.
|
||||
- `demo/smoke_sun_rim.mjs` — the rim: exactly two lines per wedge, on the
|
||||
two SIDE edges (their coordinates checked against the wedge's own
|
||||
vertices), `url(#hp-sunrim-N)` with BLACK stops on the same axis and
|
||||
the same offsets as the fill, monotone and dead at 85 %,
|
||||
`vector-effect: non-scaling-stroke` at width 1, gone below 3°, in an
|
||||
editor and in the rain — and a pixel probe on WHITE paper proving the
|
||||
side of the wedge is measurably darker than the paper on either hand
|
||||
(the point of the whole change).
|
||||
- `demo/smoke_sun_live_bg.mjs` — the sky follows `sun.sun` on a plain
|
||||
`hass` tick with no reload, asserted on the COMPUTED background of the
|
||||
stage; small steps still glide, big ones catch up at once.
|
||||
- `demo/shot_sun_short.mjs` — stills at a low and a high sun
|
||||
(`node demo/shot_sun_short.mjs <outdir> <prefix>`).
|
||||
- `demo/shot_sun_rim.mjs` — the rim on white paper and on the dark glow
|
||||
canvas, the same frame at several rim peaks (0 = before), which is how
|
||||
`RIM_MAX_ALPHA` was chosen: `node demo/shot_sun_rim.mjs <outdir>
|
||||
[0,0.3,0.42,0.5]`.
|
||||
|
||||
+101
@@ -14,6 +14,22 @@
|
||||
> nothing (external audit T1/T3). If you add a checklist line marked `[manual]`,
|
||||
> add the failing check in the same commit.
|
||||
|
||||
> **⚠ Rule: a new scrollable list inside a dialog is tested by GEOMETRY, never
|
||||
> by the DOM.** Any new scrolling box or `overflow` container added to a dialog
|
||||
> MUST get a smoke that measures **the container's own height and the visible
|
||||
> position of its first item** (`getBoundingClientRect`, and the item's rect
|
||||
> against the box's rect) — counting rendered rows, or asserting that the nodes
|
||||
> exist, proves nothing. The failure mode is always the same and always
|
||||
> invisible to a DOM check: a scrolling box is a flex item whose automatic
|
||||
> minimum size is zero (`min-height: auto` → 0 for an `overflow` child), and a
|
||||
> dialog body is a flex column with a height cap, so the box is the one child
|
||||
> that can be squeezed to a sliver while every row inside it renders happily.
|
||||
> It has bitten us twice already: the **target search results** in the tap
|
||||
> action dialog (v1.53.1 — 26 matching automations rendered into a 1 px
|
||||
> stripe; the smoke counted rows and passed) and the **«Already uploaded»**
|
||||
> plan picker (dev, unreleased — rows present, box 14 px tall, same story).
|
||||
> Both smokes measure heights now; write the third one that way from the start.
|
||||
|
||||
- [ ] Smoke harness itself (v1.43.2, audit T1/T2): every smoke asserts named
|
||||
facts via `check`/`checkAll` and exits non-zero on any mismatch or
|
||||
uncaught in-card exception; the suite runs in CI against a FRESHLY built
|
||||
@@ -1204,3 +1220,88 @@ require hands on real hardware — they remain for the human pass.
|
||||
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]
|
||||
|
||||
## Sun ray rim (docs/SUN.md «The rim», dev, unreleased)
|
||||
|
||||
- [ ] **A ray reads on white paper**: with `sun_rays` on and a LIGHT scene
|
||||
(`bg_mode: daynight` at midday, or a white plan), a lit wedge is bounded
|
||||
by a thin dark hairline along its two SIDE edges — the ones running
|
||||
inward from the ends of the window. There is NO line across the glass and
|
||||
none across the far end; the hairline fades out with the light and is
|
||||
already gone before the wedge's tip [auto: smoke_sun_rim]
|
||||
- [ ] **It stays a hairline**: zoom the plan all the way in and all the way out
|
||||
— the line is one pixel wide at every zoom, never a growing black band.
|
||||
Check on a phone and on a kiosk display too [auto: smoke_sun_rim
|
||||
(`non-scaling-stroke`), still: demo/shot_sun_rim.mjs]
|
||||
- [ ] **It is not an outline on a dark scene**: switch to the glow fill or
|
||||
night — the rim is a subtle darker edge on the shaft, not a drawn contour
|
||||
around it [manual, visual]
|
||||
- [ ] **It lives and dies with the wedge**: below 3° it goes with the wedge in
|
||||
the same two-second fade (not a frame before, not a frame after); rain or
|
||||
an overcast `weather_entity` takes it away with the light; the editors
|
||||
show neither; the kiosk and the plan view agree
|
||||
[auto: smoke_sun_rim + smoke_sun]
|
||||
- [ ] **A wall still stops it**: point the sun so a shaft runs into the
|
||||
opposite wall or into the inner corner of an L — the hairline stops on
|
||||
the wall exactly where the wedge does, and never continues into the next
|
||||
room [auto: unit sun.test «a room that cuts the shaft cuts the rim»]
|
||||
|
||||
## «Already uploaded» plan picker (dev, unreleased)
|
||||
|
||||
- [ ] **«Already uploaded» is a list, not a stripe**: in both space dialogs
|
||||
(new space and space settings, source = "I have a floor-plan image")
|
||||
press «Already uploaded» with at least five plans on the server. The box
|
||||
is a few hundred pixels tall, the first thumbnail is fully visible inside
|
||||
it, and the rest scroll. With nothing uploaded the box shows its message
|
||||
instead of clipping it. Repeat at phone width. Measure heights, do not
|
||||
trust the DOM: the rows were always there, the box was 14 px
|
||||
[auto: smoke_plan_picker]
|
||||
|
||||
## «+» adds a space from anywhere (dev, unreleased)
|
||||
|
||||
- [ ] **The button is where the floors are, always**: as an admin, open the
|
||||
card in View — the «+» sits at the end of the tab row next to the floor
|
||||
names, is at least icon-sized and actually hittable (nothing overlaps
|
||||
it), and opens the NEW-space dialog. Repeat in all three editors (Plan,
|
||||
Devices, Background): the same button in the same place, not only in the
|
||||
Plan editor as before [auto: smoke_gear_tabs]
|
||||
- [ ] **A kiosk has no «+»**: a card with `kiosk: true` does not RENDER the
|
||||
button at all — checking that the header is `display:none` is not
|
||||
enough, a hidden node is still clickable from script [auto: smoke_gear_tabs]
|
||||
- [ ] **The tab row still fits a phone**: at 390 px the row wraps, nothing
|
||||
scrolls sideways out of the card, and the «+» stays inside the card and
|
||||
hittable [auto: smoke_gear_tabs measures `scrollWidth` vs `clientWidth`]
|
||||
- [ ] **A non-admin never sees it**: the button follows the same rule as the
|
||||
per-space gear (`_canEdit`) [manual, needs a non-admin HA user]
|
||||
|
||||
## Coming back to the tab (docs/WARM-REMOUNT.md, dev, unreleased)
|
||||
|
||||
- [ ] **The view does not twitch**: pan the plan into a corner and zoom in
|
||||
(say 2.5×), leave the tab for long enough that HA reconnects, come back —
|
||||
the plan is in exactly the same place at exactly the same scale. Not
|
||||
«about the same»: the restored viewport is the same rectangle, and the
|
||||
smoke compares it frame by frame [auto: smoke_warm_dialogs]
|
||||
- [ ] **The same inside an editor**: do it while the Devices editor is open at
|
||||
a working zoom (say 350 %) — the editor and its zoom both come back
|
||||
(before the fix the mode came back and the zoom fell to 100 %). Leaving
|
||||
the editor afterwards still restores the view-mode viewport
|
||||
[auto: smoke_warm_dialogs]
|
||||
- [ ] **An open dialog stays open**: leave the tab with the space settings (or
|
||||
a device card) open and a field edited but NOT saved — on return the
|
||||
dialog is still there with the same draft [auto: smoke_warm_dialogs]
|
||||
- [ ] **A closed dialog stays closed**: close it with Esc (or Cancel, or Save)
|
||||
and only then leave the tab — nothing reopens on return, and it does not
|
||||
reappear on a second reconnect either (the snapshot is consumed once)
|
||||
[auto: smoke_warm_dialogs]
|
||||
- [ ] **Confirmations are never resurrected**: open «Align everything to the
|
||||
grid», leave the tab, come back — the confirmation is GONE and the plan
|
||||
is untouched. Same for the room-merge confirmation. This is deliberate:
|
||||
a modal whose whole content is «press OK to rewrite your plan» must not
|
||||
be waiting under a returning user's cursor [auto: smoke_warm_dialogs]
|
||||
- [ ] **Nothing is revived into the wrong place**: switch to another floor (or
|
||||
another editor) after the reconnect — a dialog that belonged to the old
|
||||
space/mode does not appear there [auto: smoke_warm_dialogs (space/mode
|
||||
guard), manual for the floor switch]
|
||||
- [ ] **A save in flight is not offered twice**: press Save in the space dialog
|
||||
and reload/reconnect during the write — the dialog does not come back
|
||||
with a live Save button; the reloaded config shows the outcome [manual]
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# Тёплый ре-маунт: возврат на вкладку без «перезагрузки»
|
||||
|
||||
> Статус: реализовано (dev, DEV-B703-01…03). Код: `src/houseplan-card.ts`,
|
||||
> модульная памятка `warmBoot`. Смоки: `demo/smoke_warm_remount.mjs`,
|
||||
> `demo/smoke_warm_dialogs.mjs`.
|
||||
|
||||
## 1. Что вообще происходит
|
||||
|
||||
Lovelace **пересоздаёт элемент карточки** — выбрасывает старый DOM-элемент и
|
||||
создаёт новый — когда websocket переподключается после долго свёрнутой
|
||||
вкладки, при перестройке дашборда и при переключении видов. Страница при этом
|
||||
никуда не девается, но экземпляр карточки умирает, и вместе с ним умирает
|
||||
**всё, что жило в полях экземпляра**.
|
||||
|
||||
Для владельца это выглядит как «карточка перезагрузилась»:
|
||||
|
||||
* мигал прелоадер (закрыто DEV-B703-01);
|
||||
* «чуть-чуть дёргается масштаб» (DEV-B703-03, §2);
|
||||
* **исчезают открытые диалоговые окна** (DEV-B703-03, §3) — это и есть прямое
|
||||
доказательство пересоздания: состояние диалога больше нигде не хранится.
|
||||
|
||||
Лекарство одно и то же: **модульная памятка** `warmBoot` — обычный `Map`,
|
||||
который живёт в загруженном JS-модуле, а не в экземпляре. Ключ:
|
||||
|
||||
```
|
||||
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config)}`
|
||||
```
|
||||
|
||||
Смена размера окна → промах → полный защитный бут (единственный случай, когда
|
||||
хром HA может реально пересобраться заново). Одинаковый конфиг дважды на одной
|
||||
странице неразличим — это задокументированное ограничение (высоты у них всё
|
||||
равно совпадают).
|
||||
|
||||
## 2. Рывок масштаба: фактическая причина
|
||||
|
||||
Памятка хранила только `hdrH`/`stageH`. Этого мало, потому что:
|
||||
|
||||
1. **Пан (`_view`) не переживал экземпляр вообще.** Единственным, что
|
||||
восстанавливалось, был зум — из `localStorage` (`LS_ZOOM`, per space).
|
||||
Новый экземпляр приходил с `_view = null`, `updated()` → `_refitView()`, а
|
||||
затем `_loadFromServer()` звал `_restoreZoom()`, который **центрирует план**
|
||||
(`_applyView(z, центр vb)`). Вид, отъеханный в угол, возвращался в центр.
|
||||
Измерено смоком: до пересоздания `view.x=50, y=493`, после — `x=250, y=293`
|
||||
при zoom 2.2.
|
||||
2. **Зум редактора не сохраняется намеренно** (`_saveZoom()` выходит при
|
||||
`_mode !== 'view'`: рабочий зум редактора — инструмент, а не «как я хочу
|
||||
смотреть»), **а режим редактора сохраняется** (`LS_NAV`). Значит ре-маунт
|
||||
внутри редактора возвращался в тот же редактор, но на зуме ПРОСМОТРА:
|
||||
измерено 3.0 → 1.0.
|
||||
3. `_viewModeSnap` (виджет «куда вернуться при выходе из редактора») умирал
|
||||
вместе с экземпляром — выход из редактора после ре-маунта прыгал второй раз.
|
||||
4. `_showFar` («показать дальние объекты») меняет `_baseVb()`, то есть саму
|
||||
систему координат, против которой клампится вид.
|
||||
|
||||
**Правило теперь:** памятка хранит не «зум», а **весь вьюпорт**:
|
||||
пространство, режим, зум, сам прямоугольник `_view`, `_viewModeSnap`,
|
||||
`_showFar`, а также инструмент редактора (`_tool`, `_decorTool`), выделение
|
||||
(`_selId`, `_rszSel`, `_decorSel`) и локальный переключатель «показать
|
||||
скрытые» (`_showHidden`). Восстановление даёт **бит-в-бит тот же прямоугольник**,
|
||||
а не «тот же зум». `_loadFromServer()` в этом случае **не зовёт**
|
||||
`_restoreZoom()` — центрирующее восстановление осталось только для настоящей
|
||||
навигации (хеш/`LS_NAV` привели на другое пространство).
|
||||
|
||||
Диплинк `#space=<id>` — явная навигация и по-прежнему сильнее памятки.
|
||||
|
||||
Памятка обновляется **на каждом `updated()`** (метод `_warmSnapshot()`), то
|
||||
есть всегда отражает последний ОТРИСОВАННЫЙ кадр. Родиться запись может только
|
||||
из осевшей геометрии (`_bootSettled`), поэтому холодный бут по-прежнему платит
|
||||
полный защитный цикл.
|
||||
|
||||
## 3. Диалоги переживают пересоздание
|
||||
|
||||
В той же записи памятки лежит `dlg`: `{ kind, space, mode, data }`, где
|
||||
`data` — **живой объект-черновик**. Памятка — состояние модуля, её никто не
|
||||
сериализует, поэтому наполовину заполненный диалог устройства вместе с
|
||||
загруженными PDF переезжает бесплатно и без потерь.
|
||||
|
||||
Восстановление (`_warmReviveDialog`) происходит **не в `setConfig`**, а на
|
||||
следующий такт после `connectedCallback`: Lovelace в момент `setConfig` может
|
||||
ещё держать старый элемент, а живому владельцу диалог красть нельзя.
|
||||
|
||||
Условия воскрешения — все обязательны:
|
||||
|
||||
1. запись памятки есть (тёплый ре-маунт, а не холодный старт);
|
||||
2. `dlg` не пуст — то есть в **последнем отрисованном кадре** прошлого
|
||||
экземпляра диалог был открыт;
|
||||
3. прошлый экземпляр **отсоединился** (`freed`), и с этого момента прошло не
|
||||
больше `WARM_REVIVE_MS` (10 c). Перестройка Lovelace укладывается в один
|
||||
такт; ушедший на другой дашборд и вернувшийся через полчаса пользователь не
|
||||
должен встречать диалог, о котором он давно забыл;
|
||||
4. **то же пространство и тот же режим** (`d.space`/`d.mode`);
|
||||
5. **одноразово**: `dlg` съедается в момент попытки восстановления. Третий
|
||||
экземпляр диалог уже не увидит, зомби невозможен.
|
||||
|
||||
Осознанное закрытие (Esc / Отмена / Сохранить) чистить памятку отдельно **не
|
||||
нужно**: следующий же `updated()` запишет `dlg: null`. Это сильнее ручной
|
||||
чистки — ни один путь закрытия нельзя забыть.
|
||||
|
||||
### 3.1. Что восстанавливается с черновиком
|
||||
|
||||
`_spaceDialog` (настройки/создание пространства), `_markerDialog` (устройство),
|
||||
`_settingsDialog` (общие настройки), `_rulesDialog` (правила иконок),
|
||||
`_openingDialog` (проём), `_decorTextDialog` (надпись), `_roomDialog` вместе со
|
||||
всей своей обвязкой (`_roomEditId`, `_roomFill`, источники температуры и
|
||||
влажности, масштабы подписей, `_areaSel`/`_nameSel`, `_pendingSplit`, `_path`).
|
||||
|
||||
Информационные попапы — `_infoCard` (карточка устройства) и `_openingInfo` —
|
||||
восстанавливаются **по id**, а не по объекту: конфиг мог перезагрузиться под
|
||||
нами, и карточка, отрисованная из устаревшего объекта, была бы враньём. Если
|
||||
объекта с таким id больше нет — попап просто не открывается.
|
||||
|
||||
### 3.2. Что НЕ восстанавливается — и почему
|
||||
|
||||
| Не воскрешаем | Причина |
|
||||
|---|---|
|
||||
| «**Выровнять всё по сетке**» (`_alignDialog`) | Модалка, всё содержимое которой — «нажмите OK, и я перепишу ваш план». Воскресить подтверждение рядом с человеком, который только что вернулся на вкладку, — прямой путь к слепому клику по разрушающей записи. Открывается одним нажатием из шестерёнки. |
|
||||
| Подтверждение **объединения комнат** (`_mergeDialog`) | Тот же класс: подтверждение необратимой правки геометрии. |
|
||||
| Подтверждение действия по тапу (`_tapConfirm`) | Держит замыкание `exec` на **мёртвый** экземпляр. |
|
||||
| Мастер импорта этажей (`_importDialog`) | `updated()` сам открывает его, пока конфиг пуст; воскрешение удвоило бы очередь. |
|
||||
| Любой диалог с `busy: true` | Запись/загрузка была в полёте. Новый экземпляр не знает, доехала ли она; показать «Сохранить» ещё раз — это приглашение записать дважды. Правду покажет перезагрузка конфига. |
|
||||
| Киоск | У киоска памятки нет вообще (`100dvh`, ничего не оседает, редактирование недоступно). |
|
||||
|
||||
Правило одной строкой: **воскрешаем черновик, не воскрешаем решение.**
|
||||
|
||||
## 4. Что осознанно отложено
|
||||
|
||||
Ре-маунт по-прежнему теряет мелкое промежуточное состояние жестов, у которого
|
||||
нет осмысленного «продолжения» после смены экземпляра: незавершённый контур
|
||||
рисования вне диалога комнаты (`_path`/`_cursorPt`), стек отмены ресайза
|
||||
(`_rszUndo`), выбор разреза (`_splitSel`, `_mergeSel`), черновик фигуры декора
|
||||
(`_decorDraft`), незавершённое перетаскивание подложки (`_bdDrag`), тост
|
||||
(`_toast`). Все они живут внутри одного жеста; пересоздание элемента жест и так
|
||||
прерывает.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.58.0",
|
||||
"version": "1.59.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.58.0",
|
||||
"version": "1.59.0-beta.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.58.0",
|
||||
"version": "1.59.0-beta.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+280
-10
@@ -34,6 +34,7 @@ import {
|
||||
computeSunRays, dayPhase, northDegOf, bgModeOf, sunRaysOn, weatherEntityOf,
|
||||
sunStateOf, cloudFactor, rayPeakAlpha, raysVisible, rayColor, RAY_FADE_MS, type SunRay,
|
||||
rayStops, skyElevation, skyNeedsSnap,
|
||||
rayRimEdges, rimStops, rimPeakAlpha, RIM_COLOR,
|
||||
} from './sun';
|
||||
import { ContentSigner } from './signing';
|
||||
import { mdiHomeCityOutline } from '@mdi/js';
|
||||
@@ -61,7 +62,7 @@ import {
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.58.0';
|
||||
const CARD_VERSION = '1.59.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
|
||||
@@ -89,9 +90,56 @@ const BOOT_SOFT_MS = 1500;
|
||||
* the key keeps two DIFFERENT cards on one page from adopting each other's
|
||||
* header height (same config twice on one view is indistinguishable — and
|
||||
* then the heights match anyway). */
|
||||
const warmBoot = new Map<string, { hdrH: number; stageH: number }>();
|
||||
/** DEV-B703-03 — what the dead instance was LOOKING at. The header height
|
||||
* alone was not enough: `_view` (the pan) never left the instance, and the
|
||||
* EDITOR zoom is deliberately not persisted (`_saveZoom` is view-only) while
|
||||
* the editor MODE is (LS_NAV). So a re-mount inside an editor came back at
|
||||
* the view-mode zoom, and a panned view came back re-centred on the plan —
|
||||
* the owner's «чуть-чуть дёргается масштаб». The memo now carries the whole
|
||||
* viewport, so the restore is the same rect, not the same zoom number. */
|
||||
type WarmViewport = {
|
||||
space: string;
|
||||
mode: 'view' | 'plan' | 'devices' | 'decor';
|
||||
zoom: number;
|
||||
view: { x: number; y: number; w: number; h: number } | null;
|
||||
/** the view-mode viewport an editor was entered from (_viewModeSnap) */
|
||||
snap: { space: string; zoom: number; cx?: number; cy?: number } | null;
|
||||
tool: MarkupTool;
|
||||
decorTool: 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text' | 'erase';
|
||||
showHidden: boolean;
|
||||
/** «показать дальние» changes _baseVb, so the restored view rect is only
|
||||
* the same rect if the frame it was clamped against is the same one */
|
||||
showFar: boolean;
|
||||
selId: string | null;
|
||||
rszSel: string | null;
|
||||
decorSel: string | null;
|
||||
};
|
||||
/** Which dialog was open and its draft (docs/WARM-REMOUNT.md §3). `data` is
|
||||
* the live draft OBJECT — the memo is module state, never serialised, so a
|
||||
* half-filled device dialog with its uploaded pdfs survives for free. */
|
||||
type WarmDialogKind = 'space' | 'marker' | 'settings' | 'opening' | 'decorText' | 'rules' | 'room' | 'info' | 'openingInfo';
|
||||
type WarmDialog = { kind: WarmDialogKind; space: string; mode: string; data: any };
|
||||
type WarmEntry = {
|
||||
hdrH: number;
|
||||
stageH: number;
|
||||
vp: WarmViewport | null;
|
||||
dlg: WarmDialog | null;
|
||||
/** when the instance that wrote `dlg` detached; 0 = it is still alive */
|
||||
freed: number;
|
||||
};
|
||||
const warmBoot = new Map<string, WarmEntry>();
|
||||
const warmBootKey = (config: unknown): string =>
|
||||
`${window.innerWidth}x${window.innerHeight}|${JSON.stringify(config ?? {})}`;
|
||||
/** A dialog is revived only if the instance that owned it died THIS long ago.
|
||||
* A Lovelace rebuild detaches and re-attaches within one task; a user who
|
||||
* walked off to another dashboard view and came back later must not be met
|
||||
* by a dialog they have long forgotten opening. */
|
||||
const WARM_REVIVE_MS = 10000;
|
||||
/** The memo gains a key on every window RESIZE and never loses one; the
|
||||
* values used to be two numbers, and now they can hold a dialog draft (a
|
||||
* plan pdf among it). Keep the last few viewports — a stale entry only
|
||||
* costs the next card at that size a cold boot. */
|
||||
const WARM_MAX_KEYS = 8;
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -424,7 +472,7 @@ class HouseplanCard extends LitElement {
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
/** Wedge memo: recomputed only when (azimuth, elevation, north, cfg rev) change (docs/SUN.md). */
|
||||
private _sunRaysCache: { key: string; rays: SunRay[] } | null = null;
|
||||
private _sunRaysCache: { key: string; rays: SunRay[]; rims: number[][][][] } | null = null;
|
||||
/** Sun elevation (0.1°) the day/night sky is currently PAINTED with, and
|
||||
* whether the next paint must jump to it instead of gliding (docs/SUN.md). */
|
||||
private _skyElev: number | null = null;
|
||||
@@ -517,6 +565,13 @@ class HouseplanCard extends LitElement {
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
|
||||
/** DEV-B703-03 warm re-mount: the dead instance's viewport, and the two
|
||||
* flags that keep the restore from being undone (the server load's centred
|
||||
* _restoreZoom) or applied twice (the dialog revival). */
|
||||
private _warmVp: WarmViewport | null = null;
|
||||
private _warmVpArmed = false;
|
||||
private _warmRevivePending = false;
|
||||
private _warmReviveTimer?: number;
|
||||
private _hashApplied = false;
|
||||
private _navApplied = false; // the saved space was restored (or the user navigated)
|
||||
// ---- kiosk (wall device) mode ----
|
||||
@@ -669,6 +724,12 @@ class HouseplanCard extends LitElement {
|
||||
// DEV-B703-02: a reattach mid-outage must keep revalidating — the retry
|
||||
// timer died in disconnectedCallback
|
||||
if (!this._loadOk && this._serverCfg && this.hass) this._scheduleLoadRetry();
|
||||
// DEV-B703-03: one task later the element Lovelace replaced has detached
|
||||
// — only then is its open dialog ours to take over.
|
||||
if (this._warmVp && !this._warmRevivePending && this._warmReviveTimer === undefined) {
|
||||
this._warmRevivePending = true;
|
||||
this._warmReviveTimer = window.setTimeout(() => this._warmReviveDialog(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
@@ -711,6 +772,13 @@ class HouseplanCard extends LitElement {
|
||||
this._unsubLayout = null;
|
||||
}
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
// DEV-B703-03: the last thing this instance was showing, then the
|
||||
// tombstone that lets exactly one successor adopt the open dialog.
|
||||
this._warmRevivePending = false;
|
||||
clearTimeout(this._warmReviveTimer);
|
||||
this._warmReviveTimer = undefined;
|
||||
this._warmSnapshot();
|
||||
this._warmPatch({ freed: Date.now() });
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -844,6 +912,7 @@ class HouseplanCard extends LitElement {
|
||||
this._booting = false;
|
||||
this._bootFading = false;
|
||||
this._hdrH = warm.hdrH;
|
||||
this._warmVp = warm.vp; // adopted below, once the model is in hand
|
||||
this._bootSoft = true; // timer armed in connectedCallback...
|
||||
if (this.isConnected) { // ...unless setConfig re-runs while attached
|
||||
clearTimeout(this._bootSoftTimer);
|
||||
@@ -893,6 +962,171 @@ class HouseplanCard extends LitElement {
|
||||
// arm it BEFORE the first view computation, so the very first paint is
|
||||
// already at the user's zoom.
|
||||
if (this._mode === 'view' && !this._view) this._zoom = this._zoomBySpace[this._space] || 1;
|
||||
this._warmAdoptViewport(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* DEV-B703-03: put back the EXACT viewport of the instance Lovelace threw
|
||||
* away — same space, same editor, same zoom and the same pan rect, so the
|
||||
* re-mount is bit-for-bit the view that was on screen. Runs after the LS
|
||||
* snapshot has restored the model (the space must exist) and after the
|
||||
* LS_NAV/default_floor guesses, which it supersedes: the memo is the newer,
|
||||
* finer-grained record of the very same intent. A `#space=` deep link is an
|
||||
* EXPLICIT navigation and still wins.
|
||||
*/
|
||||
private _warmAdoptViewport(config: CardConfig): void {
|
||||
const vp = this._warmVp;
|
||||
if (!vp) return;
|
||||
if (this._hashApplied || !this._model.find((sp) => sp.id === vp.space)) {
|
||||
this._warmVp = null; // another space is on screen — the memo is not about it
|
||||
return;
|
||||
}
|
||||
this._space = vp.space;
|
||||
this._navApplied = true;
|
||||
// the editor comes back only where an editor is allowed at all
|
||||
this._mode = vp.mode !== 'view' && this._canEdit && !config.kiosk ? vp.mode : 'view';
|
||||
this._zoom = vp.zoom;
|
||||
this._view = vp.view ? { ...vp.view } : null;
|
||||
this._viewModeSnap = vp.snap ? { ...vp.snap } : null;
|
||||
this._tool = vp.tool;
|
||||
this._decorTool = vp.decorTool;
|
||||
this._showHidden = vp.showHidden;
|
||||
if (this._showFar !== vp.showFar) { this._showFar = vp.showFar; this._frame = null; }
|
||||
this._selId = vp.selId;
|
||||
this._rszSel = vp.rszSel;
|
||||
this._decorSel = vp.decorSel;
|
||||
this._warmVpArmed = true; // _loadFromServer must not re-centre this
|
||||
}
|
||||
|
||||
/** Merge a patch into this card's warm entry. `create` is false everywhere
|
||||
* but the boot settle: a memo may only be BORN from a settled geometry. */
|
||||
private _warmPatch(patch: Partial<WarmEntry>, create = false): void {
|
||||
if (this._config?.kiosk) return;
|
||||
const k = warmBootKey(this._config);
|
||||
const cur = warmBoot.get(k);
|
||||
if (!cur && !create) return;
|
||||
warmBoot.set(k, { hdrH: this._hdrH, stageH: 0, vp: null, dlg: null, freed: 0, ...(cur || {}), ...patch });
|
||||
while (warmBoot.size > WARM_MAX_KEYS) {
|
||||
const oldest = warmBoot.keys().next().value; // Map keeps insertion order
|
||||
if (oldest === undefined || oldest === k) break;
|
||||
warmBoot.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private _warmViewportState(): WarmViewport {
|
||||
return {
|
||||
space: this._space,
|
||||
mode: this._mode,
|
||||
zoom: this._zoom,
|
||||
view: this._view ? { ...this._view } : null,
|
||||
snap: this._viewModeSnap ? { ...this._viewModeSnap } : null,
|
||||
tool: this._tool,
|
||||
decorTool: this._decorTool,
|
||||
showHidden: this._showHidden,
|
||||
showFar: this._showFar,
|
||||
selId: this._selId,
|
||||
rszSel: this._rszSel,
|
||||
decorSel: this._decorSel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The open dialog, or null. Deliberately null (docs/WARM-REMOUNT.md §4) for:
|
||||
* • «Выровнять всё по сетке» and the room MERGE confirmation — a modal whose
|
||||
* only content is "press OK to rewrite your plan". Resurrecting a
|
||||
* confirmation next to a user who has just come back to the tab is how a
|
||||
* destructive write gets a blind click; both are one click to reopen.
|
||||
* • a tap confirmation — it carries a closure over the DEAD instance.
|
||||
* • the floor-import wizard — `updated()` reopens it by itself while the
|
||||
* config is still empty; reviving it too would double the queue.
|
||||
* • ANY dialog with a save/upload in flight (`busy`) — the new instance
|
||||
* cannot know whether the write landed, and offering Save again invites a
|
||||
* second one. The config reload shows the truth instead.
|
||||
* Precedence follows the Esc stack: the topmost dialog is the one that is
|
||||
* "open" as far as the user is concerned.
|
||||
*/
|
||||
private _warmDialogState(): WarmDialog | null {
|
||||
const at = (kind: WarmDialogKind, data: any): WarmDialog =>
|
||||
({ kind, space: this._space, mode: this._mode, data });
|
||||
if (this._tapConfirm || this._alignDialog || this._mergeDialog || this._importDialog) return null;
|
||||
if (this._openingInfo) return at('openingInfo', (this._openingInfo as any).id);
|
||||
if (this._infoCard) return at('info', this._infoCard.id);
|
||||
if (this._rulesDialog) return this._rulesDialog.busy ? null : at('rules', this._rulesDialog);
|
||||
if (this._settingsDialog) return this._settingsDialog.busy ? null : at('settings', this._settingsDialog);
|
||||
if (this._markerDialog) return this._markerDialog.busy ? null : at('marker', this._markerDialog);
|
||||
if (this._openingDialog) return at('opening', this._openingDialog);
|
||||
if (this._decorTextDialog) return at('decorText', this._decorTextDialog);
|
||||
if (this._roomDialog) {
|
||||
return at('room', {
|
||||
editId: this._roomEditId, fill: this._roomFill, tempSrc: this._roomTempSrc,
|
||||
humSrc: this._roomHumSrc, srcOpen: this._roomSrcOpen, srcFilter: this._roomSrcFilter,
|
||||
nameScale: this._roomNameScale, labelScale: this._roomLabelScale,
|
||||
areaSel: this._areaSel, nameSel: this._nameSel,
|
||||
pendingSplit: this._pendingSplit, path: this._path,
|
||||
});
|
||||
}
|
||||
if (this._spaceDialog) return this._spaceDialog.busy ? null : at('space', this._spaceDialog);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Mirror the live viewport (and the open dialog) into the memo. Called from
|
||||
* every `updated()`: whatever the previous instance last PAINTED is what the
|
||||
* next one must open at — and a dialog closed with Esc/Cancel/Save writes
|
||||
* `dlg: null` here on the very next render, so it can never come back. */
|
||||
private _warmSnapshot(): void {
|
||||
if (this._booting || this._config?.kiosk) return;
|
||||
const patch: Partial<WarmEntry> = { vp: this._warmViewportState() };
|
||||
// do not overwrite the snapshot we are about to revive FROM
|
||||
if (!this._warmRevivePending) patch.dlg = this._warmDialogState();
|
||||
this._warmPatch(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open the dialog the dead instance had open. Not done in setConfig:
|
||||
* Lovelace may still be holding the old element there, and a live owner's
|
||||
* dialog must not be stolen. One task later the previous instance has
|
||||
* detached (`freed`) and the snapshot is ours to consume — exactly once.
|
||||
*/
|
||||
private _warmReviveDialog(): void {
|
||||
this._warmRevivePending = false;
|
||||
const k = warmBootKey(this._config);
|
||||
const e = warmBoot.get(k);
|
||||
if (!e || !e.dlg) return;
|
||||
const d = e.dlg;
|
||||
warmBoot.set(k, { ...e, dlg: null, freed: 0 }); // consume-once: no zombie on the third mount
|
||||
if (!e.freed || Date.now() - e.freed > WARM_REVIVE_MS) return; // owner alive, or gone long ago
|
||||
if (d.space !== this._space || d.mode !== this._mode) return; // never in another space/editor
|
||||
switch (d.kind) {
|
||||
case 'space': this._spaceDialog = { ...d.data, busy: false, savedBusy: false }; break;
|
||||
case 'marker': this._markerDialog = { ...d.data, busy: false }; break;
|
||||
case 'settings': this._settingsDialog = { ...d.data, busy: false }; break;
|
||||
case 'rules': this._rulesDialog = { ...d.data, busy: false }; break;
|
||||
case 'opening': this._openingDialog = { ...d.data }; break;
|
||||
case 'decorText': this._decorTextDialog = { ...d.data }; break;
|
||||
case 'room': {
|
||||
const r = d.data;
|
||||
this._roomEditId = r.editId; this._roomFill = r.fill; this._roomTempSrc = r.tempSrc;
|
||||
this._roomHumSrc = r.humSrc; this._roomSrcOpen = r.srcOpen; this._roomSrcFilter = r.srcFilter;
|
||||
this._roomNameScale = r.nameScale; this._roomLabelScale = r.labelScale;
|
||||
this._areaSel = r.areaSel; this._nameSel = r.nameSel;
|
||||
this._pendingSplit = r.pendingSplit; this._path = r.path;
|
||||
this._roomDialog = true;
|
||||
break;
|
||||
}
|
||||
// info popups are re-resolved by id: the config may have been reloaded
|
||||
// under us, and a card rendered from a stale object is a lie
|
||||
case 'info': {
|
||||
const dev = this._devices.find((x) => x.id === d.data);
|
||||
if (dev) this._infoCard = dev;
|
||||
break;
|
||||
}
|
||||
case 'openingInfo': {
|
||||
const op = (this._curSpaceCfg?.openings || []).find((x: any) => x.id === d.data);
|
||||
if (op) this._openingInfo = op;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/** Save a snapshot of the config+layout to localStorage for an instant start. */
|
||||
@@ -1094,6 +1328,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
protected updated(): void {
|
||||
this._skyRelease();
|
||||
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
|
||||
const stage = this._stageEl;
|
||||
if (stage && !this._roViewport) {
|
||||
this._roViewport = new ResizeObserver(() => this._refitView());
|
||||
@@ -1122,7 +1357,7 @@ class HouseplanCard extends LitElement {
|
||||
// resize with a live card) must not poison the next warm mount —
|
||||
// the memo follows the live settled geometry.
|
||||
if (t >= 0 && !this._booting && !this._config?.kiosk && stage.clientHeight > 0) {
|
||||
warmBoot.set(warmBootKey(this._config), { hdrH: t, stageH: stage.clientHeight });
|
||||
this._warmPatch({ hdrH: t, stageH: stage.clientHeight });
|
||||
}
|
||||
};
|
||||
// a frame later: setting state straight from the observer callback makes
|
||||
@@ -1219,7 +1454,12 @@ class HouseplanCard extends LitElement {
|
||||
this._space = this._model[0]?.id || this._space;
|
||||
}
|
||||
this._cacheSnapshot();
|
||||
this._restoreZoom();
|
||||
// DEV-B703-03: a warm re-mount already holds the exact viewport of the
|
||||
// instance that was thrown away; the centred restore here IS the
|
||||
// reported jerk. Only a genuine navigation (the hash/nav landed us on
|
||||
// another space) still needs it.
|
||||
if (this._warmVpArmed && this._space === this._warmVp?.space) this._warmVpArmed = false;
|
||||
else this._restoreZoom();
|
||||
} catch (e) {
|
||||
if (this._serverCfg) {
|
||||
// DEV-B703-02: this instance already RENDERS a valid config (the LS
|
||||
@@ -2157,7 +2397,7 @@ class HouseplanCard extends LitElement {
|
||||
// same config at the same viewport opens warm (no veil, no wait).
|
||||
const settledH = this._stageEl?.clientHeight ?? 0;
|
||||
if (!this._config?.kiosk && settledH > 0) {
|
||||
warmBoot.set(warmBootKey(this._config), { hdrH: this._hdrH, stageH: settledH });
|
||||
this._warmPatch({ hdrH: this._hdrH, stageH: settledH, vp: this._warmViewportState() }, true);
|
||||
}
|
||||
this._bootFading = true; // one soft opacity-out, then out of the DOM
|
||||
this._bootTimer = window.setTimeout(() => { this._bootFading = false; }, 220);
|
||||
@@ -5308,12 +5548,17 @@ class HouseplanCard extends LitElement {
|
||||
const windows = this._openingsR
|
||||
.filter((o) => o.type === 'window')
|
||||
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
|
||||
this._sunRaysCache = { key, rays: computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!) };
|
||||
const rays = computeSunRays(rooms, windows, sun.azimuth, sun.elevation, north!);
|
||||
// the rim is pure geometry off the same wedges — memoised on the same key
|
||||
this._sunRaysCache = { key, rays, rims: rays.map((r) => rayRimEdges(r)) };
|
||||
}
|
||||
const rays = this._sunRaysCache.rays;
|
||||
const rims = this._sunRaysCache.rims;
|
||||
if (!rays.length) return empty;
|
||||
const color = rayColor(dayPhase(sun.elevation).warmth);
|
||||
const stops = rayStops();
|
||||
const rimAlpha = rimPeakAlpha(cloud);
|
||||
const rimStopList = rimStops();
|
||||
// NO filter here, and none in <defs>. Owner 2026-08-04: «не надо размывать
|
||||
// их боковые грани» — the shaft keeps the crisp sides real light has, and
|
||||
// the only falloff is the gradient. The tip needs no blur either:
|
||||
@@ -5330,21 +5575,41 @@ class HouseplanCard extends LitElement {
|
||||
// wall; with this axis every point `source + dir·u` lands at offset
|
||||
// `u/len`. Whole pane at peak alpha, identical fade distance along every
|
||||
// ray, and the parallelogram's far edge exactly on the gradient's end.
|
||||
//
|
||||
// THE RIM (owner 2026-08-04, docs/SUN.md «The rim»): a 1 px black hairline
|
||||
// along the two SIDE edges only, so the shaft stays legible on white paper
|
||||
// where added luminance cannot read. It gets a gradient of its own —
|
||||
// `hp-sunrim-i` — deliberately built on the SAME x1/y1/x2/y2 and the same
|
||||
// `rayStops()` curve as `hp-sun-i`, only black and with its own peak: the
|
||||
// line must die on the very stop the fill dies on, never outlive it.
|
||||
// `non-scaling-stroke` keeps it one screen pixel at any zoom, and the
|
||||
// segments come out of the ALREADY clipped polygons, so a wall still stops
|
||||
// the light by geometry alone and no clip-path enters this layer.
|
||||
return svg`<defs>
|
||||
${rays.map((r, i) => {
|
||||
const mx = (r.a[0] + r.b[0]) / 2;
|
||||
const my = (r.a[1] + r.b[1]) / 2;
|
||||
const ax = mx + r.normal[0] * r.depth;
|
||||
const ay = my + r.normal[1] * r.depth;
|
||||
return svg`<linearGradient id="hp-sun-${i}" gradientUnits="userSpaceOnUse"
|
||||
x1="${mx}" y1="${my}"
|
||||
x2="${mx + r.normal[0] * r.depth}" y2="${my + r.normal[1] * r.depth}">
|
||||
x1="${mx}" y1="${my}" x2="${ax}" y2="${ay}">
|
||||
${stops.map(([off, k]) => svg`<stop offset="${(off * 100).toFixed(1)}%"
|
||||
stop-color="${color}" stop-opacity="${(alpha * k).toFixed(4)}"></stop>`)}
|
||||
</linearGradient>
|
||||
<linearGradient id="hp-sunrim-${i}" gradientUnits="userSpaceOnUse"
|
||||
x1="${mx}" y1="${my}" x2="${ax}" y2="${ay}">
|
||||
${rimStopList.map(([off, k]) => svg`<stop offset="${(off * 100).toFixed(1)}%"
|
||||
stop-color="${RIM_COLOR}" stop-opacity="${(rimAlpha * k).toFixed(4)}"></stop>`)}
|
||||
</linearGradient>`;
|
||||
})}
|
||||
</defs>
|
||||
<g class="sunlayer ${this._sunOut ? 'out' : ''}">
|
||||
${rays.map((r, i) => r.polys.map((p) => svg`<polygon
|
||||
points="${p.map((q) => q[0] + ',' + q[1]).join(' ')}" fill="url(#hp-sun-${i})"></polygon>`))}
|
||||
${rays.map((r, i) => (rims[i] || []).map((e) => svg`<line class="sunrim"
|
||||
x1="${e[0][0]}" y1="${e[0][1]}" x2="${e[1][0]}" y2="${e[1][1]}"
|
||||
stroke="url(#hp-sunrim-${i})" stroke-width="1"
|
||||
vector-effect="non-scaling-stroke"></line>`))}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -6124,7 +6389,12 @@ class HouseplanCard extends LitElement {
|
||||
: nothing}
|
||||
</button>`,
|
||||
)}
|
||||
${this._norm && this._mode === 'plan'
|
||||
${''/* «Добавить пространство» is a NAVIGATION action, not a plan-editor
|
||||
tool (owner 2026-08-04): it lives next to the floor names in every
|
||||
mode, exactly where the per-space gear does. Kiosk is a shop
|
||||
window — the whole .hdr is display:none there, but the button is
|
||||
also not RENDERED, so nothing invisible is clickable. */}
|
||||
${this._canEdit && !this._kiosk
|
||||
? html`<button class="tab tabadd" title=${this._t('title.add_space')}
|
||||
@click=${() => this._openSpaceDialog('create')}>
|
||||
<ha-icon icon="mdi:plus"></ha-icon>
|
||||
|
||||
@@ -1559,6 +1559,14 @@ export const cardStyles = css`
|
||||
border: 1px solid var(--hp-line);
|
||||
border-radius: var(--rad-m);
|
||||
background: var(--hp-bg2, rgba(255, 255, 255, 0.03));
|
||||
/* The same collapse that ate .candlist (v1.53.1): a scroll box is a
|
||||
flex item whose automatic minimum size is ZERO (overflow != visible),
|
||||
so inside .dialog .body — a flex column taller than its 66vh cap —
|
||||
it shrank to a 14px sliver: the rows were in the DOM, the owner saw
|
||||
a thin rounded stripe under the "Already uploaded" button. Don't
|
||||
shrink, and keep a floor even when the box is empty or loading. */
|
||||
flex: 0 0 auto;
|
||||
min-height: 2.6em;
|
||||
}
|
||||
.savedplan {
|
||||
display: flex;
|
||||
|
||||
+105
@@ -359,6 +359,111 @@ export function rayStops(): [number, number][] {
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------- the rim (owner 2026-08-04) ----------------
|
||||
|
||||
/**
|
||||
* The rim: «тонкая (1px) чёрная граница по бокам светящегося сектора, которая
|
||||
* также плавно уходит в ноль вместе с самим градиентом».
|
||||
*
|
||||
* Why at all: painting light means ADDING luminance, and white paper has none
|
||||
* left to give (the analysis kept in docs/SUN-CONTRAST.md). The owner rejected
|
||||
* the «shade instead of light» model that analysis proposed and asked for the
|
||||
* cheap half of it instead — light is invisible on white, but its BOUNDARY is
|
||||
* not. One hairline along each side of the shaft gives the wedge a "beam"
|
||||
* reading on paper without touching the fill, the geometry or anything a dark
|
||||
* scene already gets right.
|
||||
*
|
||||
* Contract (docs/SUN.md, «The rim»):
|
||||
*
|
||||
* - only the two SIDE edges — the ones running from the ends of the window
|
||||
* along `dir`. Never the glass edge (a-b) and never the far edge: those are
|
||||
* not boundaries of the beam, they are its source and its end;
|
||||
* - one screen pixel at any zoom (`vector-effect: non-scaling-stroke`);
|
||||
* - black, and it dies EXACTLY with the fill: same gradient axis (the wall's
|
||||
* inward normal, `depth` long), same normalised curve `rayStops()`, same
|
||||
* `RAY_FADE_END` — so no rim can outlive the light it outlines;
|
||||
* - clipped by the room like the wedge itself, which here is free: the
|
||||
* segments are cut out of the ALREADY clipped polygons (`rayRimEdges`),
|
||||
* so no `clip-path` enters the sun layer (docs/SUN.md keeps that promise).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Peak rim opacity at the glass, before cloud cover. Visually tuned on the
|
||||
* demo rig at both extremes: it has to make the shaft legible on white paper
|
||||
* (the whole point) yet not read as an ink outline over the dark glow canvas.
|
||||
* Below ~0.3 the line disappears on paper at kiosk scale; above ~0.5 it turns
|
||||
* into a drawn contour on a night scene.
|
||||
*/
|
||||
export const RIM_MAX_ALPHA = 0.42;
|
||||
|
||||
/** The rim is black — the one thing white paper still has room for. */
|
||||
export const RIM_COLOR = '#000000';
|
||||
|
||||
/** Peak rim opacity for the current cloud cover — dimmed exactly like the fill. */
|
||||
export function rimPeakAlpha(cloud = 1): number {
|
||||
return RIM_MAX_ALPHA * clamp01(cloud);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rim gradient stops — the SAME normalised curve as the fill, by identity and
|
||||
* not by copy: «прозрачность гаснет ВМЕСТЕ с заливкой ... ровно по той же
|
||||
* кривой и тому же порогу». Only the peak alpha and the colour differ.
|
||||
*/
|
||||
export function rimStops(): [number, number][] {
|
||||
return rayStops();
|
||||
}
|
||||
|
||||
/**
|
||||
* The two side edges of a wedge, cut to exactly what the room left of it.
|
||||
*
|
||||
* The clipped polygons already contain those edges: a boundary segment belongs
|
||||
* to a side iff both of its endpoints lie on that side's line (through `a`,
|
||||
* resp. `b`, along `dir`). Collinear pieces — polyclip readily splits an edge
|
||||
* at a touching vertex, and an L-shaped room can cut a side into several
|
||||
* stretches — are projected onto `dir` and merged, so an unclipped wedge
|
||||
* yields exactly two segments and a clipped one the fewest that cover it.
|
||||
*
|
||||
* `eps` is in render units (the canvas is NORM_W = 1000 wide), comfortably
|
||||
* above polyclip's rounding and far below anything the eye could see.
|
||||
*/
|
||||
export function rayRimEdges(ray: SunRay, eps = 1e-4): number[][][] {
|
||||
const [dx, dy] = ray.dir;
|
||||
const nx = -dy;
|
||||
const ny = dx;
|
||||
const out: number[][][] = [];
|
||||
for (const src of [ray.a, ray.b]) {
|
||||
const spans: [number, number][] = [];
|
||||
for (const poly of ray.polys) {
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p = poly[i];
|
||||
const q = poly[(i + 1) % poly.length];
|
||||
// off the side's line? then this boundary edge is the glass, the far
|
||||
// edge, or a wall the room cut the wedge with — not a side of the beam
|
||||
if (Math.abs((p[0] - src[0]) * nx + (p[1] - src[1]) * ny) > eps) continue;
|
||||
if (Math.abs((q[0] - src[0]) * nx + (q[1] - src[1]) * ny) > eps) continue;
|
||||
const up = (p[0] - src[0]) * dx + (p[1] - src[1]) * dy;
|
||||
const uq = (q[0] - src[0]) * dx + (q[1] - src[1]) * dy;
|
||||
if (Math.abs(uq - up) <= eps) continue; // degenerate sliver
|
||||
spans.push(up < uq ? [up, uq] : [uq, up]);
|
||||
}
|
||||
}
|
||||
spans.sort((s, t) => s[0] - t[0]);
|
||||
const merged: [number, number][] = [];
|
||||
for (const s of spans) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && s[0] <= last[1] + eps) last[1] = Math.max(last[1], s[1]);
|
||||
else merged.push([s[0], s[1]]);
|
||||
}
|
||||
for (const [u0, u1] of merged) {
|
||||
out.push([
|
||||
[src[0] + dx * u0, src[1] + dy * u0],
|
||||
[src[0] + dx * u1, src[1] + dy * u1],
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Day/night sky: how far the painted sky may drift from the real sun before
|
||||
* the card stops gliding and simply JUMPS to the right colour.
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
rayAlpha, rayColor, cloudFactor, RAY_MAX_ALPHA,
|
||||
raysVisible, rayPeakAlpha, RAY_ELEVATION_MIN, RAY_FADE_MS,
|
||||
RAY_LENGTH_K, RAY_FADE_END, rayStops, RAY_MIN_COS,
|
||||
rimStops, rimPeakAlpha, rayRimEdges, RIM_MAX_ALPHA, RIM_COLOR,
|
||||
SKY_SNAP_DEG, skyNeedsSnap, skyElevation,
|
||||
northDegOf, bgModeOf, sunRaysOn, weatherEntityOf, sunStateOf,
|
||||
} from '../test-build/sun.js';
|
||||
@@ -146,6 +147,98 @@ test('rayStops: the shaft is fully dissolved BEFORE its own far edge', () => {
|
||||
assert.ok(half[0] <= 0.65, 'past half-dark by two thirds of the way');
|
||||
});
|
||||
|
||||
// ---- the rim (owner 2026-08-04, docs/SUN.md «The rim») -----------------
|
||||
|
||||
test('rimStops: the rim dies on exactly the same curve as the fill', () => {
|
||||
const rim = rimStops();
|
||||
// «ровно по той же кривой и тому же порогу» — identity, not a copy that can
|
||||
// drift: if the fill's easing is ever retuned the outline follows it.
|
||||
assert.deepEqual(rim, rayStops());
|
||||
assert.ok(near(rim[0][0], 0) && near(rim[0][1], 1), 'brightest at the glass');
|
||||
assert.ok(near(rim[rim.length - 1][0], 1), 'spans the FULL wedge, like the fill');
|
||||
for (let i = 1; i < rim.length; i++) {
|
||||
assert.ok(rim[i][0] > rim[i - 1][0] || rim[i][0] === 1, 'offsets ascend');
|
||||
assert.ok(rim[i][1] <= rim[i - 1][1], 'the rim never brightens inward');
|
||||
}
|
||||
for (const [off, k] of rim) {
|
||||
if (off >= RAY_FADE_END) assert.equal(k, 0, 'no rim at/after ' + RAY_FADE_END);
|
||||
else assert.ok(k > 0, 'still drawn at ' + off);
|
||||
}
|
||||
// black, and visible on paper without becoming an ink contour on a dark scene
|
||||
assert.equal(RIM_COLOR, '#000000');
|
||||
assert.ok(RIM_MAX_ALPHA >= 0.35 && RIM_MAX_ALPHA <= 0.5, 'the owner\'s 0.35..0.5 window');
|
||||
assert.ok(near(rimPeakAlpha(1), RIM_MAX_ALPHA));
|
||||
assert.ok(near(rimPeakAlpha(), RIM_MAX_ALPHA)); // clear sky by default
|
||||
assert.ok(near(rimPeakAlpha(0.4), RIM_MAX_ALPHA * 0.4)); // clouds dim it too
|
||||
assert.equal(rimPeakAlpha(0), 0); // rain takes it with the light
|
||||
assert.ok(near(rimPeakAlpha(5), RIM_MAX_ALPHA)); // garbage cloud factor clamps
|
||||
});
|
||||
|
||||
test('rayRimEdges: the two SIDE edges only, cut exactly like the wedge', () => {
|
||||
// a west window in r1, a western sun square into it — the wedge stays well
|
||||
// inside the room, so both sides are whole
|
||||
const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0);
|
||||
assert.ok(ray, 'the west window is lit');
|
||||
const edges = rayRimEdges(ray);
|
||||
assert.equal(edges.length, 2, 'one line per side, no more');
|
||||
const far = (s) => [s[0] + ray.dir[0] * ray.len, s[1] + ray.dir[1] * ray.len];
|
||||
const same = (p, q) => near(p[0], q[0], 1e-6) && near(p[1], q[1], 1e-6);
|
||||
const has = (s, t) => edges.some(([p, q]) => (same(p, s) && same(q, t)) || (same(p, t) && same(q, s)));
|
||||
assert.ok(has(ray.a, far(ray.a)), 'the side from a runs the full reach');
|
||||
assert.ok(has(ray.b, far(ray.b)), 'the side from b runs the full reach');
|
||||
// never the glass (a-b) and never the far edge: every rim segment is
|
||||
// parallel to the ray, and both of them are the full length
|
||||
for (const [p, q] of edges) {
|
||||
const dx = q[0] - p[0];
|
||||
const dy = q[1] - p[1];
|
||||
const L = Math.hypot(dx, dy);
|
||||
assert.ok(near(L, ray.len, 1e-6), 'a whole side, not a wall of the room');
|
||||
assert.ok(near((dx / L) * ray.dir[1] - (dy / L) * ray.dir[0], 0, 1e-9), 'parallel to the ray');
|
||||
}
|
||||
// ...and the glass edge is NOT among them, however you orient it
|
||||
assert.ok(!has(ray.a, ray.b), 'the pane of glass is not a rim');
|
||||
assert.ok(!has(far(ray.a), far(ray.b)), 'the far edge is not a rim either');
|
||||
});
|
||||
|
||||
test('rayRimEdges: a room that cuts the shaft cuts the rim with it', () => {
|
||||
// the same window in a room only 30 units deep — the wedge (~46 long at 60°)
|
||||
// hits the far wall, and both rims must stop on it, not carry on in mid-air
|
||||
const narrow = [{ id: 'n1', poly: [[100, 100], [130, 100], [130, 500], [100, 500]] }];
|
||||
const [ray] = computeSunRays(narrow, [WIN.west], 270, 60, 0);
|
||||
assert.ok(ray && ray.len > 30, 'the wedge really is longer than the room');
|
||||
const edges = rayRimEdges(ray);
|
||||
assert.equal(edges.length, 2);
|
||||
for (const [p, q] of edges) {
|
||||
assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), 30, 1e-6), 'clipped to the room');
|
||||
assert.ok(Math.max(p[0], q[0]) <= 130 + 1e-6, 'nothing past the far wall');
|
||||
}
|
||||
// and the shortened rim still starts at the glass
|
||||
assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 270, 1e-6)));
|
||||
assert.ok(edges.some(([p]) => near(p[0], 100, 1e-6) && near(p[1], 330, 1e-6)));
|
||||
});
|
||||
|
||||
test('rayRimEdges: collinear splinters merge, an empty wedge draws nothing', () => {
|
||||
const [ray] = computeSunRays(ROOMS, [WIN.west], 270, 60, 0);
|
||||
// polyclip readily splits a side at a touching vertex; the rim must still be
|
||||
// ONE line per side, not a string of them
|
||||
const poly = ray.polys[0];
|
||||
const split = [];
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
const p = poly[i];
|
||||
const q = poly[(i + 1) % poly.length];
|
||||
split.push(p, [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]);
|
||||
}
|
||||
assert.equal(split.length, 8, 'every edge of the wedge is now two');
|
||||
const cut = { ...ray, polys: [split] };
|
||||
const merged = rayRimEdges(cut);
|
||||
assert.equal(merged.length, 2, 'still one line per side, not four');
|
||||
for (const [p, q] of merged) {
|
||||
assert.ok(near(Math.hypot(q[0] - p[0], q[1] - p[1]), ray.len, 1e-6), 'the whole side');
|
||||
}
|
||||
// a wedge clipped away to nothing has no rim at all
|
||||
assert.deepEqual(rayRimEdges({ ...ray, polys: [] }), []);
|
||||
});
|
||||
|
||||
test('skyNeedsSnap / skyElevation: glide with the sun, jump when we were away', () => {
|
||||
assert.equal(SKY_SNAP_DEG, 3);
|
||||
assert.equal(skyNeedsSnap(null, 12), true); // nothing painted yet
|
||||
|
||||
Reference in New Issue
Block a user