mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
- settings.volumetric_view (General settings › Display, third switch) replaces the alpha entry, the header projection toggle and the phone-menu item; one rule for card, sidebar page and kiosk; editors stay Flat; backend accepts only a boolean, support package copies it. - Raised tiles in 2.5D View: no ring, rounded body min(0.275 D, 0.3 h), edge 0.1 D with the lab filters, ×1.12 size also in layout/collision, lift 0.075 D, one floor-shadow layer under all markers (theme × floor table), frames hugging tile and edge (Alert > Focus > Selected > Hover), forced colours without edge and shadow (src/iso-tiles.ts, styles/iso-tiles). - Soft sun wash instead of projected Flat wedges (src/iso-sun.ts): same gates and windows as sun_rays, length from elevation, parallelogram along the sun, tone and streaks by floor lightness, sill line. - Walls take the user's wall colour (top opaque, sides × .77/.68/.60); theme rules for walls/openings/labels removed; furniture uses the Flat stroke rule. - Smokes smoke_iso_tiles/iso_sun/iso_theme_walls/volumetric_setting, 16 new mutants, acceptance scenes STAGE6_ACCEPTANCE_SCENARIOS (golden with their Linux CI baselines), ACCEPTANCE.md side by side. Issue: #649 User-Visible: yes
228 lines
9.0 KiB
JavaScript
228 lines
9.0 KiB
JavaScript
/** Issue #275: exact-class orthogonal strips survive every canonical consumer. */
|
|
import { readFileSync } from 'node:fs';
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
const fixture = JSON.parse(readFileSync(
|
|
new URL('../test/fixtures/275-orthogonal-strip-containment.json', import.meta.url), 'utf8',
|
|
));
|
|
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
|
|
|
|
const result = await page.evaluate(async (source) => {
|
|
const out = {};
|
|
const card = window.__card;
|
|
const root = () => card.shadowRoot || card.renderRoot;
|
|
const frame = () => new Promise((done) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(done)));
|
|
const settle = async () => {
|
|
await card.updateComplete;
|
|
while (card._modeTransitionBusy) await frame();
|
|
await frame();
|
|
};
|
|
const update = async (structural = false) => {
|
|
if (structural) {
|
|
card._cfgEpoch++;
|
|
card._modelCache = null;
|
|
card._frame = null;
|
|
card._wallUnionCache = null;
|
|
card._physicalBodiesCache = null;
|
|
card._lightBarrierCache = null;
|
|
card._isoGeometryCache.clear();
|
|
}
|
|
card.requestUpdate();
|
|
await settle();
|
|
};
|
|
const pointInRing = ([x, y], ring) => {
|
|
let inside = false;
|
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
const a = ring[i], b = ring[j];
|
|
if ((a[1] > y) !== (b[1] > y)
|
|
&& x < ((b[0] - a[0]) * (y - a[1])) / ((b[1] - a[1]) || 1e-30) + a[0]) {
|
|
inside = !inside;
|
|
}
|
|
}
|
|
return inside;
|
|
};
|
|
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) =>
|
|
polygon?.length && pointInRing(point, polygon[0])
|
|
&& !(polygon || []).slice(1).some((hole) => pointInRing(point, hole)));
|
|
const onSegment = (point, a, b) => {
|
|
const dx = b[0] - a[0], dy = b[1] - a[1];
|
|
const length2 = dx * dx + dy * dy;
|
|
if (!(length2 > 0)) return false;
|
|
const t = ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / length2;
|
|
const x = a[0] + dx * t, y = a[1] + dy * t;
|
|
return t >= -1e-7 && t <= 1 + 1e-7
|
|
&& Math.hypot(x - point[0], y - point[1]) <= 1e-4;
|
|
};
|
|
const stripSamples = (item) => {
|
|
const samples = [];
|
|
for (const storedNode of item.nodes) {
|
|
const node = storedNode.map((value) => value * 1000);
|
|
const incident = item.walls.filter((wall) => onSegment(storedNode, wall.a, wall.b));
|
|
const maxHalf = Math.max(...incident.map((wall) =>
|
|
((wall.cm / item.cell_cm) * (1000 / 240)) / 2));
|
|
const radius = maxHalf * 4;
|
|
for (const wall of incident) {
|
|
const a = wall.a.map((value) => value * 1000);
|
|
const b = wall.b.map((value) => value * 1000);
|
|
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
|
const u = [(b[0] - a[0]) / length, (b[1] - a[1]) / length];
|
|
const n = [-u[1], u[0]];
|
|
const half = ((wall.cm / item.cell_cm) * (1000 / 240)) / 2;
|
|
const ta = (a[0] - node[0]) * u[0] + (a[1] - node[1]) * u[1];
|
|
const tb = (b[0] - node[0]) * u[0] + (b[1] - node[1]) * u[1];
|
|
const lo = Math.max(Math.min(ta, tb), -radius);
|
|
const hi = Math.min(Math.max(ta, tb), radius);
|
|
const tStep = Math.max(half / 5, 0.25);
|
|
const sStep = Math.max(half / 5, 0.25);
|
|
for (let t = lo + tStep / 2; t < hi - tStep / 4; t += tStep) {
|
|
for (let s = -half + sStep / 2; s < half - sStep / 4; s += sStep) {
|
|
samples.push([
|
|
node[0] + u[0] * t + n[0] * s,
|
|
node[1] + u[1] * t + n[1] * s,
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return samples;
|
|
};
|
|
const svgMisses = (path, samples) => samples.filter((point) =>
|
|
!path?.isPointInFill?.(new DOMPoint(point[0], point[1]))).length;
|
|
const paperMisses = (samples) => {
|
|
const paths = [...root().querySelectorAll('.hp-paper')];
|
|
return samples.filter((point) => !paths.some((path) =>
|
|
path.isPointInFill?.(new DOMPoint(point[0], point[1])))).length;
|
|
};
|
|
|
|
const makeSpace = (item) => {
|
|
const xs = item.rooms.flatMap((room) => room.poly.map((point) => point[0]));
|
|
const ys = item.rooms.flatMap((room) => room.poly.map((point) => point[1]));
|
|
const pad = 0.08;
|
|
return {
|
|
id: `issue-275-${item.id}`,
|
|
title: 'Issue 275',
|
|
cell_cm: item.cell_cm,
|
|
view_box: [
|
|
Math.min(...xs) - pad,
|
|
Math.min(...ys) - pad,
|
|
Math.max(...xs) - Math.min(...xs) + pad * 2,
|
|
Math.max(...ys) - Math.min(...ys) + pad * 2,
|
|
],
|
|
rooms: structuredClone(item.rooms),
|
|
walls: structuredClone(item.walls),
|
|
settings: { show_borders: true, fill_mode: 'none' },
|
|
};
|
|
};
|
|
// The embedded cards share one production config cache. Serve both fixtures
|
|
// in that one immutable snapshot so the second assertion cannot accidentally
|
|
// reuse the first space under a fake revision.
|
|
const staticCfg = {
|
|
...structuredClone(card._serverCfg),
|
|
spaces: source.cases.map(makeSpace),
|
|
markers: [],
|
|
};
|
|
|
|
for (const item of source.cases) {
|
|
const space = makeSpace(item);
|
|
const cfg = { ...structuredClone(card._serverCfg), spaces: [space], markers: [] };
|
|
card._serverCfg = structuredClone(cfg);
|
|
card._layout = {};
|
|
card._space = space.id;
|
|
card._setMode('plan');
|
|
card._tool = 'select';
|
|
await update(true);
|
|
|
|
const prefix = item.id.replaceAll('-', '_');
|
|
const samples = stripSamples(item);
|
|
const persisted = JSON.stringify(card._serverCfg.spaces[0]);
|
|
const path = root().querySelector('[data-hp="wall"]');
|
|
const canonical = card._wallUnionGeometry();
|
|
out[`${prefix}_has_dense_semantic_samples`] = samples.length >= 300;
|
|
out[`${prefix}_plan_has_all_strips`] = svgMisses(path, samples) === 0;
|
|
out[`${prefix}_room_geometry_has_all_strips`] = samples.every((point) =>
|
|
pointInGeometry(point, canonical?.roomGeom));
|
|
out[`${prefix}_paper_has_all_strips`] = paperMisses(samples) === 0;
|
|
out[`${prefix}_plan_uses_canonical_path`] = !!path
|
|
&& path.getAttribute('d') === canonical?.d;
|
|
|
|
const model = card._spaceModel();
|
|
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
|
|
const barriers = card._lightBarriers(model, polys, card._physicalBodiesR(model));
|
|
out[`${prefix}_light_has_all_strips`] = samples.every((point) =>
|
|
pointInGeometry(point, barriers.masonryGeometry));
|
|
const wallCache = card._wallUnionCache;
|
|
const barrierFingerprint = barriers.fingerprint;
|
|
|
|
card.hass = {
|
|
...card.hass,
|
|
states: {
|
|
...card.hass.states,
|
|
[`sensor.issue_275_${prefix}`]: {
|
|
entity_id: `sensor.issue_275_${prefix}`, state: '1', attributes: {},
|
|
},
|
|
},
|
|
};
|
|
await update(false);
|
|
out[`${prefix}_state_tick_reuses_wall_geometry`] = card._wallUnionCache === wallCache;
|
|
out[`${prefix}_state_tick_reuses_light_geometry`] = card._lightBarriers(
|
|
model, polys, card._physicalBodiesR(model),
|
|
).fingerprint === barrierFingerprint;
|
|
|
|
card._setMode('view');
|
|
await update(false);
|
|
out[`${prefix}_view_has_all_strips`] = svgMisses(
|
|
root().querySelector('[data-hp="wall"]'), samples,
|
|
) === 0;
|
|
|
|
const kioskBefore = card._config.kiosk;
|
|
card._config.kiosk = true;
|
|
await update(false);
|
|
out[`${prefix}_kiosk_has_all_strips`] = svgMisses(
|
|
root().querySelector('[data-hp="wall"]'), samples,
|
|
) === 0;
|
|
card._config.kiosk = kioskBefore;
|
|
await update(false);
|
|
|
|
await customElements.whenDefined('houseplan-space-card');
|
|
const staticCard = document.createElement('houseplan-space-card');
|
|
const baseCall = card.hass.callWS.bind(card.hass);
|
|
staticCard.setConfig({
|
|
type: 'custom:houseplan-space-card', space: space.id, show_button: false,
|
|
});
|
|
staticCard.hass = { ...card.hass, callWS: async (message) => {
|
|
if (message.type === 'houseplan/config/get') {
|
|
return { config: structuredClone(staticCfg), rev: 1 };
|
|
}
|
|
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
|
|
return baseCall(message);
|
|
} };
|
|
document.body.appendChild(staticCard);
|
|
const started = Date.now();
|
|
while (!staticCard.renderRoot?.querySelector('[data-hp="wall"]')
|
|
&& Date.now() - started < 6000) {
|
|
await new Promise((done) => setTimeout(done, 60));
|
|
}
|
|
await staticCard.updateComplete;
|
|
const staticPath = staticCard.renderRoot?.querySelector('[data-hp="wall"]');
|
|
out[`${prefix}_static_matches_plan`] = staticPath?.getAttribute('d')
|
|
=== path?.getAttribute('d');
|
|
out[`${prefix}_static_has_all_strips`] = svgMisses(staticPath, samples) === 0;
|
|
staticCard.remove();
|
|
await window.__hpHarnessProjection(card, 'iso');
|
|
await window.__hpEnsureHarnessIsoRuntime(card);
|
|
await update(false);
|
|
const isoWalls = card._isoSource().build().walls;
|
|
out[`${prefix}_hidden_iso_has_all_strips`] = samples.every((point) =>
|
|
pointInGeometry(point, isoWalls));
|
|
await window.__hpHarnessProjection(card, 'flat');
|
|
out[`${prefix}_render_never_writes_config`] = JSON.stringify(
|
|
card._serverCfg.spaces[0],
|
|
) === persisted;
|
|
}
|
|
return out;
|
|
}, fixture);
|
|
|
|
checkAll(result);
|
|
await finish(browser, result);
|