mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
349 lines
14 KiB
JavaScript
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);
|