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:
Claude
2026-09-28 17:51:06 +03:00
parent c1e05e465b
commit 600330e187
18 changed files with 538 additions and 121 deletions
+6 -6
View File
@@ -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
View File
@@ -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
+17 -1
View File
@@ -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; }