fix: стабилизировать штриховку после зума (#685)

Issue: #685
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-28 07:35:39 +00:00
committed by claude[bot]
parent e8e7ecc9f2
commit 13af1d5e30
12 changed files with 305 additions and 56 deletions
+30
View File
@@ -1393,6 +1393,36 @@ export async function prepareGoldenScenario(page, scenario) {
card.requestUpdate();
await card.updateComplete;
}
if (scenario.staticHatchSharpness) {
const root = card.renderRoot;
const scene = root.querySelector('.plan-svg');
const view = card._viewOr(card._baseVb());
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 visibleKinds = new Set([...root.querySelectorAll('.opening[data-kind]')]
.map((node) => node.getAttribute('data-kind')));
const modelKinds = new Set((card._openingsR || []).map((opening) => opening.type));
const settled = scene?.getAttribute('viewBox') === exactViewBox
&& scenes.every((node) => ['', 'none'].includes(getComputedStyle(node).transform))
&& 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';
const openings = ['door', 'window', 'gate'].every((kind) => visibleKinds.has(kind))
&& modelKinds.has('passage');
if (!settled || !analytic || !openings
|| Math.abs(card._zoom - scenario.zoom) > 1e-9) {
throw new Error(`static hatch sharpness contract failed: ${scenario.id}`);
}
}
if (scenario.wallJunctionPreview) {
const { path, pointer, cms, cm } = scenario.wallJunctionPreview;
const validPoint = (point) => Array.isArray(point) && point.length === 2
+16 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 66;
export const GOLDEN_MATRIX_VERSION = 67;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -653,6 +653,21 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'opening-symbol-room-wall-light', fixture: 'visual', space: 'golden-opening-symbols',
mode: 'view', openingSymbolContract: openingRoomContract,
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
...[
['static-hatch-openings-132-dpr1-light', 1.32, 1, 'light'],
['static-hatch-openings-140-dpr1-dark', 1.40, 1, 'dark'],
['static-hatch-openings-132-dpr2-dark', 1.32, 2, 'dark'],
['static-hatch-openings-140-dpr2-light', 1.40, 2, 'light'],
].map(([id, zoom, deviceScaleFactor, theme]) => ({
id, fixture: 'visual', space: 'golden-opening-symbols', mode: 'view',
openingSymbolContract: openingRoomContract,
extraOpenings: [{
id: 'sharpness-passage', type: 'passage', x: 0.62, y: 0.50,
angle: 0, length: 0.06,
}],
staticHatchSharpness: true, zoom, zoomCenter: [500, 500],
deviceScaleFactor, theme, viewport: { width: 1000, height: 900 }, ...stage,
})),
{ id: 'opening-symbol-diagonal-partition-dark', fixture: 'visual', space: 'golden-opening-symbols',
mode: 'view', openingSymbolContract: openingPartitionContract,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
+154
View File
@@ -0,0 +1,154 @@
// 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.
import { createHash } from 'node:crypto';
import { launch, checkAll, finish } from './serve.mjs';
import { prepareGoldenScenario } from './golden/harness.mjs';
import { GOLDEN_SCENARIOS } from './golden/matrix.mjs';
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'opening-symbol-room-wall-light');
if (!scenario) throw new Error('opening-symbol-room-wall-light fixture is missing');
const sharpnessScenario = { ...scenario, extraOpenings: [{
id: 'sharpness-passage', type: 'passage', x: 0.62, y: 0.5, angle: 0, length: 0.06,
}] };
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
await prepareGoldenScenario(page, sharpnessScenario);
const settle = async () => {
await page.waitForFunction(() => !window.__goldenCard?._cameraTransition?.active);
await page.evaluate(async () => {
const card = window.__goldenCard;
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
};
const setZoom = async (zoom) => {
await page.evaluate((value) => {
const card = window.__goldenCard;
const vb = card._baseVb();
card._applyView(value, vb[0] + vb[2] / 2, vb[1] + vb[3] / 2);
card.requestUpdate();
}, zoom);
await settle();
};
const state = () => page.evaluate(() => {
const card = window.__goldenCard;
const root = card.renderRoot;
const scene = root.querySelector('.plan-svg');
const view = card._viewOr(card._baseVb());
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 kinds = new Set([...root.querySelectorAll('.opening[data-kind]')]
.map((node) => node.getAttribute('data-kind')));
const modelKinds = new Set((card._openingsR || []).map((opening) => opening.type));
return {
zoom: card._zoom,
exactViewBox: scene?.getAttribute('viewBox') === exact,
noSceneTransform: scenes.every((node) => ['', 'none'].includes(getComputedStyle(node).transform)),
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',
openingKinds: ['door', 'window', 'gate'].every((kind) => kinds.has(kind))
&& modelKinds.has('passage'),
};
});
const clip = await page.locator('houseplan-card').evaluate((card) => {
const rect = card.renderRoot.querySelector('.stage').getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
const shotHash = async () => createHash('sha256')
.update(await page.screenshot({ clip, animations: 'disabled', scale: 'css' }))
.digest('hex');
// Button: 100 -> 140%.
await setZoom(1);
await page.locator('houseplan-card').evaluate((card) =>
card.renderRoot.querySelector('[data-hp="zoom-in"]').click());
await settle();
const button = await state();
const buttonHash = await shotHash();
// A different route to the exact same target: 196 -> 140%.
await setZoom(1.96);
await page.locator('houseplan-card').evaluate((card) =>
card.renderRoot.querySelector('[data-hp="zoom-out"]').click());
await settle();
const buttonReverse = await state();
const reverseHash = await shotHash();
// Wheel: one centred notch, 100 -> 115%.
await setZoom(1);
await page.locator('houseplan-card').evaluate((card) => {
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
stage.dispatchEvent(new WheelEvent('wheel', {
bubbles: true, cancelable: true, deltaY: -100,
clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
}));
});
await settle();
const wheel = await state();
// Pinch: 100 -> 132%, then release both fingers and wait for the terminal Lit
// frame. The intermediate promoted transform is allowed; the returned state is
// deliberately sampled only after the two full settled frames above.
await setZoom(1);
await page.locator('houseplan-card').evaluate((card) => {
const stage = card.renderRoot.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const emit = (type, id, x, y) => stage.dispatchEvent(new PointerEvent(type, {
bubbles: true, composed: true, cancelable: true,
pointerId: id, pointerType: 'touch', isPrimary: id === 71,
button: 0, buttons: type === 'pointerup' ? 0 : 1,
clientX: rect.left + x, clientY: rect.top + y,
}));
const cx = rect.width / 2, cy = rect.height / 2;
emit('pointerdown', 71, cx - 100, cy);
emit('pointerdown', 72, cx + 100, cy);
emit('pointermove', 71, cx - 132, cy);
emit('pointermove', 72, cx + 132, cy);
emit('pointerup', 71, cx - 132, cy);
emit('pointerup', 72, cx + 132, cy);
});
await settle();
const pinch = await state();
// The same architectural SVG is shared by View and all three editors.
const modes = [];
for (const mode of ['view', 'plan', 'devices', 'decor']) {
await page.evaluate((value) => window.__hpTest.setMode(value), mode);
await page.waitForTimeout(350);
await settle();
await setZoom(1.32);
modes.push(await state());
}
const terminal = (sample) => sample.exactViewBox && sample.noSceneTransform
&& sample.noSceneWillChange && sample.noLayerTransform && sample.analyticHatch
&& sample.openingKinds;
const out = {
buttonSettlesAt140: Math.abs(button.zoom - 1.4) < 1e-9 && terminal(button),
reverseButtonSettlesAt140: Math.abs(buttonReverse.zoom - 1.4) < 1e-9
&& terminal(buttonReverse),
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),
};
checkAll(out);
await finish(browser, { ...out, buttonHash, reverseHash, button, buttonReverse, wheel, pinch, modes });
+51 -24
View File
@@ -1,8 +1,11 @@
// 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 pattern, that neither of them scales
// it back by zoom, and that the two agree with each other.
// 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.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const res = await page.evaluate(async () => {
@@ -13,14 +16,34 @@ const res = await page.evaluate(async () => {
await c.updateComplete;
};
const space = () => c._serverCfg.spaces.find((s) => s.id === c._space);
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') || '',
});
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',
};
};
c._mode = 'plan'; c.requestUpdate(); await settle();
await new Promise((r) => setTimeout(r, 400));
@@ -41,16 +64,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(pattern(c.shadowRoot));
out.referenceStepIsEight = atFive?.width === 8 && atFive?.height === 8;
out.referenceStrokeIsTwo = atFive?.stroke === 2;
out.noZoomScaleAtReference = !!atFive && !/scale/.test(atFive.transform);
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;
// Zoom must not touch the pattern any more — that is the whole point.
c._applyView(3); await settle();
const zoomed = read(pattern(c.shadowRoot));
c._applyView(3); c.requestUpdate(); await settle();
const zoomed = read(paint(c.shadowRoot));
out.zoomDoesNotChangeThePattern = JSON.stringify(zoomed) === JSON.stringify(atFive);
c._applyView(1); await settle();
c._applyView(1); c.requestUpdate(); await settle();
// A coarse grid: the step follows the centimetres, so it shrinks in units.
space().cell_cm = 25;
@@ -59,10 +82,10 @@ const res = await page.evaluate(async () => {
c._saveConfig();
await new Promise((r) => setTimeout(r, 500));
c.requestUpdate(); await settle();
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}`;
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;
// 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).
@@ -78,10 +101,14 @@ const res = await page.evaluate(async () => {
await new Promise((r) => setTimeout(r, 80));
}
await card.updateComplete;
const staticPattern = read(pattern(card.renderRoot));
const staticPattern = read(paint(card.renderRoot));
out.staticRendererFollowsTheCell = !!staticPattern
&& Math.abs(staticPattern.width - 1.6) < 1e-9;
out.bothRenderersAgree = JSON.stringify(staticPattern) === JSON.stringify(atTwentyFive);
&& 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;
return out;
});
+7
View File
@@ -602,6 +602,13 @@ 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)
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Settled zoom levels now keep physical wall hatching crisp and consistent,
including fractional zooms and high-DPI screens. The short live zoom/pinch
animation is unchanged, while the final wall and door/window/gate/passage
frame is rendered directly at its target scale
([#685](https://github.com/Matysh/houseplan-card/issues/685)).
- A floor-link stair in View no longer shows the browser's black-and-white
focus frame when it receives focus; it can still be reached with Tab and
followed with Enter or Space
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- После завершения зума физические стены и их штриховка остаются чёткими и
одинаковыми на дробных масштабах и экранах с высоким DPI. Короткая анимация
зума и pinch не менялась, а итоговый кадр стен и проёмов — дверей, окон,
ворот и проходов — рисуется сразу для конечного масштаба
([#685](https://github.com/Matysh/houseplan-card/issues/685)).
- Лестница-переход в режиме просмотра больше не показывает чёрно-белую рамку
фокуса браузера; до неё по-прежнему можно дойти клавишей Tab и перейти по
Enter или Пробелу
+12
View File
@@ -294,6 +294,18 @@ 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`
+11 -11
View File
@@ -7824,24 +7824,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: ' const stripe = 2 * (step / HATCH_BASE_STEP_UNITS);',
replace: ' const stripe = 2;',
find: 'x2=${hatchStep} gradientTransform=rotate(45)',
replace: 'x2=${hatchStep * 7 / 8} gradientTransform=rotate(45)',
}],
},
{
id: 'hatch-zoom-compensation-back',
guard: 'node demo/smoke_wall_hatch_density.mjs',
because: 'компенсация 1/zoom возвращает ровно то, ради устранения чего '
+ 'задача и делалась: стена меняет вид при зуме (решение владельца §4.2)',
id: 'hatch-static-gradient-repeat-disabled',
guard: 'node demo/smoke_static_zoom_sharpness.mjs',
because: 'без repeat аналитический paint-server оставляет одну полосу и '
+ 'возвращает масштабозависимую пустую/мягкую штриховку #685; терминальный '
+ 'smoke обязан доказывать стабилизацию, а не только конечный viewBox',
patches: [{
file: 'src/houseplan-card.ts',
find: ' width="${step}" height="${step}" patternTransform="rotate(45)">',
replace: ' width="${step}" height="${step}"\n'
+ ' patternTransform="rotate(45) scale(${Math.max(0.4, 1 / Math.max(this._zoom, 0.4)).toFixed(3)})">',
find: 'gradientTransform=rotate(45) spreadMethod=repeat',
replace: 'gradientTransform=rotate(45) spreadMethod=pad',
}],
},
{
+7 -16
View File
@@ -56,7 +56,7 @@ import {
innerContourForRoom, roomWallProfile, outsetContour, openingInnerFaceOffsetFromIndex,
openingTunnelGeometriesFromIndex, openingWallIndex as buildOpeningWallIndex, drawWallPreviewD,
DRAW_WALL_DEFAULT_CM, normalizeWallIntervals, intervalCmAt, wallBodyNeedsSolid,
wallHatchNeedsSolid, wallHatchStepUnits, HATCH_BASE_STEP_UNITS, type OpeningTunnelGeometry,
wallHatchNeedsSolid, wallHatchStepUnits, type OpeningTunnelGeometry,
type OpeningWallIndex, type WallEntry, type WallInterval,
} from './wall-thickness';
import type { JunctionLimitViolation, JunctionSharedGeometry } from './junction-limits';
@@ -8856,20 +8856,6 @@ export class HouseplanCard extends LitElement {
return this._editorRuntimeOrThrow()._wallThickApply(allRoom);
}
private _wallHatchDefs(color: string): TemplateResult {
// Hatching is visible in View as well as in Plan, so its tiny SVG
// definition belongs to the eager projection rather than editor runtime.
const step = wallHatchStepUnits(this._cellCm);
const stripe = 2 * (step / HATCH_BASE_STEP_UNITS);
const stroke = color || '#607d8b';
return svg`<defs>
<pattern id="hp-wall-hatch" patternUnits="userSpaceOnUse"
width="${step}" height="${step}" patternTransform="rotate(45)">
<path d="M0 0 L0 ${step}" stroke="${stroke}" stroke-width="${stripe}"></path>
</pattern>
</defs>` as unknown as TemplateResult;
}
/**
* One authoritative room-fill projection per render frame. Room polygons and
* thick-wall opening tunnels consume the same object, so a live HA tick can
@@ -10741,6 +10727,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.
const hatchStep = wallHatchStepUnits(this._cellCm);
const wallHatch = 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>`;
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>`
@@ -10911,7 +10902,7 @@ export class HouseplanCard extends LitElement {
`space` comes from _renderCfg, so a live resize preview
controller preview moves the paper together with the rooms.
One <g> keeps the visible sheet and filtered silhouette free of room seams. */}
${this._wallHatchDefs(disp.color)}${renderPaperShapes(paperShapes)}
${wallHatch}${renderPaperShapes(paperShapes)}
${this._editing ? this._renderMarkupDefs(vb) : nothing}
${''/* the grid is a property of the plane, not of a box: it follows
the VIEW so it is there wherever you pan (docs/CANVAS.md §7) */}
+4 -3
View File
@@ -446,7 +446,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 66);
assert.equal(GOLDEN_MATRIX_VERSION, 67);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -809,7 +809,8 @@ test('diagonal opening golden asserts a real painted 45 degree symbol', () => {
});
test('opening symbol goldens lock room, diagonal, flip-pair and hidden Iso contracts', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.openingSymbolContract);
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.openingSymbolContract
&& !item.staticHatchSharpness);
assert.deepEqual(scenarios.map((item) => item.id), [
'opening-symbol-room-wall-light',
'opening-symbol-diagonal-partition-dark',
@@ -939,7 +940,7 @@ test('issue 570 Stage 4 reuses the historical iso goldens for visual handoff cov
test('#673 Stage 6 designer acceptance scenes are canonical golden entries', () => {
const ids = STAGE6_ACCEPTANCE_SCENARIOS.map((scenario) => scenario.id);
assert.equal(GOLDEN_MATRIX_VERSION, 66);
assert.equal(GOLDEN_MATRIX_VERSION, 67);
assert.deepEqual(ids, [
'isometric-stage6-light-lightfloor',
'isometric-stage6-light-darkfloor',