Files
houseplan-card/demo/smoke_wall_thickness_transition.mjs
Claude e5ab4659f6 feat: 2.5D stage 6 — public setting, raised tiles, soft sun, theme-free walls (#649)
- 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
2026-09-25 18:55:14 +03:00

144 lines
6.0 KiB
JavaScript

/**
* #150: a production-scale Split must preserve the exact 10 cm → 0 cm
* exterior breakpoint in every consumer of canonical wall geometry.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 850 });
const result = await page.evaluate(async () => {
const out = {};
const card = window.__card;
const root = () => card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => {
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
await frame();
};
const containsPath = (path, x, y) => !!path?.isPointInFill(new DOMPoint(x, y));
const containsD = (d, x, y) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d || '');
path.setAttribute('fill-rule', 'evenodd');
svg.appendChild(path);
document.body.appendChild(svg);
const inside = containsPath(path, x, y);
svg.remove();
return inside;
};
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]) + 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 space = card._serverCfg.spaces.find((item) => item.id === card._space);
space.rooms = [{
id: 'split-source', name: 'Source', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
}];
delete space.walls;
delete space.openings;
delete space.open_spans;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
space.settings = { ...(space.settings || {}), show_borders: true };
card._cfgEpoch++;
card._regSignature = '';
card._setMode('plan');
card.requestUpdate();
await settle();
// Exercise the real Plan handlers: select room, choose both wall endpoints,
// confirm the child, then apply thickness to all walls of the left child.
card._tool = 'split';
card._splitClick([300, 300]);
card._splitClick([500, 100]);
card._splitClick([500, 900]);
out.splitDialog = !!card._pendingSplit && !!card._roomDialog;
card._nameSel = 'Right';
card._saveRoom();
await settle();
const rooms = card._spaceModel().rooms;
const left = rooms.reduce((best, room) => {
const meanX = room.poly.reduce((sum, point) => sum + point[0], 0) / room.poly.length;
return !best || meanX < best.meanX ? { room, meanX } : best;
}, null)?.room;
out.splitCommitted = rooms.length === 2 && !!left;
card._tool = 'wallthick';
card._wallThickClick([300, 100]);
out.leftWallSelected = card._wallDialog?.roomId === left?.id;
card._wallDialog = { ...card._wallDialog, value: '10' };
card._wallThickApply(true);
await settle();
out.savedIntervals = card._intervalCm([100, 100, 500, 100]) === 10
&& card._intervalCm([500, 100, 900, 100]) === 0
&& card._intervalCm([500, 100, 500, 900]) === 10;
const half = (10 / card._cellCm) * card._gridPitch / 2;
const planPath = root().querySelector('[data-hp="wall"]');
const planD = planPath?.getAttribute('d') || '';
out.planFullDepth = containsPath(planPath, 300, 100 - half * 0.75)
&& containsPath(planPath, 300, 100 + half * 0.75);
out.planZeroSideClear = !containsPath(planPath, 700, 96)
&& !containsPath(planPath, 700, 104);
const canonical = card._wallUnionGeometry();
out.paperZeroSideClear = !!canonical?.paperD && !containsD(canonical.paperD, 700, 96);
card._setMode('view');
await settle();
const viewPath = root().querySelector('[data-hp="wall"]');
const viewD = viewPath?.getAttribute('d') || '';
out.viewMatchesPlan = !!planD && viewD === planD
&& !containsPath(viewPath, 700, 96) && containsPath(viewPath, 300, 96);
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.lightUsesSteppedMasonry = pointInGeometry([300, 96], barriers.masonryGeometry)
&& !pointInGeometry([700, 96], barriers.masonryGeometry);
await customElements.whenDefined('houseplan-space-card');
const staticCfg = JSON.parse(JSON.stringify(card._serverCfg));
const baseCall = card.hass.callWS.bind(card.hass);
const staticHass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: staticCfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: card._layout || {}, rev: 1 };
return baseCall(message);
} };
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: card._space, show_button: false });
staticCard.hass = staticHass;
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.staticMatchesPlan = staticPath?.getAttribute('d') === planD
&& !containsPath(staticPath, 700, 96) && containsPath(staticPath, 300, 96);
staticCard.remove();
await window.__hpHarnessProjection(card, 'iso');
await window.__hpEnsureHarnessIsoRuntime(card);
await settle();
const isoWalls = card._isoSource().build().walls;
out.hiddenIsoMatchesCanonical = !!root().querySelector('[data-hp="iso-walls"]')
&& pointInGeometry([300, 96], isoWalls) && !pointInGeometry([700, 96], isoWalls);
return out;
});
checkAll(result);
await finish(browser, result);