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',