Files
houseplan-card/demo/smoke_wall_thickness.mjs
2026-08-26 13:39:52 +03:00

349 lines
14 KiB
JavaScript

/**
* Wall thickness (docs/WALL-THICKNESS.md): tool, hatch body, floor area drops
* with thickness, opening cuts, centred/default and flipped door symbols,
* shared once, zero→line,
* degrade/rekey, safe Resize rejects a mixed-thickness shared wall.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
const hostContourOpenings = () => {
for (const opening of sp().openings || []) {
if (opening.host) continue;
const centre = [Number(opening.x), Number(opening.y)];
const candidates = (sp().wall_segments || []).filter((segment) => {
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > 0)) return false;
const t = ((centre[0] - segment.a[0]) * dx
+ (centre[1] - segment.a[1]) * dy) / len2;
const q = [segment.a[0] + dx * t, segment.a[1] + dy * t];
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
const delta = Math.abs((((angle - Number(opening.angle)) % 180) + 270) % 180 - 90);
const half = Number(opening.length) / 2;
const len = Math.sqrt(len2);
return t >= 0 && t <= 1 && Math.hypot(centre[0] - q[0], centre[1] - q[1]) < 1e-8
&& Math.min(delta, Math.abs(180 - delta)) < 1e-6
&& t * len >= half - 1e-9 && (1 - t) * len >= half - 1e-9;
});
if (candidates.length === 1) {
const segment = candidates[0];
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
const t = ((centre[0] - segment.a[0]) * dx
+ (centre[1] - segment.a[1]) * dy) / (dx * dx + dy * dy);
opening.host = { kind: 'wall', id: segment.id, t };
}
}
};
const floorAreaOf = (id) => {
const rooms = c._spaceModel().rooms;
const walls = sp().walls || [];
if (!walls.length) {
const r = rooms.find((x) => x.id === id);
const p = r?.poly;
if (!p) return 0;
let s = 0;
for (let i = 0; i < p.length; i++) {
const a = p[i], b = p[(i + 1) % p.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
const el = sr().querySelector(`[data-hp="room"][data-id="${id}"]`);
const pts = el?.getAttribute('points');
if (pts) {
const poly = pts.trim().split(/\s+/).map((t) => t.split(',').map(Number));
let s = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
s += a[0] * b[1] - b[0] * a[1];
}
return Math.abs(s) / 2;
}
return 0;
};
sp().settings = { ...(sp().settings || {}), show_borders: true, hide_openings: undefined };
delete sp().walls;
delete sp().openings;
for (const r of sp().rooms || []) delete r.open_to;
const areaBefore = floorAreaOf('r1');
c._setMode('plan');
c._tool = 'wallthick';
await upd();
out.toolArmed = c._tool === 'wallthick';
out.toolBtn = !!sr().querySelector('.editbar .btn.on ha-icon[icon="mdi:wall"]')
|| [...sr().querySelectorAll('.editbar .btn')].some((b) => b.classList.contains('on') && c._tool === 'wallthick');
const outer = [50, 250];
c._cursorPt = outer;
await upd();
out.hover = !!sr().querySelector('.wallthick-hover');
c._wallThickClick(outer);
await upd();
out.dialogOpen = !!c._wallDialog && !!sr().querySelector('.wallthick-dlg');
c._wallDialog = { ...c._wallDialog, value: '20' };
c._wallThickApply(false);
await upd();
out.bodyDrawn = sr().querySelectorAll('[data-hp="wall"]').length >= 1;
const areaAfter = floorAreaOf('r1');
out.areaShrinks = areaAfter > 0 && areaAfter < areaBefore - 1;
// A sun shaft starts at the two room-side corners of a thick opening, not
// on the wall centreline and not at an incidence-narrowed pseudo-aperture.
sp().openings = [{
id: 'wtSun', type: 'window', x: 0.04, y: 0.25, angle: 90, length: 0.09,
}];
hostContourOpenings();
sp().settings = { ...(sp().settings || {}), north_deg: 0, sun_rays: true };
c.hass = { ...c.hass, states: { ...c.hass.states, 'sun.sun': {
entity_id: 'sun.sun', state: 'above_horizon',
attributes: { azimuth: 240, elevation: 60 },
} } };
c._setMode('view');
await upd();
const sunRay = (c._sunRaysCache?.rays || []).find((r) => r.openingId === 'wtSun');
const sunDepth = (20 / c._cellCm) * c._gridPitch;
const ys = sunRay ? [sunRay.a[1], sunRay.b[1]].sort((a, b) => a - b) : [];
out.sunStartsInnerFace = !!sunRay
&& Math.abs(sunRay.a[0] - (40 + sunDepth / 2)) < 1e-6
&& Math.abs(sunRay.b[0] - (40 + sunDepth / 2)) < 1e-6;
out.sunStartsInnerCorners = !!sunRay
&& Math.abs(ys[0] - 205) < 1e-6 && Math.abs(ys[1] - 295) < 1e-6;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._tool = 'wallthick';
c._wallThickClick([550, 250]);
await upd();
c._wallDialog = { ...c._wallDialog, value: '25' };
c._wallThickApply(false);
await upd();
const shared = [...sr().querySelectorAll('[data-hp="wall"]')];
out.sharedOnce = shared.length === 1;
sp().openings = [{
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: false,
}];
hostContourOpenings();
await upd();
out.doorDrawn = !!sr().querySelector('[data-hp="opening"][data-kind="door"]');
const bodyD = shared[0]?.getAttribute('d') || sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
out.openingCutsSlab = (bodyD.match(/\bM\b/g) || []).length >= 2;
const doorBodyTransform = () => sr()
.querySelector('[data-hp="opening"][data-kind="door"] > g[transform^="scale("]'
+ ' > g[transform^="translate("]')
?.getAttribute('transform') || '';
out.doorDefaultCentered = /^translate\(0 0\)$/.test(doorBodyTransform());
sp().openings[0].flip_v = true;
await upd();
out.doorSavedFlipStaysCentered = /^translate\(\s*0\s+0\s*\)$/.test(doorBodyTransform());
const firstGateTurn = (id) => {
const transform = sr().querySelector(
`[data-hp="opening"][data-id="${id}"] .op-leaf`,
)?.style?.transform || '';
return Number(transform.match(/rotate\(([-+0-9.eE]+)deg\)/)?.[1]);
};
sp().openings = [
{ id: 'wtGateDefault', type: 'gate', x: 0.55, y: 0.20, angle: 90, length: 0.08,
flip_v: false },
{ id: 'wtGateFlipped', type: 'gate', x: 0.55, y: 0.30, angle: 90, length: 0.08,
flip_v: true },
];
hostContourOpenings();
await upd();
const sharedGateDefault = firstGateTurn('wtGateDefault');
const sharedGateFlipped = firstGateTurn('wtGateFlipped');
out.sharedGateFlipReversesTurn = Math.abs(sharedGateDefault) === 10
&& sharedGateFlipped === -sharedGateDefault;
const partitionsBefore = structuredClone(sp().partitions || []);
sp().partitions = [...partitionsBefore, {
id: 'wt-gate-partition', a: [0.15, 0.75], b: [0.45, 0.75], cm: 25,
}];
sp().openings = [
{ id: 'wtPartGateDefault', type: 'gate', x: 0.25, y: 0.75, angle: 0, length: 0.08,
flip_v: false, host: { kind: 'partition', id: 'wt-gate-partition', t: 1 / 3 } },
{ id: 'wtPartGateFlipped', type: 'gate', x: 0.35, y: 0.75, angle: 0, length: 0.08,
flip_v: true, host: { kind: 'partition', id: 'wt-gate-partition', t: 2 / 3 } },
];
await upd();
const partitionGateDefault = firstGateTurn('wtPartGateDefault');
const partitionGateFlipped = firstGateTurn('wtPartGateFlipped');
out.partitionGateFlipReversesTurn = Math.abs(partitionGateDefault) === 10
&& partitionGateFlipped === -partitionGateDefault;
sp().partitions = partitionsBefore;
sp().openings = [{
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: true,
}];
hostContourOpenings();
await upd();
sp().settings = { ...(sp().settings || {}), hide_openings: true, show_borders: true };
c._setMode('view');
await upd();
const cutD = sr().querySelector('[data-hp="wall"]')?.getAttribute('d') || '';
out.hideKeepsCut = sr().querySelectorAll('[data-hp="opening"]').length === 0
&& (cutD.match(/\bM\b/g) || []).length >= 2;
c._setMode('plan');
c._tool = 'wallthick';
await upd();
c._wallThickClick(outer);
await upd();
c._wallDialog = { ...c._wallDialog, value: '' };
c._toast = null;
c._wallThickApply(false);
await upd();
out.emptyRejected = !!c._wallDialog && !!c._toast
&& c._intervalCm([40, 180, 40, 420]) === 20;
c._wallDialog = { ...c._wallDialog, value: '0' };
c._wallThickApply(false);
await upd();
out.zeroApplied = c._intervalCm([40, 180, 40, 420]) === 0;
sp().walls = [...(sp().walls || []), { key: '9.99,9.99@1.5708', cm: 15 }];
c._dropLegacySegments();
out.degrade = !(sp().walls || []).some((w) => w.key.startsWith('9.99'));
// The current door is hosted by the shared wall. Turning that carrier into
// a zero wall is rejected atomically; after removing the opening, zero and
// a later positive thickness use the ordinary Thickness flow.
c._tool = 'wallthick';
c._toast = null;
c._wallThickClick([550, 250]);
await upd();
c._wallDialog = { ...c._wallDialog, value: '0' };
c._wallThickApply(false);
await upd();
out.openingBlocksZero = !!c._wallDialog && !!c._toast
&& c._intervalCm([550, 200, 550, 300]) === 25;
delete sp().openings;
c._wallDialog = { ...c._wallDialog, value: '0' };
c._wallThickApply(false);
await upd();
out.sharedZero = c._intervalCm([550, 200, 550, 300]) === 0;
c._wallThickClick([550, 250]);
await upd();
if (c._wallDialog) {
c._wallDialog = { ...c._wallDialog, value: '30' };
c._wallThickApply(false);
await upd();
}
out.rekeyReady = !!(sp().walls || []).find((w) => w.cm === 30);
// #277: this shared wall deliberately contains 25/30 cm atomic records.
// The handle remains visible but Resize must refuse it without creating a
// history command or changing any persisted thickness record.
c._setMode('plan');
c._tool = 'resize';
c._wallDialog = null;
await upd();
const stageEl = () => sr().querySelector('.stage');
const toScreen = (x, y) => {
const r = stageEl().getBoundingClientRect();
const v = c._viewOr(c._baseVb());
return {
clientX: r.left + ((x - v.x) / v.w) * r.width,
clientY: r.top + ((y - v.y) / v.h) * r.height,
};
};
const pev = (type, target, x, y) => {
if (!target) return;
const { clientX, clientY } = toScreen(x, y);
target.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true,
pointerId: 42, clientX, clientY, button: 0, isPrimary: true,
}));
};
const handles = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')];
let sharedHandle = null;
for (const h of handles) {
const cx = +h.getAttribute('cx'), cy = +h.getAttribute('cy');
if (Math.abs(cx - 550) < c._gridPitch * 2 && cy > 150 && cy < 450) {
sharedHandle = h;
break;
}
}
out.resizeEdgeFound = !!sharedHandle;
const wallsBefore = JSON.stringify(sp().walls || []);
const historyBefore = c._geometryHistory.size;
if (sharedHandle) {
const cx = +sharedHandle.getAttribute('cx');
const cy = +sharedHandle.getAttribute('cy');
const step = c._gridPitch;
out.mixedThicknessResizeDisabled = sharedHandle.getAttribute('aria-disabled') === 'true';
pev('pointerdown', sharedHandle, cx, cy);
pev('pointermove', sharedHandle, cx + step, cy);
pev('pointerup', sharedHandle, cx + step, cy);
await upd();
out.disabledResizeKeepsWalls = JSON.stringify(sp().walls || []) === wallsBefore;
out.disabledResizeCreatesNoHistory = c._geometryHistory.size === historyBefore;
} else {
c._dropLegacySegments();
out.mixedThicknessResizeDisabled = false;
out.disabledResizeKeepsWalls = false;
out.disabledResizeCreatesNoHistory = false;
}
out.pixelProbe = !!sr().querySelector('.wallbody');
// AUD-159B7-04: the same screen-depth policy owns both cards. At 1 cm the
// hatch must be suppressed in full and static views; at 20 cm both restore
// it. The static card measures its actual stage width via ResizeObserver.
c._setMode('view');
const parityCfg = JSON.parse(JSON.stringify(c._serverCfg));
const paritySp = parityCfg.spaces.find((x) => x.id === c._space);
paritySp.settings = { ...(paritySp.settings || {}), show_borders: true };
paritySp.walls = (paritySp.walls || []).map((w) => ({ ...w, cm: 1 }));
c._serverCfg = parityCfg;
await upd();
await customElements.whenDefined('houseplan-space-card');
const baseCall = c.hass.callWS.bind(c.hass);
const staticHass = { ...c.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: parityCfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(m);
} };
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = staticHass;
document.body.appendChild(staticCard);
const t0 = Date.now();
while ((!staticCard.renderRoot?.querySelector('.wallbody') || !(staticCard._stageWidth > 0))
&& Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await staticCard.updateComplete;
out.thinWallFullSolid = !!sr().querySelector('.wallbody.solid');
out.thinWallStaticSolid = !!staticCard.renderRoot?.querySelector('.wallbody.solid');
for (const w of paritySp.walls || []) w.cm = 20;
c._cfgEpoch++;
c.requestUpdate();
staticCard.requestUpdate();
await c.updateComplete;
await staticCard.updateComplete;
out.thickWallFullHatched = !!sr().querySelector('.wallbody:not(.solid)');
out.thickWallStaticHatched = !!staticCard.renderRoot?.querySelector('.wallbody:not(.solid)');
staticCard.remove();
return out;
});
checkAll(res);
await finish(browser, res);