diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index 572efdf0..7cd36ebf 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -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}`); } diff --git a/demo/smoke_daycycle_layer_budget.mjs b/demo/smoke_daycycle_layer_budget.mjs index fb23867e..f304a682 100644 --- a/demo/smoke_daycycle_layer_budget.mjs +++ b/demo/smoke_daycycle_layer_budget.mjs @@ -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( diff --git a/demo/smoke_daycycle_raster.mjs b/demo/smoke_daycycle_raster.mjs index 9a70332c..7f7b4f97 100644 --- a/demo/smoke_daycycle_raster.mjs +++ b/demo/smoke_daycycle_raster.mjs @@ -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)); diff --git a/demo/smoke_daycycle_zoom_layers.mjs b/demo/smoke_daycycle_zoom_layers.mjs new file mode 100644 index 00000000..93a045d6 --- /dev/null +++ b/demo/smoke_daycycle_zoom_layers.mjs @@ -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); diff --git a/demo/smoke_static_zoom_sharpness.mjs b/demo/smoke_static_zoom_sharpness.mjs index d7087477..ad064293 100644 --- a/demo/smoke_static_zoom_sharpness.mjs +++ b/demo/smoke_static_zoom_sharpness.mjs @@ -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 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); diff --git a/demo/smoke_wall_hatch_density.mjs b/demo/smoke_wall_hatch_density.mjs index 670cbba3..009d8004 100644 --- a/demo/smoke_wall_hatch_density.mjs +++ b/demo/smoke_wall_hatch_density.mjs @@ -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; }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae723349..6e45f616 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 266fcf9f..ce830f89 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 72e22bc2..0009827d 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 diff --git a/docs/SUN.md b/docs/SUN.md index adc70f07..7a29fe0a 100644 --- a/docs/SUN.md +++ b/docs/SUN.md @@ -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 diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index 5aa88884..48c01d1c 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -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 diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index b16d7ca1..b99aeef4 100644 --- a/docs/testing-notes/mutation-browser-guards.md +++ b/docs/testing-notes/mutation-browser-guards.md @@ -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` diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index c1421691..5f177680 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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: '', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 290d9d1c..3b37c652 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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`${this._zoom === 1 - ? svg`` - : svg``}`; + const stripe = 2 * (hatchStep / HATCH_BASE_STEP_UNITS); + const wallHatch = svg``; const editorClose = html`${this._mode !== 'view' ? html`` @@ -10884,7 +10884,7 @@ export class HouseplanCard extends LitElement { ${isoFrame?.underlay ?? nothing} ` : nothing} ${dayCycle && paperShapes.length ? svg`