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