From 13af1d5e30ec2c743a6983d6a63685f5c33c1220 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Mon, 28 Sep 2026 09:42:49 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=88?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D1=85=D0=BE=D0=B2=D0=BA=D1=83=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D0=B7=D1=83=D0=BC=D0=B0=20(#685)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #685 User-Visible: yes --- demo/golden/harness.mjs | 30 ++++ demo/golden/matrix.mjs | 17 +- demo/smoke_static_zoom_sharpness.mjs | 154 ++++++++++++++++++ demo/smoke_wall_hatch_density.mjs | 75 ++++++--- docs/ARCHITECTURE.md | 7 + docs/CHANGELOG.md | 6 + docs/CHANGELOG.ru.md | 6 + docs/WALL-THICKNESS.md | 12 ++ docs/testing-notes/mutation-browser-guards.md | 2 +- scripts/mutation-registry.mjs | 22 +-- src/houseplan-card.ts | 23 +-- test/golden-matrix.test.mjs | 7 +- 12 files changed, 305 insertions(+), 56 deletions(-) create mode 100644 demo/smoke_static_zoom_sharpness.mjs diff --git a/demo/golden/harness.mjs b/demo/golden/harness.mjs index d947cbd0..572efdf0 100644 --- a/demo/golden/harness.mjs +++ b/demo/golden/harness.mjs @@ -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 diff --git a/demo/golden/matrix.mjs b/demo/golden/matrix.mjs index 2c62b825..26746674 100644 --- a/demo/golden/matrix.mjs +++ b/demo/golden/matrix.mjs @@ -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 }, diff --git a/demo/smoke_static_zoom_sharpness.mjs b/demo/smoke_static_zoom_sharpness.mjs new file mode 100644 index 00000000..d7087477 --- /dev/null +++ b/demo/smoke_static_zoom_sharpness.mjs @@ -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 }); diff --git a/demo/smoke_wall_hatch_density.mjs b/demo/smoke_wall_hatch_density.mjs index 009d8004..670cbba3 100644 --- a/demo/smoke_wall_hatch_density.mjs +++ b/demo/smoke_wall_hatch_density.mjs @@ -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; }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f31bcad..ae723349 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 166edac1..47109b90 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index fcf8f2e7..319c42fc 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,12 @@ ## Не выпущено +- После завершения зума физические стены и их штриховка остаются чёткими и + одинаковыми на дробных масштабах и экранах с высоким DPI. Короткая анимация + зума и pinch не менялась, а итоговый кадр стен и проёмов — дверей, окон, + ворот и проходов — рисуется сразу для конечного масштаба + ([#685](https://github.com/Matysh/houseplan-card/issues/685)). + - Лестница-переход в режиме просмотра больше не показывает чёрно-белую рамку фокуса браузера; до неё по-прежнему можно дойти клавишей Tab и перейти по Enter или Пробелу diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md index 48c01d1c..5aa88884 100644 --- a/docs/WALL-THICKNESS.md +++ b/docs/WALL-THICKNESS.md @@ -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 diff --git a/docs/testing-notes/mutation-browser-guards.md b/docs/testing-notes/mutation-browser-guards.md index b99aeef4..b16d7ca1 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 544a8531..ab3b370a 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -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', }], }, { diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 701b0243..a4111212 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -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` - - - - ` 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`` + : svg``; const editorClose = html`${this._mode !== 'view' ? html`` @@ -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 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) */} diff --git a/test/golden-matrix.test.mjs b/test/golden-matrix.test.mjs index dd9a7625..b9c30e1b 100644 --- a/test/golden-matrix.test.mjs +++ b/test/golden-matrix.test.mjs @@ -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',