mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
252 lines
11 KiB
JavaScript
252 lines
11 KiB
JavaScript
/**
|
|
* #231: decor is one composition layer above every floor treatment and below
|
|
* live light, sun, physical geometry, opening symbols and HTML overlays.
|
|
*
|
|
* The raster probes are intentional. A DOM-only assertion used to pass while
|
|
* an opaque room fill still erased decor from the rendered plan.
|
|
*/
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
const { page, browser } = await launch({ width: 820, height: 760 });
|
|
const stage = page.locator('houseplan-card').locator('.stage').first();
|
|
|
|
const fixture = await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
const root = () => c.shadowRoot || c.renderRoot;
|
|
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
|
const pitch = 1 / 240;
|
|
const wallKey = (a, b) => {
|
|
const q = (value) => Math.round(value / pitch) * pitch;
|
|
let dx = b[0] - a[0], dy = b[1] - a[1];
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
dx /= length; dy /= length;
|
|
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
|
let angle = Math.atan2(dy, dx);
|
|
if (angle < 0) angle += Math.PI;
|
|
angle = Math.round(angle * 1800) / 1800;
|
|
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
|
|
};
|
|
const wall = (a, b) => ({ key: wallKey(a, b), a, b, cm: 30 });
|
|
const update = async () => {
|
|
c._cfgEpoch++;
|
|
c.requestUpdate();
|
|
await c.updateComplete;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
};
|
|
const roomPoly = [[0.1, 0.15], [0.9, 0.15], [0.9, 0.85], [0.1, 0.85]];
|
|
sp.plan_url = null;
|
|
sp.view_box = [0, 0, 1, 1];
|
|
sp.cell_cm = 5;
|
|
sp.rooms = [{
|
|
id: 'decor-order-room', name: 'Decor room', area: 'decor_order_area', poly: roomPoly,
|
|
settings: {},
|
|
}];
|
|
sp.walls = roomPoly.map((a, index) => wall(a, roomPoly[(index + 1) % roomPoly.length]));
|
|
sp.openings = [
|
|
{ id: 'decor-order-window', type: 'window', x: 0.5, y: 0.15, angle: 0, length: 0.24 },
|
|
];
|
|
sp.decor = [
|
|
{ id: 'decor-order-line', kind: 'line', x1: 0.5, y1: 0.04, x2: 0.5, y2: 0.96,
|
|
color: '#ff0033', opacity: 1, width_cm: 8 },
|
|
{ id: 'decor-order-rect', kind: 'rect', x: 0.18, y: 0.27, w: 0.18, h: 0.12,
|
|
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
|
fill_color: '#ff6680', fill_opacity: 1 },
|
|
{ id: 'decor-order-ellipse', kind: 'ellipse', x: 0.64, y: 0.27, w: 0.18, h: 0.12,
|
|
color: '#ff0033', opacity: 1, width_cm: 2, fill: true,
|
|
fill_color: '#ff6680', fill_opacity: 1 },
|
|
{ id: 'decor-order-text', kind: 'text', x: 0.27, y: 0.68, text: 'DECOR',
|
|
color: '#ff0033', opacity: 1, size_cm: 14 },
|
|
{ id: 'decor-order-furniture', kind: 'furniture', symbol: 'sofa',
|
|
x: 0.64, y: 0.61, w: 0.18, h: 0.13, color: '#ff0033', opacity: 1, width_cm: 2 },
|
|
];
|
|
sp.settings = {
|
|
...(sp.settings || {}), fill_mode: 'custom', custom_fill: { c: '#2255cc', a: 1 },
|
|
show_borders: false, show_names: false, hide_openings: true,
|
|
hide_decor: false, glow_enabled: false, sun_rays: false,
|
|
};
|
|
c._setMode('view');
|
|
await update();
|
|
|
|
const svg = root().querySelector('.stage .zoomwrap > svg');
|
|
const stageRect = root().querySelector('.stage').getBoundingClientRect();
|
|
const ctm = svg.getScreenCTM();
|
|
const toStage = (x, y) => {
|
|
const point = svg.createSVGPoint(); point.x = x; point.y = y;
|
|
const screen = point.matrixTransform(ctm);
|
|
return [screen.x - stageRect.left, screen.y - stageRect.top];
|
|
};
|
|
const decor = root().querySelector('.decorlayer');
|
|
const room = root().querySelector('[data-hp="room"][data-id="decor-order-room"]');
|
|
const dataTunnel = root().querySelector(
|
|
'.opening-tunnels[data-layer="data"] [data-id="decor-order-window"]',
|
|
);
|
|
const follows = (lower, upper) => !!lower && !!upper
|
|
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
const kinds = new Set([...root().querySelectorAll('.decorlayer [data-hp="decor"]')]
|
|
.map((node) => node.dataset.kind));
|
|
return {
|
|
before: {
|
|
allDecorKindsRender: ['line', 'rect', 'ellipse', 'text', 'furniture']
|
|
.every((kind) => kinds.has(kind)),
|
|
oneDecorLayer: root().querySelectorAll('.decorlayer').length === 1,
|
|
dataFloorBeforeDecor: follows(room, decor) && follows(dataTunnel, decor),
|
|
dataTunnelExists: !!dataTunnel,
|
|
},
|
|
probes: { room: toStage(500, 500), tunnel: toStage(500, 150) },
|
|
stored: JSON.stringify(sp.decor),
|
|
};
|
|
});
|
|
|
|
const customShot = await stage.screenshot({ animations: 'disabled' });
|
|
|
|
const hover = await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
const root = c.shadowRoot || c.renderRoot;
|
|
const room = root.querySelector('[data-hp="room"][data-id="decor-order-room"]');
|
|
room?.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' }));
|
|
await c.updateComplete;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const hoverFill = root.querySelector('.room-hover-fill-layer');
|
|
const decor = root.querySelector('.decorlayer');
|
|
return {
|
|
hoverFillExists: !!hoverFill,
|
|
hoverBeforeDecor: !!hoverFill && !!decor
|
|
&& !!(hoverFill.compareDocumentPosition(decor) & Node.DOCUMENT_POSITION_FOLLOWING),
|
|
};
|
|
});
|
|
const hoverShot = await stage.screenshot({ animations: 'disabled' });
|
|
|
|
const glow = await page.evaluate(async () => {
|
|
const c = window.__card;
|
|
const root = c.shadowRoot || c.renderRoot;
|
|
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
|
root.querySelector('[data-hp="room"][data-id="decor-order-room"]')?.dispatchEvent(
|
|
new PointerEvent('pointerleave', { pointerType: 'mouse' }),
|
|
);
|
|
sp.rooms[0].settings = {};
|
|
sp.settings = {
|
|
...sp.settings, fill_mode: 'glow', glow_enabled: true,
|
|
show_borders: false, hide_openings: true, sun_rays: false,
|
|
};
|
|
c._serverCfg.settings = {
|
|
...(c._serverCfg.settings || {}),
|
|
fill_colors: {
|
|
...(c._serverCfg.settings?.fill_colors || {}),
|
|
glow_base: { c: '#2255cc', a: 1 },
|
|
},
|
|
};
|
|
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const base = root.querySelector('.glow-base-layer');
|
|
const tunnel = root.querySelector(
|
|
'.opening-tunnels[data-layer="glow-base"] [data-id="decor-order-window"]',
|
|
);
|
|
const decor = root.querySelector('.decorlayer');
|
|
const liveGlow = root.querySelector('.glow-pools-frame');
|
|
const follows = (lower, upper) => !!lower && !!upper
|
|
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
return {
|
|
glowBaseExists: !!base,
|
|
glowTunnelExists: !!tunnel,
|
|
glowBaseBeforeDecor: follows(base, decor) && follows(tunnel, decor),
|
|
decorBeforeLiveGlow: follows(decor, liveGlow),
|
|
};
|
|
});
|
|
const glowShot = await stage.screenshot({ animations: 'disabled' });
|
|
|
|
const decodePixels = await page.evaluate(async ({ shots, probes }) => {
|
|
const decode = async (base64) => {
|
|
const image = new Image();
|
|
image.src = `data:image/png;base64,${base64}`;
|
|
await image.decode();
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = image.width; canvas.height = image.height;
|
|
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
context.drawImage(image, 0, 0);
|
|
return { width: image.width, height: image.height,
|
|
pixels: context.getImageData(0, 0, image.width, image.height).data };
|
|
};
|
|
const images = await Promise.all(shots.map(decode));
|
|
const isDecorRed = ({ width, height, pixels }, [rawX, rawY]) => {
|
|
let red = 0; let samples = 0;
|
|
const cx = Math.round(rawX), cy = Math.round(rawY);
|
|
for (let y = cy - 2; y <= cy + 2; y++) for (let x = cx - 2; x <= cx + 2; x++) {
|
|
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
|
const offset = (y * width + x) * 4;
|
|
const r = pixels[offset], g = pixels[offset + 1], b = pixels[offset + 2];
|
|
if (r > 180 && r > g * 1.8 && r > b * 1.35) red++;
|
|
samples++;
|
|
}
|
|
return samples > 0 && red / samples >= 0.6;
|
|
};
|
|
const verdict = (image) => ({
|
|
room: isDecorRed(image, probes.room),
|
|
tunnel: isDecorRed(image, probes.tunnel),
|
|
});
|
|
return images.map(verdict);
|
|
}, {
|
|
shots: [customShot, hoverShot, glowShot].map((shot) => shot.toString('base64')),
|
|
probes: fixture.probes,
|
|
});
|
|
|
|
const parity = await page.evaluate(async (stored) => {
|
|
const c = window.__card;
|
|
const root = c.shadowRoot || c.renderRoot;
|
|
const sp = c._serverCfg.spaces.find((space) => space.id === c._space);
|
|
const update = async () => {
|
|
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
};
|
|
sp.settings = {
|
|
...sp.settings, show_borders: true, hide_openings: false,
|
|
sun_rays: true, north_deg: 0,
|
|
};
|
|
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
|
|
entity_id: 'sun.sun', state: 'above_horizon',
|
|
attributes: { azimuth: 0, elevation: 25, rising: true },
|
|
} } };
|
|
await update();
|
|
const decor = root.querySelector('.decorlayer');
|
|
const follows = (lower, upper) => !!lower && !!upper
|
|
&& !!(lower.compareDocumentPosition(upper) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
const liveGlow = root.querySelector('.glow-pools-frame');
|
|
const sun = root.querySelector('.sunlayer');
|
|
const walls = root.querySelector('.wallbodies');
|
|
const opening = root.querySelector('[data-hp="opening"][data-id="decor-order-window"]');
|
|
const devLayer = root.querySelector('.devlayer');
|
|
const paper = root.querySelector('.hp-paperg');
|
|
const out = {
|
|
backdropBeforeDecor: follows(paper, decor),
|
|
liveGlowAfterDecor: follows(decor, liveGlow),
|
|
sunAfterDecor: follows(decor, sun),
|
|
wallsAfterDecor: follows(decor, walls),
|
|
openingSymbolsAfterDecor: follows(decor, opening),
|
|
htmlDevicesAndLabelsAfterDecor: follows(decor, devLayer),
|
|
renderDoesNotRewriteDecor: JSON.stringify(sp.decor) === stored,
|
|
};
|
|
|
|
sp.settings.hide_decor = true;
|
|
c._setMode('view'); await update();
|
|
out.hideDecorStillHidesView = !root.querySelector('.decorlayer');
|
|
c._setMode('decor'); await update();
|
|
out.ownEditorOverridesHide = !!root.querySelector('.decorlayer')
|
|
&& root.querySelectorAll('.decorlayer [data-hp="decor"]').length >= 5;
|
|
return out;
|
|
}, fixture.stored);
|
|
|
|
const result = {
|
|
...fixture.before,
|
|
...hover,
|
|
...glow,
|
|
customFillKeepsDecorPixels: decodePixels[0].room,
|
|
customTunnelKeepsDecorPixels: decodePixels[0].tunnel,
|
|
hoverDoesNotTintDecorPixels: decodePixels[1].room,
|
|
hoverTunnelKeepsDecorPixels: decodePixels[1].tunnel,
|
|
glowBaseKeepsDecorPixels: decodePixels[2].room,
|
|
glowTunnelKeepsDecorPixels: decodePixels[2].tunnel,
|
|
...parity,
|
|
};
|
|
|
|
checkAll(result);
|
|
await finish(browser, result);
|