mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: стабилизировать штриховку после зума (#685)
Issue: #685 User-Visible: yes
This commit is contained in:
committed by
claude[bot]
parent
e8e7ecc9f2
commit
13af1d5e30
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- После завершения зума физические стены и их штриховка остаются чёткими и
|
||||
одинаковыми на дробных масштабах и экранах с высоким DPI. Короткая анимация
|
||||
зума и pinch не менялась, а итоговый кадр стен и проёмов — дверей, окон,
|
||||
ворот и проходов — рисуется сразу для конечного масштаба
|
||||
([#685](https://github.com/Matysh/houseplan-card/issues/685)).
|
||||
|
||||
- Лестница-переход в режиме просмотра больше не показывает чёрно-белую рамку
|
||||
фокуса браузера; до неё по-прежнему можно дойти клавишей Tab и перейти по
|
||||
Enter или Пробелу
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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) */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user