mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
fix(daycycle): stage-sized plan layers without a frozen raster; revert #685 hatch
With the day/night background the plan went blurry after zooming from 100 %
(sharp when the page was opened at 800 %) and navigating a strongly zoomed
plan flashed the page white. Both came from #582's composition, not from the
wall hatch that #685 replaced:
- `.stage.daycycle.hp-safe-daycycle-outline .plan-svg` promoted the scene
with `will-change: transform`; Chromium freezes the raster scale of such a
layer, so the 100 % raster was shown stretched. The explicit layer #582
needs is now `will-change: opacity` (re-rasters at the current scale).
- The filtered outline had `overflow: visible` and a gesture exposed every
scene (#544) without bound, so the promoted layers grew with zoom squared
(CDP LayerTree, ~460 %: plan-svg 15.9x, outline 13.2x the stage; 39x after
navigating at 800 %). The full card clips its outline to its box and marks
it data-hp-live-overflow="clip" (never exposed); the live viewport bounds
every other exposure with an inline clip-path: inset(-25%) that leaves
with it, so idle DOM stays byte-identical (#531).
Owner-verified in Chrome 152 (built-in browser, DPR 2): sharp after 100 ->
800 %, no white flashes after reloading at 800 %.
Owner decision: #685's analytic gradient is reverted (13af1d5e), the single
<pattern> is back at every zoom; its close-up golden scenes stay and check
the pattern, its terminal-frame smoke checks the pattern.
Witnesses: demo/smoke_daycycle_zoom_layers.mjs (800 % x DPR 2: layers vs
stage, the hint, reload path, button/wheel/pinch); #582/#532 smokes now pin
the opacity hint; test/live-viewport.test.mjs (bounded exposure, clipped
scene); test/daycycle-layers.test.mjs (cascade). Four Node-guarded mutants.
Issue: #689
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -1400,8 +1400,7 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
const exactViewBox = `${view.x} ${view.y} ${view.w} ${view.h}`;
|
||||
const scenes = [...root.querySelectorAll('[data-hp-live-viewbox]')];
|
||||
const layers = [...root.querySelectorAll('[data-hp-live-layer="camera"]')];
|
||||
const gradient = root.querySelector('linearGradient#hp-wall-hatch');
|
||||
const stops = [...(gradient?.querySelectorAll('stop') || [])];
|
||||
const pattern = root.querySelector('defs > pattern#hp-wall-hatch');
|
||||
const visibleKinds = new Set([...root.querySelectorAll('.opening[data-kind]')]
|
||||
.map((node) => node.getAttribute('data-kind')));
|
||||
const modelKinds = new Set((card._openingsR || []).map((opening) => opening.type));
|
||||
@@ -1410,15 +1409,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
&& scenes.every((node) => !getComputedStyle(node).willChange
|
||||
|| getComputedStyle(node).willChange === 'auto')
|
||||
&& layers.every((node) => ['', 'none'].includes(getComputedStyle(node).transform));
|
||||
const analytic = !!gradient
|
||||
&& gradient.getAttribute('gradientUnits') === 'userSpaceOnUse'
|
||||
&& gradient.getAttribute('spreadMethod') === 'repeat'
|
||||
&& stops.length === 4
|
||||
&& stops[1].getAttribute('stop-opacity') === '0'
|
||||
&& stops[2].getAttribute('stop-opacity') === '0';
|
||||
// #689: the historical pattern at every scale (#685's gradient reverted).
|
||||
const hatch = !!pattern && !root.querySelector('linearGradient#hp-wall-hatch');
|
||||
const openings = ['door', 'window', 'gate'].every((kind) => visibleKinds.has(kind))
|
||||
&& modelKinds.has('passage');
|
||||
if (!settled || !analytic || !openings
|
||||
if (!settled || !hatch || !openings
|
||||
|| Math.abs(card._zoom - scenario.zoom) > 1e-9) {
|
||||
throw new Error(`static hatch sharpness contract failed: ${scenario.id}`);
|
||||
}
|
||||
|
||||
@@ -289,7 +289,10 @@ checks.terminalFrameKeepsBudget = settled.layers.every(
|
||||
checks.settledPlanLayerIsExplicitAndBounded = !!settledPlanLayer
|
||||
&& settledPlanLayer.width <= active.stage.width * 1.5 + 64
|
||||
&& settledPlanLayer.height <= active.stage.height * 1.5 + 64
|
||||
&& settledPlanLayer.reasons.some((reason) => reason.includes('will-change: transform'))
|
||||
// #689: explicit, but through the opacity hint — the transform hint
|
||||
// freezes the raster scale and the plan stays blurry after a zoom.
|
||||
&& settledPlanLayer.reasons.some((reason) => reason.includes('will-change: opacity'))
|
||||
&& settledPlanLayer.reasons.every((reason) => !reason.includes('will-change: transform'))
|
||||
&& settledPlanLayer.reasons.every((reason) => !reason.includes('Overlaps other composited content'));
|
||||
checks.capturedPresentedPinchFrames = frameMetrics.length >= 3;
|
||||
checks.presentedFramesHaveNoWhiteTile = frameMetrics.every(
|
||||
|
||||
@@ -166,7 +166,9 @@ out.dayCycleOutlineFiltered = /drop-shadow/.test(safeDayStyle?.filter || '');
|
||||
out.dayCycleOutlinePromoted = /filter/.test(safeDayStyle?.willChange || '');
|
||||
out.dayCyclePaperStaysUnfiltered = (safeDayStyle?.paperFilter || 'none') === 'none'
|
||||
&& !/filter/.test(safeDayStyle?.paperWillChange || '');
|
||||
out.safePlanLayerIsExplicit = /transform/.test(safeDayStyle?.planWillChange || '');
|
||||
// #689: the explicit scene layer is an opacity hint; a transform hint would
|
||||
// freeze its raster scale after a zoom.
|
||||
out.safePlanLayerIsExplicit = (safeDayStyle?.planWillChange || '') === 'opacity';
|
||||
|
||||
out.rasterTasksObserved = [...staticSamples, ...dayCycleSamples].every((sample) => sample.tasks > 0);
|
||||
const staticMedianMs = median(staticSamples.map((sample) => sample.ms));
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// #689: with the day/night background the plan layers must stay stage-sized at
|
||||
// any zoom, and the scene must not keep a will-change: transform hint (its explicit layer is
|
||||
// an opacity hint). Before
|
||||
// the fix, CDP LayerTree measured (~460 %, DPR 2) plan-svg at 15.9× and the
|
||||
// filtered outline at 13.2× the stage during a gesture, the outline at 12.8×
|
||||
// at rest; at 800 % that is hundreds of MB of GPU memory and navigation
|
||||
// flashed white. The same will-change: transform hint froze the raster scale:
|
||||
// zooming from 100 % showed the 100 % raster stretched. Headless Chromium can
|
||||
// see neither the blur nor the flashes (owner-verified in real Chrome), so this
|
||||
// witness measures the state that causes them: layer sizes and the hint.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { installHpTestOnPage } from './helpers/hp-test.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 820, height: 760 }, 2);
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
let latestLayers = [];
|
||||
cdp.on('LayerTree.layerTreeDidChange', ({ layers }) => { latestLayers = layers; });
|
||||
await cdp.send('LayerTree.enable');
|
||||
const frames = [];
|
||||
cdp.on('Page.screencastFrame', (event) => {
|
||||
if (frames.length < 120) frames.push(event.data);
|
||||
void cdp.send('Page.screencastFrameAck', { sessionId: event.sessionId });
|
||||
});
|
||||
|
||||
const settle = async () => {
|
||||
await page.waitForTimeout(1200);
|
||||
await page.evaluate(async () => {
|
||||
await window.__card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
});
|
||||
};
|
||||
|
||||
const dayNight = async () => {
|
||||
// A dark custom room fill keeps the near-white frame sample meaningful at
|
||||
// 800 %, where the demo's own light paper would otherwise fill the stage.
|
||||
await page.evaluate(() => window.__hpTest.setServerConfig((cfg) => ({
|
||||
...cfg,
|
||||
settings: { ...(cfg.settings || {}), bg_mode: 'daynight', glow_enabled: false },
|
||||
spaces: cfg.spaces.map((space) => ({
|
||||
...space, settings: { ...(space.settings || {}), fill_mode: 'custom', custom_fill: '#435468' },
|
||||
})),
|
||||
})));
|
||||
await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states, 'sun.sun': {
|
||||
entity_id: 'sun.sun', state: 'above_horizon',
|
||||
attributes: { azimuth: 180, elevation: 40, rising: false },
|
||||
} } };
|
||||
await card.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
};
|
||||
|
||||
const stageBox = () => page.evaluate(() => {
|
||||
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
});
|
||||
|
||||
const snapshot = async () => {
|
||||
await page.waitForTimeout(80);
|
||||
const state = await page.evaluate(() => {
|
||||
const root = window.__card.renderRoot;
|
||||
const stage = root.querySelector('.stage');
|
||||
const plan = root.querySelector('.plan-svg');
|
||||
const outline = root.querySelector('.hp-paper-outline-svg');
|
||||
return {
|
||||
zoom: window.__card._zoom,
|
||||
daycycle: stage.classList.contains('daycycle'),
|
||||
safe: stage.classList.contains('hp-safe-daycycle-outline'),
|
||||
planWillChange: getComputedStyle(plan).willChange,
|
||||
planTransform: getComputedStyle(plan).transform,
|
||||
outlineOverflow: outline ? getComputedStyle(outline).overflow : null,
|
||||
outlineInline: outline ? `${outline.style.overflow}|${outline.style.clipPath}` : null,
|
||||
sceneClips: [...root.querySelectorAll('[data-hp-live-viewbox]')].map((node) => node.style.clipPath || ''),
|
||||
};
|
||||
});
|
||||
const layers = [];
|
||||
for (const layer of latestLayers.filter((item) => item.drawsContent)) {
|
||||
let name = '';
|
||||
if (layer.backendNodeId) {
|
||||
try {
|
||||
const { node } = await cdp.send('DOM.describeNode', { backendNodeId: layer.backendNodeId });
|
||||
const attrs = node.attributes || [];
|
||||
const index = attrs.indexOf('class');
|
||||
name = index >= 0 ? attrs[index + 1] : node.nodeName;
|
||||
} catch { /* detached between the event and inspection */ }
|
||||
}
|
||||
let reasons = [];
|
||||
try {
|
||||
({ compositingReasons: reasons } = await cdp.send('LayerTree.compositingReasons', {
|
||||
layerId: layer.layerId,
|
||||
}));
|
||||
} catch { /* retired layers may have no reason record */ }
|
||||
layers.push({ name, width: layer.width, height: layer.height, reasons });
|
||||
}
|
||||
return { state, layers };
|
||||
};
|
||||
|
||||
// Everything the card paints; the page root and the sticky header are not plan.
|
||||
const planLayers = (snap) => snap.layers.filter((layer) => layer.name
|
||||
&& layer.name !== '#document' && !/\bhdr\b/.test(layer.name));
|
||||
const within = (snap, stage, factor) => planLayers(snap).every((layer) =>
|
||||
layer.width <= stage.width * factor + 240 && layer.height <= stage.height * factor + 240);
|
||||
const largest = (snap) => planLayers(snap).reduce((max, layer) =>
|
||||
Math.max(max, layer.width * layer.height), 0);
|
||||
|
||||
const wheelAt = async (stage, deltaY, count) => {
|
||||
// Near a corner: the hover tooltip stays out of the central frame sample.
|
||||
await page.mouse.move(stage.x + 40, stage.y + 40);
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
await page.waitForTimeout(24);
|
||||
}
|
||||
};
|
||||
|
||||
await dayNight();
|
||||
const stage = await stageBox();
|
||||
await cdp.send('Page.startScreencast', { format: 'png', everyNthFrame: 2, maxWidth: 820, maxHeight: 760 });
|
||||
|
||||
// A: page loaded at 100 %, zoomed to 800 % with the wheel (owner's blur path).
|
||||
await wheelAt(stage, -240, 30);
|
||||
const zoomingIn = await snapshot();
|
||||
await settle();
|
||||
const zoomedIn = await snapshot();
|
||||
|
||||
// The buttons and a pinch settle into the same hint-free scene (AC1).
|
||||
const clickZoom = (hook) => page.evaluate((hook) =>
|
||||
window.__card.renderRoot.querySelector(`[data-hp="${hook}"]`).click(), hook);
|
||||
await clickZoom('zoom-out'); await settle();
|
||||
await clickZoom('zoom-in'); await settle();
|
||||
const buttons = await snapshot();
|
||||
await page.evaluate(() => {
|
||||
const stage = window.__card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
stage.setPointerCapture = () => {};
|
||||
stage.releasePointerCapture = () => {};
|
||||
const emit = (type, id, x) => stage.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true, pointerId: id, pointerType: 'touch',
|
||||
isPrimary: id === 6891, button: type === 'pointerdown' ? 0 : -1,
|
||||
buttons: type === 'pointerup' ? 0 : 1, clientX: x, clientY: rect.top + rect.height / 2,
|
||||
}));
|
||||
const cx = rect.left + rect.width / 2;
|
||||
emit('pointerdown', 6891, cx - 120); emit('pointerdown', 6892, cx + 120);
|
||||
emit('pointermove', 6891, cx - 90); emit('pointermove', 6892, cx + 90);
|
||||
emit('pointerup', 6891, cx - 90); emit('pointerup', 6892, cx + 90);
|
||||
});
|
||||
await settle();
|
||||
const pinched = await snapshot();
|
||||
await wheelAt(stage, -240, 12);
|
||||
await settle();
|
||||
|
||||
// B: reload at 800 % (the camera is restored), day/night again, then navigate
|
||||
// (owner's white-flash path).
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__card?._model?.length > 0, { timeout: 9000 });
|
||||
await installHpTestOnPage(page);
|
||||
await dayNight();
|
||||
const reloaded = await snapshot();
|
||||
await wheelAt(stage, 240, 3);
|
||||
const navigating = await snapshot();
|
||||
await wheelAt(stage, -240, 6);
|
||||
await settle();
|
||||
const navigated = await snapshot();
|
||||
await cdp.send('Page.stopScreencast');
|
||||
|
||||
const nearWhite = await page.evaluate(async ({ encoded, stage }) => {
|
||||
const out = [];
|
||||
for (const data of encoded) {
|
||||
const image = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = `data:image/png;base64,${data}`;
|
||||
});
|
||||
const sx = image.width / innerWidth, sy = image.height / innerHeight;
|
||||
const w = Math.max(1, Math.floor(stage.width * sx)), h = Math.max(1, Math.floor(stage.height * sy));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w; canvas.height = h;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(image, Math.floor(stage.x * sx), Math.floor(stage.y * sy), w, h, 0, 0, w, h);
|
||||
const px = context.getImageData(0, 0, w, h).data;
|
||||
let sampled = 0, white = 0;
|
||||
for (let y = Math.floor(h * 0.25); y < h * 0.75; y += 3) {
|
||||
for (let x = Math.floor(w * 0.25); x < w * 0.75; x += 3) {
|
||||
const o = (y * w + x) * 4;
|
||||
sampled++;
|
||||
if (px[o] >= 250 && px[o + 1] >= 250 && px[o + 2] >= 250) white++;
|
||||
}
|
||||
}
|
||||
out.push(white / Math.max(1, sampled));
|
||||
}
|
||||
return out;
|
||||
}, { encoded: frames, stage });
|
||||
|
||||
const planReasons = (snap) => planLayers(snap).find((layer) => /\bplan-svg\b/.test(layer.name))?.reasons || [];
|
||||
const out = {
|
||||
dayNightSafeAt800: zoomedIn.state.daycycle && zoomedIn.state.safe && zoomedIn.state.zoom === 8,
|
||||
reloadRestores800Untouched: reloaded.state.daycycle && !reloaded.state.safe && reloaded.state.zoom === 8,
|
||||
// K1: no frozen raster — the explicit layer is a trivial 3D transform.
|
||||
settledSceneHasNoWillChangeHint: [zoomedIn, buttons, pinched, navigated]
|
||||
.every((snap) => snap.state.planWillChange === 'opacity'),
|
||||
buttonAndPinchChangedTheCamera: buttons.state.safe && pinched.state.zoom < 8,
|
||||
// The scene stays an explicit layer (#582), but no reason is the hint that
|
||||
// freezes the raster scale, and it is never an implicit overlap layer.
|
||||
settledSceneLayerIsExplicitWithoutHint: planReasons(zoomedIn).some((reason) => /will-change: opacity/.test(reason))
|
||||
&& planReasons(zoomedIn).every((reason) => !/will-change: transform/.test(reason)
|
||||
&& !/Overlaps other composited content/.test(reason)),
|
||||
// K3: layers stay stage-sized at 800 %.
|
||||
gestureLayersWithinBudget: within(zoomingIn, stage, 1.5) && within(navigating, stage, 1.5),
|
||||
settledLayersWithinBudget: [zoomedIn, buttons, pinched, reloaded, navigated]
|
||||
.every((snap) => within(snap, stage, 1)),
|
||||
outlineClippedToItsBox: zoomedIn.state.outlineOverflow === 'hidden'
|
||||
&& navigating.state.outlineInline === '|' && navigated.state.outlineInline === '|',
|
||||
idleScenesKeepNoClip: [zoomedIn, buttons, pinched, reloaded, navigated].every((snap) => snap.state.sceneClips.every((clip) => clip === '')),
|
||||
capturedFrames: nearWhite.length >= 3,
|
||||
// A white/transparent frame covers the stage; white glyphs of 800 % device
|
||||
// markers stay a few per cent of the central sample.
|
||||
noWhiteFrames: nearWhite.every((ratio) => ratio < 0.25),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify({
|
||||
stage,
|
||||
largest: {
|
||||
zoomingIn: largest(zoomingIn), zoomedIn: largest(zoomedIn), reloaded: largest(reloaded),
|
||||
navigating: largest(navigating), navigated: largest(navigated),
|
||||
},
|
||||
layers: { zoomingIn: planLayers(zoomingIn), zoomedIn: planLayers(zoomedIn), navigating: planLayers(navigating) },
|
||||
states: {
|
||||
zoomingIn: zoomingIn.state, zoomedIn: zoomedIn.state, buttons: buttons.state,
|
||||
pinched: pinched.state, reloaded: reloaded.state, navigated: navigated.state,
|
||||
},
|
||||
whiteMax: Math.max(0, ...nearWhite),
|
||||
}, null, 1));
|
||||
checkAll(out);
|
||||
await cdp.send('LayerTree.disable');
|
||||
await finish(browser, out);
|
||||
@@ -1,7 +1,7 @@
|
||||
// Issue #685: a settled zoom must be a fresh vector frame, and the hatch must
|
||||
// not be a bitmap pattern which a WebView can keep resampling after the live
|
||||
// compositor transform is gone. This production-bundle witness owns both the
|
||||
// analytic SVG paint server and its live-viewport wiring.
|
||||
// Issue #685: a settled zoom must be a fresh vector frame after every route
|
||||
// (button, wheel, pinch, every mode). #689 reverted #685's analytic gradient —
|
||||
// the blur came from a frozen compositor raster, not from the hatch — so the
|
||||
// wall hatch is again the one historical <pattern> at every scale.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
import { prepareGoldenScenario } from './golden/harness.mjs';
|
||||
@@ -43,8 +43,7 @@ const state = () => page.evaluate(() => {
|
||||
const exact = `${view.x} ${view.y} ${view.w} ${view.h}`;
|
||||
const scenes = [...root.querySelectorAll('[data-hp-live-viewbox]')];
|
||||
const layers = [...root.querySelectorAll('[data-hp-live-layer="camera"]')];
|
||||
const gradient = root.querySelector('linearGradient#hp-wall-hatch');
|
||||
const stops = [...(gradient?.querySelectorAll('stop') || [])];
|
||||
const pattern = root.querySelector('defs > pattern#hp-wall-hatch');
|
||||
const kinds = new Set([...root.querySelectorAll('.opening[data-kind]')]
|
||||
.map((node) => node.getAttribute('data-kind')));
|
||||
const modelKinds = new Set((card._openingsR || []).map((opening) => opening.type));
|
||||
@@ -55,12 +54,8 @@ const state = () => page.evaluate(() => {
|
||||
noSceneWillChange: scenes.every((node) => !getComputedStyle(node).willChange
|
||||
|| getComputedStyle(node).willChange === 'auto'),
|
||||
noLayerTransform: layers.every((node) => ['', 'none'].includes(getComputedStyle(node).transform)),
|
||||
analyticHatch: !!gradient
|
||||
&& gradient.getAttribute('gradientUnits') === 'userSpaceOnUse'
|
||||
&& gradient.getAttribute('spreadMethod') === 'repeat'
|
||||
&& stops.length === 4
|
||||
&& stops[1].getAttribute('stop-opacity') === '0'
|
||||
&& stops[2].getAttribute('stop-opacity') === '0',
|
||||
patternHatch: !!pattern && pattern.getAttribute('patternUnits') === 'userSpaceOnUse'
|
||||
&& !root.querySelector('linearGradient#hp-wall-hatch'),
|
||||
openingKinds: ['door', 'window', 'gate'].every((kind) => kinds.has(kind))
|
||||
&& modelKinds.has('passage'),
|
||||
};
|
||||
@@ -138,7 +133,7 @@ for (const mode of ['view', 'plan', 'devices', 'decor']) {
|
||||
}
|
||||
|
||||
const terminal = (sample) => sample.exactViewBox && sample.noSceneTransform
|
||||
&& sample.noSceneWillChange && sample.noLayerTransform && sample.analyticHatch
|
||||
&& sample.noSceneWillChange && sample.noLayerTransform && sample.patternHatch
|
||||
&& sample.openingKinds;
|
||||
const out = {
|
||||
buttonSettlesAt140: Math.abs(button.zoom - 1.4) < 1e-9 && terminal(button),
|
||||
@@ -147,7 +142,7 @@ const out = {
|
||||
sameScaleIsDeterministic: buttonHash === reverseHash,
|
||||
wheelSettlesAt115: Math.abs(wheel.zoom - 1.15) < 1e-9 && terminal(wheel),
|
||||
pinchSettlesAt132: Math.abs(pinch.zoom - 1.32) < 1e-9 && terminal(pinch),
|
||||
allModesUseSettledAnalyticHatch: modes.length === 4 && modes.every(terminal),
|
||||
allModesUseSettledPatternHatch: modes.length === 4 && modes.every(terminal),
|
||||
};
|
||||
|
||||
checkAll(out);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// Issue #230: the hatch step is a physical distance, not a coordinate one.
|
||||
//
|
||||
// The units own the arithmetic; this smoke owns the wiring — that both renderers
|
||||
// actually put the computed step into the paint server, that neither of them
|
||||
// scales it back by zoom, and that the two agree geometrically. The full card
|
||||
// uses #685's analytic repeating gradient while the non-interactive card keeps
|
||||
// the historical stroked pattern, so the comparison deliberately normalises
|
||||
// both representations to the same physical contract.
|
||||
// actually put the computed step into the pattern, that neither of them scales
|
||||
// it back by zoom, and that the two agree with each other.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
@@ -16,34 +13,14 @@ const res = await page.evaluate(async () => {
|
||||
await c.updateComplete;
|
||||
};
|
||||
const space = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const paint = (root) => root.querySelector('#hp-wall-hatch');
|
||||
const read = (server) => {
|
||||
if (!server) return null;
|
||||
if (server.localName === 'linearGradient') {
|
||||
const x1 = Number(server.getAttribute('x1'));
|
||||
const y1 = Number(server.getAttribute('y1'));
|
||||
const x2 = Number(server.getAttribute('x2'));
|
||||
const y2 = Number(server.getAttribute('y2'));
|
||||
const stops = [...server.querySelectorAll('stop')];
|
||||
const edge = Number(stops[1]?.getAttribute('offset'));
|
||||
const step = Math.hypot(x2 - x1, y2 - y1);
|
||||
const rotation = Number((server.getAttribute('gradientTransform') || '')
|
||||
.match(/rotate\(([-+0-9.eE]+)/)?.[1] || 0);
|
||||
return {
|
||||
step,
|
||||
stripe: 2 * edge * step,
|
||||
angle: Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI + rotation,
|
||||
origin: `${x1},${y1}`,
|
||||
};
|
||||
}
|
||||
const width = Number(server.getAttribute('width'));
|
||||
return {
|
||||
step: width,
|
||||
stripe: Number(server.querySelector('path')?.getAttribute('stroke-width')),
|
||||
angle: Number((server.getAttribute('patternTransform') || '').match(/rotate\(([-+0-9.eE]+)/)?.[1]),
|
||||
origin: '0,0',
|
||||
};
|
||||
};
|
||||
const pattern = (root) => root.querySelector('#hp-wall-hatch');
|
||||
const read = (p) => p && ({
|
||||
width: Number(p.getAttribute('width')),
|
||||
height: Number(p.getAttribute('height')),
|
||||
transform: p.getAttribute('patternTransform') || '',
|
||||
stroke: Number(p.querySelector('path')?.getAttribute('stroke-width')),
|
||||
d: p.querySelector('path')?.getAttribute('d') || '',
|
||||
});
|
||||
|
||||
c._mode = 'plan'; c.requestUpdate(); await settle();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
@@ -64,16 +41,16 @@ const res = await page.evaluate(async () => {
|
||||
out.wallBodyIsRendered = !!c.shadowRoot.querySelector('.wallbody');
|
||||
|
||||
// Reference scale: exactly the historical numbers, so old plans do not move.
|
||||
const atFive = read(paint(c.shadowRoot));
|
||||
out.referenceStepIsEight = Math.abs((atFive?.step ?? 0) - 8) < 1e-9;
|
||||
out.referenceStrokeIsTwo = Math.abs((atFive?.stripe ?? 0) - 2) < 1e-9;
|
||||
out.referenceAngleIsFortyFive = Math.abs((atFive?.angle ?? 0) - 45) < 1e-9;
|
||||
const atFive = read(pattern(c.shadowRoot));
|
||||
out.referenceStepIsEight = atFive?.width === 8 && atFive?.height === 8;
|
||||
out.referenceStrokeIsTwo = atFive?.stroke === 2;
|
||||
out.noZoomScaleAtReference = !!atFive && !/scale/.test(atFive.transform);
|
||||
|
||||
// Zoom must not touch the pattern any more — that is the whole point.
|
||||
c._applyView(3); c.requestUpdate(); await settle();
|
||||
const zoomed = read(paint(c.shadowRoot));
|
||||
c._applyView(3); await settle();
|
||||
const zoomed = read(pattern(c.shadowRoot));
|
||||
out.zoomDoesNotChangeThePattern = JSON.stringify(zoomed) === JSON.stringify(atFive);
|
||||
c._applyView(1); c.requestUpdate(); await settle();
|
||||
c._applyView(1); await settle();
|
||||
|
||||
// A coarse grid: the step follows the centimetres, so it shrinks in units.
|
||||
space().cell_cm = 25;
|
||||
@@ -82,10 +59,10 @@ const res = await page.evaluate(async () => {
|
||||
c._saveConfig();
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
c.requestUpdate(); await settle();
|
||||
const atTwentyFive = read(paint(c.shadowRoot));
|
||||
out.coarseGridShrinksTheStep = Math.abs(atTwentyFive.step - 1.6) < 1e-9;
|
||||
out.coarseGridScalesTheStroke = Math.abs(atTwentyFive.stripe - 0.4) < 1e-9;
|
||||
out.coarseGridKeepsTheAngle = Math.abs(atTwentyFive.angle - 45) < 1e-9;
|
||||
const atTwentyFive = read(pattern(c.shadowRoot));
|
||||
out.coarseGridShrinksTheStep = Math.abs(atTwentyFive.width - 1.6) < 1e-9;
|
||||
out.coarseGridScalesTheStroke = Math.abs(atTwentyFive.stroke - 0.4) < 1e-9;
|
||||
out.coarseGridStripeSpansTheCell = atTwentyFive.d === `M0 0 L0 ${atTwentyFive.width}`;
|
||||
|
||||
// The static renderer is the second path that draws a wall body, and it used
|
||||
// to carry its own hard-coded 8 (spec §8.2, AC12).
|
||||
@@ -101,14 +78,10 @@ const res = await page.evaluate(async () => {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
}
|
||||
await card.updateComplete;
|
||||
const staticPattern = read(paint(card.renderRoot));
|
||||
const staticPattern = read(pattern(card.renderRoot));
|
||||
out.staticRendererFollowsTheCell = !!staticPattern
|
||||
&& Math.abs(staticPattern.step - 1.6) < 1e-9;
|
||||
out.bothRenderersAgree = !!staticPattern
|
||||
&& Math.abs(staticPattern.step - atTwentyFive.step) < 1e-9
|
||||
&& Math.abs(staticPattern.stripe - atTwentyFive.stripe) < 1e-9
|
||||
&& Math.abs(staticPattern.angle - atTwentyFive.angle) < 1e-9
|
||||
&& staticPattern.origin === atTwentyFive.origin;
|
||||
&& Math.abs(staticPattern.width - 1.6) < 1e-9;
|
||||
out.bothRenderersAgree = JSON.stringify(staticPattern) === JSON.stringify(atTwentyFive);
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -595,20 +595,18 @@ the last settled Lit frame; both land on the current view, keeping #451's
|
||||
one-CSS-pixel marker contract on every frame. `.stage` stays the outer clip, but
|
||||
a transformed scene SVG gets inline `overflow: visible` so rasterised content
|
||||
covers the incoming edge, even with the pointer held still (#544); HTML layers
|
||||
never do. From the first live paint to the terminal commit a scene SVG stays in
|
||||
never do. The exposure is bounded by an inline `clip-path: inset(-25%)`
|
||||
(`LIVE_SCENE_EXPOSURE_CLIP`) set and removed with it — beyond the 15 % refresh
|
||||
threshold, but never the whole plan: unbounded, a promoted scene grew with
|
||||
zoom² and at 800 % × DPR 2 exhausted GPU memory (white frames, #689). A scene
|
||||
marked `data-hp-live-overflow="clip"` — the filtered day-cycle outline — is
|
||||
projected but never exposed. From the first live paint to the terminal commit a scene SVG stays in
|
||||
one compositor lifecycle: a refresh or Lit frame may replace its anchor but never
|
||||
demote/re-promote it — HA Companion WebView shows that as a blank frame (#579).
|
||||
The day-cycle paper outline joins these roots before the first camera move; the
|
||||
static card uses its stage-sized form from the first frame (#582, Key decision 7).
|
||||
Unchanged values are never rewritten, so idle frames stay byte-identical;
|
||||
`commitHouseplanViewport` removes the transforms and forces the final `viewBox`.
|
||||
After the camera leaves its byte-compatible 100% view, the flat wall hatch uses
|
||||
an analytic user-space repeating gradient rather than a repeated stroked bitmap
|
||||
tile (#685). Therefore the terminal transform-free SVG is rasterised directly
|
||||
for its final fractional scale; the live transformed frames may still be
|
||||
temporarily soft, but the idle frame must not retain a resampled hatch texture.
|
||||
Door, window and gate strokes share that terminal SVG, and passages remain real
|
||||
negative wall geometry.
|
||||
|
||||
## English and Russian ship whole (#400)
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
screen pixels, remains uniform when a stair is resized or rotated, and uses
|
||||
the same physical value in View, the Plan editor and PDF
|
||||
([#688](https://github.com/Matysh/houseplan-card/issues/688)).
|
||||
- With the day/night background, the plan no longer turns blurry after zooming
|
||||
in: walls, hatching and openings stay as sharp as when the page is opened at
|
||||
that zoom. Navigating a strongly zoomed plan no longer makes the page flash
|
||||
white. The wall hatching of
|
||||
[#685](https://github.com/Matysh/houseplan-card/issues/685) is reverted to
|
||||
its earlier pattern, which it did not fix and which gained stepped stripe
|
||||
edges ([#689](https://github.com/Matysh/houseplan-card/issues/689)).
|
||||
|
||||
## v1.78.0-beta.8 — 2026-09-28
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
остаётся постоянной в экранных пикселях, не искажается при изменении размера
|
||||
и повороте и одинаково применяется в просмотре, редакторе плана и PDF
|
||||
([#688](https://github.com/Matysh/houseplan-card/issues/688)).
|
||||
- С фоном день/ночь план больше не становится размытым после приближения:
|
||||
стены, штриховка и проёмы остаются такими же чёткими, как при открытии
|
||||
страницы сразу на этом масштабе. Навигация по сильно приближенному плану
|
||||
больше не вызывает белого мигания страницы. Штриховка стен из
|
||||
[#685](https://github.com/Matysh/houseplan-card/issues/685) возвращена к
|
||||
прежнему узору: она не исправляла проблему и добавила ступенчатые края полос
|
||||
([#689](https://github.com/Matysh/houseplan-card/issues/689)).
|
||||
|
||||
## v1.78.0-beta.8 — 2026-09-28
|
||||
|
||||
|
||||
+12
@@ -93,6 +93,18 @@ The public setting remains a two-value selector. `static` uses `bg_color`.
|
||||
neither the filter nor the hint. The non-interactive static space card uses
|
||||
the same stage-sized day-cycle outline from its first frame because it has no
|
||||
camera gesture that could activate a fallback later (#582).
|
||||
- The sibling stays stage-sized at every zoom (#689). The full card clips it to
|
||||
its own box (`.stage .hp-paper-outline-svg { overflow: hidden }`) and marks it
|
||||
`data-hp-live-overflow="clip"`, so a gesture never exposes it either: a filter
|
||||
layer precedes any `clip-path`, and an open outline spanned the whole paper
|
||||
at the current zoom — 12.8× the stage at ~460 % and hundreds of MB at 800 % ×
|
||||
DPR 2, which made navigation flash white. Its glow may be missing on an
|
||||
incoming edge for at most one budgeted `viewBox` refresh. The visible scene
|
||||
keeps the explicit layer #582 requires, but through `will-change: opacity`:
|
||||
a `will-change: transform` hint froze its raster scale, so after zooming from
|
||||
100 % to 800 % the plan showed the stretched 100 % raster while a page opened
|
||||
at 800 % was sharp. Both were verified by the owner in Chrome 152;
|
||||
`demo/smoke_daycycle_zoom_layers.mjs` measures the layers and the hint.
|
||||
- Only the environment and the zero-offset alpha-aware outline outside the
|
||||
grouped plan-paper footprint change. The plan, paper, floors, room fills,
|
||||
Glow/spill, devices, labels, decor/backdrop, vacuum, hover, and window rays
|
||||
|
||||
@@ -294,18 +294,6 @@ thinner than `HATCH_MIN_STEP_PX = 2` (`wallHatchNeedsSolid`). The step is
|
||||
clamped to `[0.5, 80]` units so a pathological `cell_cm` cannot degenerate the
|
||||
pattern.
|
||||
|
||||
At the byte-compatible 100% view the interactive flat card keeps that
|
||||
historical pattern. Once the settled camera actually scales the plan, it paints
|
||||
the unchanged geometry as an analytic repeating `linearGradient` (#685), not as
|
||||
a small stroked SVG tile. The gradient vector is normal to the stripes, has
|
||||
exactly one physical hatch step of length, and its two hard-stop edges preserve
|
||||
the historical stroke/gap ratio and phase. This distinction is raster-only: it
|
||||
prevents Chromium/HA WebView from keeping a soft resampled tile at a fractional
|
||||
settled zoom. Stored walls, centimetre depth, hatch angle and density do not
|
||||
change. The non-interactive space card may keep the historical pattern because
|
||||
it has no live camera; the density smoke normalises both paint servers to the
|
||||
same geometry.
|
||||
|
||||
A variable-offset join where exactly one adjacent edge has zero depth is a
|
||||
local flat cap, not a mitre. Both `inset` and `outset` retain the physical
|
||||
edge's offset point followed by the untouched zero-edge vertex (or the reverse
|
||||
|
||||
@@ -82,9 +82,9 @@ The invariant depends on computed CSS, SVG paint, clipping, stacking or pixels p
|
||||
- `device-unavailable-hover-restored`
|
||||
- `golden-filled-tunnel-removed`
|
||||
- `golden-lamp-out-of-reach`
|
||||
- `hatch-static-gradient-repeat-disabled`
|
||||
- `hatch-static-renderer-untouched`
|
||||
- `hatch-stroke-not-scaled`
|
||||
- `hatch-zoom-compensation-back`
|
||||
- `iso-first-frame-reveals-flat-during-lazy-load`
|
||||
- `iso-room-label-44-box-centres-name`
|
||||
- `iso-sun-card-drops-occluders`
|
||||
|
||||
@@ -4004,6 +4004,59 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'daycycle-scene-transform-hint-back',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/daycycle-layers.test.mjs',
|
||||
because: '#689 K1: a will-change: transform hint freezes the scene raster scale; after '
|
||||
+ 'zooming from 100 % the plan shows the stretched 100 % raster (owner, Chrome 152). '
|
||||
+ 'Browser proof: demo/smoke_daycycle_zoom_layers.mjs',
|
||||
patches: [{
|
||||
file: 'src/styles/plan.styles.ts',
|
||||
find: ' .stage.daycycle.hp-safe-daycycle-outline .plan-svg {\n'
|
||||
+ ' will-change: opacity;\n',
|
||||
replace: ' .stage.daycycle.hp-safe-daycycle-outline .plan-svg {\n'
|
||||
+ ' will-change: transform;\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'daycycle-outline-overflow-visible',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/daycycle-layers.test.mjs',
|
||||
because: '#689 K3: an unclipped filtered outline covers the whole paper at the current '
|
||||
+ 'zoom (12.8x the stage at ~460 %, far more at 800 %) and navigation flashes white',
|
||||
patches: [{
|
||||
file: 'src/styles/plan.styles.ts',
|
||||
find: ' .stage .hp-paper-outline-svg { overflow: hidden; }\n',
|
||||
replace: ' .stage .hp-paper-outline-svg { overflow: visible; }\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'live-exposure-unbounded',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/live-viewport.test.mjs',
|
||||
because: '#689 K3: without the clip a projected scene exposes its whole content and the '
|
||||
+ 'promoted layer grows with zoom squared (plan-svg 15.9x the stage at ~460 %)',
|
||||
patches: [{
|
||||
file: 'src/live-viewport.ts',
|
||||
find: ' if (expose && style.clipPath !== LIVE_SCENE_EXPOSURE_CLIP) {\n'
|
||||
+ ' style.clipPath = LIVE_SCENE_EXPOSURE_CLIP;\n'
|
||||
+ ' }\n',
|
||||
replace: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'live-exposure-opens-clipped-scene',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test test/live-viewport.test.mjs',
|
||||
because: '#689 K3: the filtered day/night outline must never be opened by a gesture: its '
|
||||
+ 'filter layer precedes any clip-path and would again span the whole paper',
|
||||
patches: [{
|
||||
file: 'src/live-viewport.ts',
|
||||
find: " && (layer as unknown as Element).getAttribute?.(SCENE_OVERFLOW_ATTRIBUTE) !== 'clip';",
|
||||
replace: ' && true;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'opening-light-quantum-identity',
|
||||
guard: 'node --test --test-name-pattern="#366" test/logic.test.mjs',
|
||||
@@ -7824,24 +7877,24 @@ const MUTANT_DEFINITIONS = [
|
||||
{
|
||||
id: 'hatch-stroke-not-scaled',
|
||||
guard: 'node demo/smoke_wall_hatch_density.mjs',
|
||||
because: 'штриховка обязана следовать за шагом: иначе на мелкой клетке '
|
||||
+ 'полосы слипаются в сплошное пятно, а на крупной расходятся',
|
||||
because: 'штрих обязан следовать за шагом: иначе на мелкой клетке полосы '
|
||||
+ 'слипаются в сплошное пятно, а на крупной становятся волосяными',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'x2=${hatchStep} gradientTransform=rotate(45)',
|
||||
replace: 'x2=${hatchStep * 7 / 8} gradientTransform=rotate(45)',
|
||||
find: ' const stripe = 2 * (hatchStep / HATCH_BASE_STEP_UNITS);',
|
||||
replace: ' const stripe = 2;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'hatch-static-gradient-repeat-disabled',
|
||||
guard: 'node demo/smoke_static_zoom_sharpness.mjs',
|
||||
because: 'без repeat аналитический paint-server оставляет одну полосу и '
|
||||
+ 'возвращает масштабозависимую пустую/мягкую штриховку #685; терминальный '
|
||||
+ 'smoke обязан доказывать стабилизацию, а не только конечный viewBox',
|
||||
id: 'hatch-zoom-compensation-back',
|
||||
guard: 'node demo/smoke_wall_hatch_density.mjs',
|
||||
because: 'компенсация 1/zoom возвращает ровно то, ради устранения чего '
|
||||
+ 'задача и делалась: стена меняет вид при зуме (решение владельца §4.2)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: 'gradientTransform=rotate(45) spreadMethod=repeat',
|
||||
replace: 'gradientTransform=rotate(45) spreadMethod=pad',
|
||||
find: ' width="${hatchStep}" height="${hatchStep}" patternTransform="rotate(45)">',
|
||||
replace: ' width="${hatchStep}" height="${hatchStep}"\n'
|
||||
+ ' patternTransform="rotate(45) scale(${Math.max(0.4, 1 / Math.max(this._zoom, 0.4)).toFixed(3)})">',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -9728,7 +9781,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'until pointerup or a budgeted viewBox refresh; terminal screenshots miss the defect',
|
||||
patches: [{
|
||||
file: 'src/live-viewport.ts',
|
||||
find: " if (options.exposeSceneOverflow && style.overflow !== 'visible') {\n"
|
||||
find: " if (expose && style.overflow !== 'visible') {\n"
|
||||
+ " style.overflow = 'visible';\n"
|
||||
+ ' }',
|
||||
replace: '',
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
innerContourForRoom, roomWallProfile, outsetContour, openingInnerFaceOffsetFromIndex,
|
||||
openingTunnelGeometriesFromIndex, openingWallIndex as buildOpeningWallIndex, drawWallPreviewD,
|
||||
DRAW_WALL_DEFAULT_CM, normalizeWallIntervals, intervalCmAt, wallBodyNeedsSolid,
|
||||
wallHatchNeedsSolid, wallHatchStepUnits, type OpeningTunnelGeometry,
|
||||
wallHatchNeedsSolid, wallHatchStepUnits, HATCH_BASE_STEP_UNITS, type OpeningTunnelGeometry,
|
||||
type OpeningWallIndex, type WallEntry, type WallInterval,
|
||||
} from './wall-thickness';
|
||||
import type { JunctionLimitViolation, JunctionSharedGeometry } from './junction-limits';
|
||||
@@ -10736,11 +10736,11 @@ export class HouseplanCard extends LitElement {
|
||||
);
|
||||
const transitionStageBg = modeVisual?.stageColor || stageBg;
|
||||
const transitionBrightness = modeVisual?.sceneBrightness ?? 1;
|
||||
// View owns the eager hatch; 100% stays byte-compatible, scaled views avoid a cached tile.
|
||||
// View owns the eager hatch: one pattern at every zoom (#689 reverted #685's gradient).
|
||||
const hatchStep = wallHatchStepUnits(this._cellCm);
|
||||
const wallHatch = svg`<defs>${this._zoom === 1
|
||||
? svg`<pattern id=hp-wall-hatch patternUnits=userSpaceOnUse width=${hatchStep} height=${hatchStep} patternTransform=rotate(45)><path d="M0 0V${hatchStep}" stroke=${disp.color} stroke-width=${hatchStep / 4}/></pattern>`
|
||||
: svg`<linearGradient id=hp-wall-hatch gradientUnits=userSpaceOnUse x2=${hatchStep} gradientTransform=rotate(45) spreadMethod=repeat><stop offset=.125 stop-color=${disp.color}/><stop offset=.125 stop-opacity=0 /><stop offset=.875 stop-opacity=0 /><stop offset=.875 stop-color=${disp.color}/></linearGradient>`}</defs>`;
|
||||
const stripe = 2 * (hatchStep / HATCH_BASE_STEP_UNITS);
|
||||
const wallHatch = svg`<defs><pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse"
|
||||
width="${hatchStep}" height="${hatchStep}" patternTransform="rotate(45)"><path d="M0 0 L0 ${hatchStep}" stroke="${disp.color || '#607d8b'}" stroke-width="${stripe}"></path></pattern></defs>`;
|
||||
const editorClose = html`<span class="editor-close-slot" aria-hidden=${this._mode === 'view' ? 'true' : nothing}>${this._mode !== 'view' ? html`<button
|
||||
class="closex" title=${this._t('title.close_editor')} aria-label=${this._t('title.close_editor')} data-hp="editor-close" data-editor-navigation="view"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this._setMode('view'); }}><ha-icon icon="mdi:close"></ha-icon></button>`
|
||||
@@ -10884,7 +10884,7 @@ export class HouseplanCard extends LitElement {
|
||||
${isoFrame?.underlay ?? nothing}
|
||||
</svg>` : nothing}
|
||||
${dayCycle && paperShapes.length ? svg`<svg class="hp-paper-outline-svg"
|
||||
data-hp-live-viewbox=${iso ? 'camera' : 'floor'}
|
||||
data-hp-live-viewbox=${iso ? 'camera' : 'floor'} data-hp-live-overflow="clip"
|
||||
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
|
||||
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
|
||||
<g transform=${iso ? isoFloorMatrixCss() : nothing}>
|
||||
|
||||
+33
-5
@@ -109,19 +109,43 @@ const projectionText = (projection: LiveLayerProjection): string =>
|
||||
`translate(${projection.translateXPercent}%,${projection.translateYPercent}%)`
|
||||
+ ` scale(${projection.scaleX},${projection.scaleY})`;
|
||||
|
||||
/**
|
||||
* #689: how far an exposed scene may paint beyond its own viewport box while
|
||||
* it is projected. The budgeted `viewBox` refresh fires at a 15 % shift or
|
||||
* scale change (`LIVE_VIEWBOX_REFRESH_SHIFT`), so 25 % covers every incoming
|
||||
* edge #544 needs. Unbounded `overflow: visible` let a promoted scene layer
|
||||
* grow with zoom² — 15.9× the stage at ~460 %, hundreds of MB of GPU memory at
|
||||
* 800 % × DPR 2 — and the page flashed white.
|
||||
*/
|
||||
export const LIVE_SCENE_EXPOSURE_CLIP = 'inset(-25%)';
|
||||
|
||||
/** A scene marked so is clipped to its own box and never exposed (#689). */
|
||||
const SCENE_OVERFLOW_ATTRIBUTE = 'data-hp-live-overflow';
|
||||
|
||||
const exposesOverflow = (
|
||||
layer: ElementCSSInlineStyle,
|
||||
options: { exposeSceneOverflow?: boolean },
|
||||
): boolean => options.exposeSceneOverflow === true
|
||||
&& (layer as unknown as Element).getAttribute?.(SCENE_OVERFLOW_ATTRIBUTE) !== 'clip';
|
||||
|
||||
const setLayerProjection = (
|
||||
layer: ElementCSSInlineStyle,
|
||||
projection: LiveLayerProjection | null,
|
||||
options: { exposeSceneOverflow?: boolean } = {},
|
||||
): void => {
|
||||
const style = layer.style;
|
||||
const expose = exposesOverflow(layer, options);
|
||||
if (!projection) {
|
||||
// #544: the SVG viewport may be opened only while it is being projected.
|
||||
// `.stage` remains the outer clip, while removing this inline value keeps
|
||||
// the settled DOM and filter/compositing path byte-equivalent to #531.
|
||||
if (options.exposeSceneOverflow && style.overflow === 'visible') {
|
||||
// `.stage` remains the outer clip, while removing these inline values
|
||||
// keeps the settled DOM and filter/compositing path byte-equivalent to
|
||||
// #531. The bound (#689) leaves together with the exposure it bounds.
|
||||
if (expose && style.overflow === 'visible') {
|
||||
style.removeProperty('overflow');
|
||||
}
|
||||
if (expose && style.clipPath === LIVE_SCENE_EXPOSURE_CLIP) {
|
||||
style.removeProperty('clip-path');
|
||||
}
|
||||
// #531: снимать только то, что стоит. Лишняя запись в стиль — это
|
||||
// инвалидация, а тихий кадр обязан оставлять DOM нетронутым.
|
||||
if (style.transform) {
|
||||
@@ -134,10 +158,14 @@ const setLayerProjection = (
|
||||
// A transformed SVG keeps its old viewport box. Without exposing the scene
|
||||
// beyond that internal box, the incoming edge shows `.stage` background
|
||||
// until the next budgeted viewBox refresh (#544). The stage still clips the
|
||||
// complete card, so no scene pixels escape the visible plan surface.
|
||||
if (options.exposeSceneOverflow && style.overflow !== 'visible') {
|
||||
// complete card, so no scene pixels escape the visible plan surface; the
|
||||
// clip keeps the promoted layer stage-sized at any zoom (#689).
|
||||
if (expose && style.overflow !== 'visible') {
|
||||
style.overflow = 'visible';
|
||||
}
|
||||
if (expose && style.clipPath !== LIVE_SCENE_EXPOSURE_CLIP) {
|
||||
style.clipPath = LIVE_SCENE_EXPOSURE_CLIP;
|
||||
}
|
||||
const text = projectionText(projection);
|
||||
// Promotion is gesture-scoped, not a per-frame hint (#579). Avoid even
|
||||
// equal writes while the projection changes: WebViews may treat them as
|
||||
|
||||
@@ -113,8 +113,17 @@ export const planStyles = css`
|
||||
.stage.daycycle.hp-safe-daycycle-outline .hp-paper-outline-svg {
|
||||
transition: none;
|
||||
}
|
||||
/* #689: the scene keeps its explicit stage-sized layer (#582: never an
|
||||
implicit overlap layer over the promoted outline), but through an
|
||||
opacity hint, not a transform hint. Chromium freezes the raster scale
|
||||
of a will-change: transform layer, so after zooming from 100 % to 800 %
|
||||
the plan showed the 100 % raster stretched eightfold, while a page
|
||||
loaded at 800 % was sharp (owner-verified in Chrome 152). An opacity
|
||||
hint re-rasters at the current scale; translateZ(0) computes to a 2D
|
||||
identity there and is no stable layer reason. The live viewport's
|
||||
inline transform hint still owns a gesture (#579). */
|
||||
.stage.daycycle.hp-safe-daycycle-outline .plan-svg {
|
||||
will-change: transform;
|
||||
will-change: opacity;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hp-day-cycle-bg,
|
||||
@@ -235,6 +244,13 @@ export const planStyles = css`
|
||||
visibility: hidden;
|
||||
}
|
||||
.stage.hp-safe-daycycle-outline .hp-paper-outline-svg { visibility: visible; }
|
||||
/* #689: the full card's filtered outline is clipped to its own box. With
|
||||
overflow visible its filter layer covered the whole paper at the
|
||||
current zoom — 12.8× the stage at ~460 %, hundreds of MB at 800 % ×
|
||||
DPR 2 — and navigation flashed white. It is marked
|
||||
data-hp-live-overflow="clip" so a gesture never exposes it either; the
|
||||
static card keeps its fixed-view outline unchanged. */
|
||||
.stage .hp-paper-outline-svg { overflow: hidden; }
|
||||
.hp-static-stage .hp-paper-outline-svg { visibility: visible; }
|
||||
.iso-underlay-svg { z-index: 0; overflow: visible; }
|
||||
.iso-shadows-svg { z-index: 3; overflow: visible; }
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// #689: day/night composition must not freeze the plan raster nor grow with
|
||||
// zoom². The browser proof lives in demo/smoke_static_zoom_sharpness.mjs and
|
||||
// demo/smoke_daycycle_layer_budget.mjs; this suite pins the cascade without
|
||||
// Chromium so the mutants that guard it stay off the capped browser
|
||||
// inventory (#659).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planStyles } from '../test-build/styles.js';
|
||||
|
||||
/** Top-level and @media rules of one stylesheet as [selectors[], decls{}]. */
|
||||
const rulesOf = (cssText) => {
|
||||
const text = cssText.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const out = [];
|
||||
const walk = (chunk) => {
|
||||
let i = 0;
|
||||
while (i < chunk.length) {
|
||||
const open = chunk.indexOf('{', i);
|
||||
if (open === -1) break;
|
||||
const header = chunk.slice(i, open).replace(/\s+/g, ' ').trim();
|
||||
let depth = 1, j = open + 1;
|
||||
while (j < chunk.length && depth > 0) {
|
||||
if (chunk[j] === '{') depth++;
|
||||
else if (chunk[j] === '}') depth--;
|
||||
j++;
|
||||
}
|
||||
const body = chunk.slice(open + 1, j - 1);
|
||||
if (header.startsWith('@media') || header.startsWith('@supports')) walk(body);
|
||||
else if (!header.startsWith('@')) {
|
||||
const decls = {};
|
||||
for (const part of body.split(';')) {
|
||||
const colon = part.indexOf(':');
|
||||
if (colon > 0) decls[part.slice(0, colon).trim()] = part.slice(colon + 1).trim();
|
||||
}
|
||||
out.push([header.split(',').map((s) => s.trim()), decls]);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
};
|
||||
walk(text);
|
||||
return out;
|
||||
};
|
||||
|
||||
const RULES = rulesOf(planStyles.cssText);
|
||||
const touching = (predicate) => RULES.filter(([sels]) => sels.some(predicate));
|
||||
|
||||
test('#689 K1: no plan-svg rule carries a will-change: transform hint', () => {
|
||||
const hinted = touching((s) => /\.plan-svg\b/.test(s))
|
||||
.filter(([, d]) => /\btransform\b/.test(d['will-change'] || ''));
|
||||
assert.deepEqual(hinted.map(([sels]) => sels.join(', ')), []);
|
||||
});
|
||||
|
||||
test('#689 K1: the safe day/night scene keeps an explicit layer via an opacity hint', () => {
|
||||
const decls = RULES.filter(([sels]) => sels.includes('.stage.daycycle.hp-safe-daycycle-outline .plan-svg'))
|
||||
.map(([, d]) => d);
|
||||
assert.ok(decls.some((d) => d['will-change'] === 'opacity'), 'the #582 explicit layer must stay');
|
||||
});
|
||||
|
||||
test('#689 K3: the full card clips its filtered outline to its own box', () => {
|
||||
const clip = RULES.filter(([sels]) => sels.includes('.stage .hp-paper-outline-svg')).map(([, d]) => d);
|
||||
assert.ok(clip.some((d) => d.overflow === 'hidden'), '.stage .hp-paper-outline-svg must be overflow: hidden');
|
||||
const opened = touching((s) => /^\.stage\b/.test(s) && /\.hp-paper-outline-svg\b/.test(s))
|
||||
.filter(([, d]) => d.overflow === 'visible');
|
||||
assert.deepEqual(opened.map(([sels]) => sels.join(', ')), []);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
LIVE_SCENE_EXPOSURE_CLIP,
|
||||
isIdentityLiveLayerProjection,
|
||||
liveLayerProjection,
|
||||
liveViewBoxText,
|
||||
@@ -234,3 +235,42 @@ test('#531 изометрия: камера и пол проецируются
|
||||
assert.equal(root.camera.style.transform, undefined);
|
||||
assert.equal(root.floor.style.transform, undefined);
|
||||
});
|
||||
|
||||
// #689. Unbounded `overflow: visible` let a projected scene layer grow with
|
||||
// zoom² (CDP LayerTree, ~460 %: plan-svg 15.9× and the day-cycle outline
|
||||
// 13.2× the stage); at 800 % × DPR 2 that is hundreds of MB of GPU memory
|
||||
// and the page flashed white. The exposure is bounded and leaves with it.
|
||||
test('#689 AC3: exposure is bounded by a clip that leaves together with it', () => {
|
||||
const root = fakeRoot();
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0 });
|
||||
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 10 });
|
||||
assert.equal(LIVE_SCENE_EXPOSURE_CLIP, 'inset(-25%)');
|
||||
for (const scene of [root.camera, root.cameraPeer, root.floor]) {
|
||||
assert.equal(scene.style.overflow, 'visible', 'the incoming edge is still exposed (#544)');
|
||||
assert.equal(scene.style.clipPath, LIVE_SCENE_EXPOSURE_CLIP, 'but only within the bound');
|
||||
}
|
||||
assert.equal(root.layer.style.clipPath, undefined, 'HTML layers get no scene clip');
|
||||
paintLiveViewport(root, painted, painted, anchor, { now: 20, force: true });
|
||||
for (const scene of [root.camera, root.cameraPeer, root.floor]) {
|
||||
assert.equal(scene.style.overflow, undefined, 'idle DOM keeps no exposure (#531)');
|
||||
assert.equal(scene.style.clipPath, undefined, 'idle DOM keeps no clip (#531)');
|
||||
assert.equal(scene.style.transform, undefined);
|
||||
assert.ok(scene.styleRemovals.includes('clip-path'));
|
||||
}
|
||||
});
|
||||
|
||||
test('#689 AC3: a scene marked clip is projected but never exposed', () => {
|
||||
const root = fakeRoot();
|
||||
root.floor.attrs['data-hp-live-overflow'] = 'clip';
|
||||
const painted = frame(0, 0);
|
||||
let anchor = paintLiveViewport(root, painted, painted, null, { now: 0, keepSceneLayer: true });
|
||||
anchor = paintLiveViewport(root, painted, frame(10, 0), anchor, { now: 10, keepSceneLayer: true });
|
||||
assert.match(root.floor.style.transform, /^translate\(-1%,0%\) scale\(1,1\)$/, 'it still follows the gesture');
|
||||
assert.equal(root.floor.style.overflow, undefined, 'the filtered outline is never opened');
|
||||
assert.equal(root.floor.style.clipPath, undefined);
|
||||
assert.equal(root.camera.style.clipPath, LIVE_SCENE_EXPOSURE_CLIP, 'unmarked scenes are still exposed');
|
||||
paintLiveViewport(root, painted, painted, anchor, { now: 20, force: true });
|
||||
assert.equal(root.floor.style.transform, undefined);
|
||||
assert.equal(root.floor.styleRemovals.includes('overflow'), false, 'nothing to remove, nothing written');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user