Files
houseplan-card/demo/smoke_active_chain_ink.mjs
2026-09-06 17:38:47 +03:00

146 lines
6.2 KiB
JavaScript

/**
* Issue #307: the active wall-chain axis and nodes must stay visible on
* already-placed segments. Every click persists the segment as an ordinary
* partition,
* whose opaque masonry paints above the markup layer; the chain ink therefore
* lives in its own layer between the wall bodies and the snap overlay.
*/
import { launch, check, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const NORM_W = 1000;
const chain = [[0.2, 0.3], [0.5, 0.3], [0.5, 0.55]];
const partitionIds = ['partition-307-a', 'partition-307-b'];
const cfg = {
model_version: 10,
spaces: [{
id: 'ink', title: 'Ink', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [{ id: 'room', name: 'Room', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.25], [0.1, 0.25]] }],
wall_segments: [],
partitions: chain.slice(1).map((b, index) => ({
id: partitionIds[index], a: chain[index], b, cm: 20,
})),
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(cfg));
card._layout = {};
card._space = 'ink';
card._modelCache = null;
card._frame = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'draw';
card._path = chain.map((p) => [p[0] * NORM_W, p[1] * NORM_W]);
card._activeWallChainId = 'chain-307';
card._activeWallChainPartitionIds = partitionIds;
card._wallChainSegmentCms = [20, 20];
card._clearPlanSnapHover();
await update();
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + ((0.75 * NORM_W - view.x) / view.w) * rect.width,
clientY: rect.top + ((0.55 * NORM_W - view.y) / view.h) * rect.height,
bubbles: true, pointerId: 71, pointerType: 'mouse',
}));
await update();
const pathline = root().querySelector('polyline.pathline');
const vertices = [...root().querySelectorAll('circle.vertex')];
const bodies = [...root().querySelectorAll('.wallbody-fill')];
const overlay = root().querySelector('[data-hp="plan-snap-overlay"]');
result.pathlinePresent = !!pathline;
result.vertexCount = vertices.length === 3;
result.rubberBandAxis = !!root().querySelector('line.active-axis');
result.rubberBandVertex = !!root().querySelector('circle.active-vertex');
// AC2: document order — wall bodies → chain ink → snap overlay.
const after = (a, b) => !!(a && b
&& (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING));
result.chainHasBody = bodies.length > 0;
result.inkAboveBodies = bodies.every((body) => after(body, pathline));
result.inkBelowSnapOverlay = after(pathline, overlay);
// Pixel probes read the composited stage: the persisted segment's midpoint
// must show the yellow axis, the shared chain node must show a node dot.
return result;
});
// AC1: pixel colours at the persisted segment's axis midpoint and chain node.
// The probes are mapped to CSS right before the shot: the draw toolbar mounts
// above the stage and shifts the SVG after the first render.
const probeInfo = await page.evaluate(async () => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
// The stage camera keeps animating after the first render; wait until the
// screen matrix is stable so the probes match the screenshot that follows.
const svgEl = root.querySelector('polyline.pathline').ownerSVGElement;
let last = '';
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 100));
const m = svgEl.getScreenCTM();
const key = [m.a, m.b, m.c, m.d, m.e, m.f].join(',');
if (key === last) break;
last = key;
}
const stage = root.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const pathline = root.querySelector('polyline.pathline');
const svg = pathline.ownerSVGElement;
const toCss = ([ux, uy]) => {
const point = new DOMPoint(ux, uy).matrixTransform(svg.getScreenCTM());
return [point.x - rect.left, point.y - rect.top];
};
return {
probes: { axisMid: toCss([350, 300]), node: toCss([500, 300]) },
rect: { width: rect.width, height: rect.height },
};
});
const shot = await page.locator('.stage').first().screenshot({ animations: 'disabled' });
const pixels = await page.evaluate(async ([bytes, info]) => {
const bitmap = await createImageBitmap(new Blob([new Uint8Array(bytes)], { type: 'image/png' }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const scaleX = bitmap.width / info.rect.width;
const scaleY = bitmap.height / info.rect.height;
const at = ([cx, cy]) => {
const d = ctx.getImageData(Math.round(cx * scaleX), Math.round(cy * scaleY), 1, 1).data;
return [d[0], d[1], d[2]];
};
const around = ([cx, cy], predicate, radius = 4) => {
const px = Math.round(cx * scaleX);
const py = Math.round(cy * scaleY);
for (let dy = -radius; dy <= radius; dy++) for (let dx = -radius; dx <= radius; dx++) {
const d = ctx.getImageData(px + dx, py + dy, 1, 1).data;
if (predicate([d[0], d[1], d[2]])) return true;
}
return false;
};
const yellowish = ([r, g, b]) => r > 200 && g > 130 && g < 220 && b < 120;
// The node dot is tiny at this zoom: its centre may land on the dark rim
// (#4a2800) instead of the yellow fill. Warm ink of either kind passes;
// masonry (cold white) and the background (blue-tinted) do not.
const warmInk = ([r, g, b]) => r > 110 && r > g && g > b && b < 130;
return {
axisMidYellow: around(info.probes.axisMid, yellowish),
nodeYellow: around(info.probes.node, warmInk, 12),
axisMidRgb: at(info.probes.axisMid),
nodeRgb: at(info.probes.node),
};
}, [[...shot], probeInfo]);
checkAll(out);
check('axisMidYellow', pixels.axisMidYellow, true);
check('nodeYellow', pixels.nodeYellow, true);
console.log('pixel probes:', JSON.stringify(pixels));
await finish(browser);