Files
houseplan-card/demo/smoke_decor_layer_order.mjs
2026-09-02 21:56:04 +00:00

261 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]];
const imageAssetId = '7'.repeat(64);
c._decorAssets = new Map([[imageAssetId, {
asset_id: imageAssetId, name: 'layer-order.svg', mime: 'image/svg+xml',
width: 2, height: 1, bytes: 100,
url: 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 1"%3E%3Cpath fill="%23ff0033" d="M0 0h2v1H0z"/%3E%3C/svg%3E',
used_by: [],
}]]);
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 },
{ id: 'decor-order-image', kind: 'image', asset_id: imageAssetId,
x: 0.76, y: 0.72, w: 0.08, h: 0.04, opacity: 1 },
];
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', 'image']
.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 >= 6;
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);