fix(openings): preserve gate flip turn direction

Address code review H1 by keeping gate flip direction observable without a second vertical mirror. Add fail-closed golden contracts, smoke coverage, and a mutation guard for the affected opening-symbol geometry.

Issue: #242
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 21:14:37 +03:00
parent 2af7b0b5a2
commit a65db3985d
17 changed files with 609 additions and 53 deletions
File diff suppressed because one or more lines are too long
+80
View File
@@ -158,6 +158,86 @@ export function prepareGoldenFixture(scenario) {
},
});
}
if (scenario.openingSymbolContract) {
const contract = scenario.openingSymbolContract;
if (!['room', 'partition'].includes(contract.kind)
|| !['flat', 'iso'].includes(contract.surface)
|| !(contract.wallCm > 0)
|| !Array.isArray(contract.openings) || !contract.openings.length) {
throw new Error(`invalid golden openingSymbolContract: ${scenario.id}`);
}
const seen = new Set();
for (const opening of contract.openings) {
if (!opening?.id || seen.has(opening.id)
|| !['door', 'window', 'gate'].includes(opening.type)
|| !Number.isFinite(opening.at) || !Number.isFinite(opening.length)
|| !(opening.length > 0) || !['center', 'edge'].includes(opening.offset)
|| typeof opening.flipV !== 'boolean') {
throw new Error(`invalid golden opening symbol entry: ${opening?.id || '<empty>'}`);
}
if (opening.type === 'gate' && opening.offset !== 'center')
throw new Error(`golden gate must remain centred: ${opening.id}`);
seen.add(opening.id);
}
const tl = [0.08, 0.08], tr = [0.92, 0.08];
const mr = [0.92, 0.50], br = [0.92, 0.92];
const bl = [0.08, 0.92], ml = [0.08, 0.50];
const wall = (a, b, cm = 15) => ({
key: fixtureWallKey(a, b), a: [...a], b: [...b], cm,
});
let rooms;
let walls;
let partitions;
let openings;
if (contract.kind === 'room') {
rooms = [
{ id: 'golden-opening-upper', name: 'Upper', area: null,
poly: [tl, tr, mr, ml].map((point) => [...point]) },
{ id: 'golden-opening-lower', name: 'Lower', area: null,
poly: [ml, mr, br, bl].map((point) => [...point]) },
];
walls = [
wall(tl, tr), wall(tr, mr), wall(mr, ml, contract.wallCm), wall(ml, tl),
wall(mr, br), wall(br, bl), wall(bl, ml),
];
partitions = [];
openings = contract.openings.map((opening) => ({
id: opening.id, type: opening.type,
x: opening.at, y: 0.50, angle: 0, length: opening.length,
flip_v: opening.flipV,
}));
} else {
if (!Array.isArray(contract.a) || !Array.isArray(contract.b)
|| contract.a.length !== 2 || contract.b.length !== 2
|| ![...contract.a, ...contract.b].every(Number.isFinite)) {
throw new Error(`invalid golden diagonal partition: ${scenario.id}`);
}
rooms = [{ id: 'golden-opening-room', name: 'Opening symbols', area: null,
poly: [tl, tr, br, bl].map((point) => [...point]) }];
walls = [wall(tl, tr), wall(tr, br), wall(br, bl), wall(bl, tl)];
const a = contract.a, b = contract.b;
partitions = [{
id: 'golden-opening-partition', a: [...a], b: [...b], cm: contract.wallCm,
}];
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI;
openings = contract.openings.map((opening) => ({
id: opening.id, type: opening.type,
x: a[0] + (b[0] - a[0]) * opening.at,
y: a[1] + (b[1] - a[1]) * opening.at,
angle, length: opening.length, flip_v: opening.flipV,
host: { kind: 'partition', id: 'golden-opening-partition', t: opening.at },
}));
}
fixture.config.spaces.push({
id: scenario.space,
title: 'Opening symbol contract',
plan_url: null,
view_box: [0, 0, 1, 1],
cell_cm: 5,
settings: { fill_mode: 'none', show_borders: true, show_names: false },
rooms, walls, partitions, openings, wall_columns: [], decor: [],
});
}
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
+144 -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 = 36;
export const GOLDEN_MATRIX_VERSION = 37;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -11,6 +11,136 @@ const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, max
// URL/storage/registry behaviour and must fail if the rendered view stays flat.
const expiredIsoFixture = { testOnlyLabsSnapshot: true };
const openingRoomContract = {
kind: 'room', wallCm: 30, surface: 'flat',
openings: [
{ id: 'golden-centred-door', type: 'door', at: 0.25, length: 0.10,
flipV: false, offset: 'center' },
{ id: 'golden-centred-window', type: 'window', at: 0.50, length: 0.10,
flipV: false, offset: 'center' },
{ id: 'golden-centred-gate', type: 'gate', at: 0.75, length: 0.10,
flipV: false, offset: 'center' },
],
};
const openingPartitionContract = {
kind: 'partition', wallCm: 30, surface: 'flat',
a: [0.14, 0.22], b: [0.86, 0.78],
openings: [
{ id: 'golden-diagonal-door', type: 'door', at: 0.22, length: 0.09,
flipV: false, offset: 'center' },
{ id: 'golden-diagonal-window', type: 'window', at: 0.50, length: 0.09,
flipV: false, offset: 'center' },
{ id: 'golden-diagonal-gate', type: 'gate', at: 0.78, length: 0.09,
flipV: false, offset: 'center' },
],
};
const openingFlipContract = {
kind: 'room', wallCm: 30, surface: 'flat',
openings: [
{ id: 'golden-door-default', type: 'door', at: 0.16, length: 0.08,
flipV: false, offset: 'center' },
{ id: 'golden-door-flipped', type: 'door', at: 0.30, length: 0.08,
flipV: true, offset: 'edge' },
{ id: 'golden-window-default', type: 'window', at: 0.44, length: 0.08,
flipV: false, offset: 'center' },
{ id: 'golden-window-flipped', type: 'window', at: 0.58, length: 0.08,
flipV: true, offset: 'edge' },
{ id: 'golden-gate-default', type: 'gate', at: 0.72, length: 0.08,
flipV: false, offset: 'center', turnPair: 'golden-gate-turn' },
{ id: 'golden-gate-flipped', type: 'gate', at: 0.86, length: 0.08,
flipV: true, offset: 'center', turnPair: 'golden-gate-turn' },
],
};
const openingIsoContract = {
kind: 'room', wallCm: 30, surface: 'iso',
openings: [
{ id: 'golden-iso-door', type: 'door', at: 0.20, length: 0.09,
flipV: false, offset: 'center' },
{ id: 'golden-iso-window', type: 'window', at: 0.40, length: 0.09,
flipV: false, offset: 'center' },
{ id: 'golden-iso-gate-default', type: 'gate', at: 0.62, length: 0.09,
flipV: false, offset: 'center', turnPair: 'golden-iso-gate-turn' },
{ id: 'golden-iso-gate-flipped', type: 'gate', at: 0.82, length: 0.09,
flipV: true, offset: 'center', turnPair: 'golden-iso-gate-turn' },
],
};
/** Exact existing baseline impact measured against origin/dev with the same
* Chromium build. Keep this list reviewable: shared visual fixtures make an
* opening-only change visible in editor/dialog captures too. */
export const OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT = Object.freeze([
'isometric-geometry-view-dark',
'isometric-geometry-view-light',
'isometric-live-layers-dark',
'isometric-no-borders-dark',
'isometric-touch-kiosk-dark',
'isometric-large-warm-remount-dark',
'geometry-view-dark-fit',
'geometry-view-light-fit',
'room-label-parity-view-dark',
'room-label-parity-plan-dark',
'room-label-parity-view-light',
'room-label-parity-plan-light',
'day-cycle-dawn-dark',
'day-cycle-day-dark',
'day-cycle-dusk-dark',
'day-cycle-night-dark',
'geometry-plan-editor-dark',
'space-tab-drop-before-light',
'space-tab-drop-after-dark',
'plan-snap-endpoint-light',
'plan-snap-line-gaps-dark',
'junction-patch-resilience-plan-dark',
'opening-placement-door-thick-wall-dark',
'opening-placement-passage-thick-wall-dark',
'opening-placement-passage-thick-wall-light',
'geometry-devices-editor-dark',
'geometry-decor-editor-dark',
'tray-wide-selection-en',
'tray-wide-tool-ru',
'tray-medium-group-en',
'tray-medium-selection-ru',
'tray-narrow-palette-en',
'tray-narrow-tool-ru',
'geometry-diagonal-45-opening-dark',
'openings-thick-wall-dark',
'lighting-glow-sun-dark',
'device-value-badge-positions-dark',
'device-icon-state-table-light',
'device-icon-state-table-dark',
'device-text-shell-long-light',
'device-text-shell-long-dark',
'lighting-sun-window-state-only-dark',
'lighting-fill-light-axis-split-dark',
'lighting-fill-temp-axis-split-dark',
'lighting-fill-lqi-axis-split-dark',
'lighting-temp-glow-dark',
'lighting-temp-glow-light',
'lighting-custom-glow-dark',
'lighting-opaque-glow-two-doorways-dark',
'lighting-custom-glow-light',
'lighting-temp-glow-no-sources-dark',
'lighting-temp-glow-room-override-dark',
'lighting-manual-auto-spill-overlap-dark',
'hover-over-glow-dark',
'hover-nested-room-dark',
'large-house-zoom-040-dark',
'large-house-zoom-250-dark',
'large-house-warm-remount-dark',
'device-dialog-desktop-en',
'device-help-popover-light-ru',
'decor-color-popover-desktop-en',
'general-color-popover-desktop-en',
'space-room-color-popover-desktop-ru',
'backup-full-preview-desktop-en',
'backup-plan-only-export-desktop-en',
'optimize-preflight-dialog-dark-en',
'optimize-preflight-dialog-light-ru',
]);
const decorLayerFixture = [
{ id: 'golden-decor-axis', kind: 'line', x1: 0.10, y1: 0.54, x2: 0.90, y2: 0.54,
color: '#ff0033', opacity: 1, width_cm: 8 },
@@ -155,6 +285,19 @@ export const GOLDEN_SCENARIOS = Object.freeze([
mode: 'plan', openingPreview: { type: 'passage', pointer: [0.48, 0.65] },
openingPreviewPixels: { minPixels: 150, minInsideWallPixels: 8, minChannelDelta: 4 },
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'opening-symbol-room-wall-light', fixture: 'visual', space: 'golden-opening-symbols',
mode: 'view', openingSymbolContract: openingRoomContract,
theme: 'light', 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 },
{ id: 'opening-symbol-flip-pairs-light', fixture: 'visual', space: 'golden-opening-symbols',
mode: 'view', openingSymbolContract: openingFlipContract,
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-opening-symbol-parity-dark', fixture: 'visual', space: 'golden-opening-symbols',
mode: 'view', labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
openingSymbolContract: openingIsoContract,
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
+118
View File
@@ -483,6 +483,119 @@ async function inspectTunnelContinuity(page, png, clip, spec) {
}, { png64: png.toString('base64'), clip, spec });
}
/** Prove the opening-centre contract from rendered geometry before a PNG is
* compared. The screenshot remains useful for human review, but a shifted
* symbol, shallow jamb or cancelled gate turn must fail semantically even
* when no reviewed baseline exists yet. */
async function assertOpeningSymbolContract(page, contract) {
return page.evaluate((expected) => {
const card = window.__goldenCard;
const root = card?.renderRoot;
const space = card?._serverCfg?.spaces?.find((item) => item.id === card._space);
if (!card || !root || !space) throw new Error('semantic golden opening card is missing');
const epsilon = 1e-5;
const parsePair = (value, name) => {
const match = String(value || '').match(new RegExp(`${name}\\(([-+0-9.eE]+)[ ,]+([-+0-9.eE]+)\\)`));
if (!match) throw new Error(`semantic golden cannot parse ${name}: ${value}`);
return [Number(match[1]), Number(match[2])];
};
const parseTurn = (leaf) => {
const value = leaf?.style?.transform || leaf?.getAttribute?.('style') || '';
const match = String(value).match(/rotate\(([-+0-9.eE]+)deg\)/);
if (!match) throw new Error(`semantic golden cannot parse gate turn: ${value}`);
return Number(match[1]);
};
const openingCfg = new Map((space.openings || []).map((opening) => [opening.id, opening]));
const turns = new Map();
const rows = [];
if (expected.surface === 'flat') {
const renderedById = new Map((card._openingsR || []).map((opening) => [opening.id, opening]));
const fullDepth = (expected.wallCm / card._cellCm) * card._gridPitch;
for (const item of expected.openings) {
const cfg = openingCfg.get(item.id);
const rendered = renderedById.get(item.id);
const group = root.querySelector(`.opening[data-id="${CSS.escape(item.id)}"]`);
if (!cfg || !rendered || !group || cfg.type !== item.type
|| !!cfg.flip_v !== item.flipV) {
throw new Error(`semantic golden opening config/render mismatch: ${item.id}`);
}
if (Math.hypot(rendered.rx - cfg.x * 1000, rendered.ry - cfg.y * 1000) > epsilon)
throw new Error(`semantic golden opening left its wall centerline: ${item.id}`);
const scale = group.querySelector(':scope > g[transform^="scale("]');
const body = scale?.querySelector(':scope > g[transform^="translate("]');
if (!scale || !body) throw new Error(`semantic golden visible group is missing: ${item.id}`);
const translation = parsePair(body.getAttribute('transform'), 'translate');
const offset = Math.hypot(...translation);
const expectedOffset = item.offset === 'center' ? 0 : fullDepth / 2;
if (Math.abs(offset - expectedOffset) > epsilon) {
throw new Error(`semantic golden visible-group offset failed for ${item.id}: `
+ `${offset} != ${expectedOffset}`);
}
const jambs = [...scale.querySelectorAll(':scope > line')];
if (jambs.length !== 2 || jambs.some((line) => {
const depth = Math.abs(Number(line.getAttribute('y2')) - Number(line.getAttribute('y1')));
return Math.abs(depth - fullDepth) > epsilon;
})) {
throw new Error(`semantic golden full-depth jamb failed: ${item.id}`);
}
if (item.type === 'window' && !body.querySelector('.op-glass'))
throw new Error(`semantic golden window group lost its glass: ${item.id}`);
let turn = null;
if (item.type === 'gate') {
const scalePair = parsePair(scale.getAttribute('transform'), 'scale');
if (Math.abs(scalePair[1] - 1) > epsilon)
throw new Error(`semantic golden gate regained scaleY flip: ${item.id}`);
turn = parseTurn(group.querySelector('.op-leaf'));
if (Math.abs(Math.abs(turn) - 10) > epsilon)
throw new Error(`semantic golden gate turn is not 10 degrees: ${item.id}`);
if (item.turnPair) turns.set(item.id, { pair: item.turnPair, flipV: item.flipV, turn });
}
rows.push({ id: item.id, offset, jambDepth: fullDepth, turn });
}
} else {
const bases = card._isoSource?.()?.build?.().openings || [];
for (const item of expected.openings) {
const cfg = openingCfg.get(item.id);
const basis = bases.find((opening) => opening.id === item.id);
if (!cfg || !basis || cfg.type !== item.type || !!cfg.flip_v !== item.flipV
|| !basis.leaves.length) {
throw new Error(`semantic golden Iso opening mismatch: ${item.id}`);
}
const centre = basis.leaves.length === 1
? [
basis.leaves[0].hinge[0] + basis.leaves[0].closedVector[0] / 2,
basis.leaves[0].hinge[1] + basis.leaves[0].closedVector[1] / 2,
]
: [
basis.leaves.reduce((sum, leaf) => sum + leaf.hinge[0], 0) / basis.leaves.length,
basis.leaves.reduce((sum, leaf) => sum + leaf.hinge[1], 0) / basis.leaves.length,
];
const offset = Math.hypot(centre[0] - cfg.x * 1000, centre[1] - cfg.y * 1000);
if (item.offset !== 'center' || offset > epsilon)
throw new Error(`semantic golden Iso centre failed for ${item.id}: ${offset}`);
const turn = item.type === 'gate' ? basis.leaves[0].turnDeg : null;
if (turn != null && Math.abs(Math.abs(turn) - 10) > epsilon)
throw new Error(`semantic golden Iso gate turn failed: ${item.id}`);
if (item.turnPair) turns.set(item.id, { pair: item.turnPair, flipV: item.flipV, turn });
rows.push({ id: item.id, offset, turn });
}
if (root.querySelectorAll('.iso-opening-panel').length < expected.openings.length)
throw new Error('semantic golden Iso opening panels are missing');
}
for (const pairName of new Set([...turns.values()].map((item) => item.pair))) {
const pair = [...turns.values()].filter((item) => item.pair === pairName)
.sort((a, b) => Number(a.flipV) - Number(b.flipV));
if (pair.length !== 2 || pair[0].flipV || !pair[1].flipV
|| Math.abs(pair[0].turn + pair[1].turn) > epsilon) {
throw new Error(`semantic golden gate flip does not reverse turn: ${pairName}`);
}
}
return { surface: expected.surface, rows };
}, contract);
}
let baselineManifest = null;
const baselineManifestPath = resolve(baselineRoot, GOLDEN_BASELINE_MANIFEST);
if (existsSync(baselineManifestPath)) {
@@ -516,6 +629,11 @@ try {
pageErrors.length = 0;
result.runtime = await prepareGoldenScenario(page, scenario);
if (pageErrors.length) throw new Error(`browser exception: ${pageErrors.join(' | ')}`);
if (scenario.openingSymbolContract) {
result.openingSymbolContract = await assertOpeningSymbolContract(
page, scenario.openingSymbolContract,
);
}
if (scenario.openingGeometry) {
result.openingGeometry = await page.evaluate((expected) => {
const card = window.__goldenCard;
+11 -3
View File
@@ -35,8 +35,10 @@ const out = await page.evaluate(async () => {
host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.25 } },
{ id: 'iso-flipped-window', type: 'window', x: 0.5, y: 0.12, angle: 0, length: 0.08,
flip_v: true, host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.5 } },
{ id: 'iso-centred-gate', type: 'gate', x: 0.675, y: 0.12, angle: 0, length: 0.08,
flip_v: true, host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.75 } },
{ id: 'iso-centred-gate', type: 'gate', x: 0.605, y: 0.12, angle: 0, length: 0.08,
flip_v: false, host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.65 } },
{ id: 'iso-flipped-gate', type: 'gate', x: 0.745, y: 0.12, angle: 0, length: 0.08,
flip_v: true, host: { kind: 'partition', id: 'iso-smoke-wall', t: 0.85 } },
];
card._cfgEpoch++;
card.requestUpdate();
@@ -47,6 +49,7 @@ const out = await page.evaluate(async () => {
const centredDoor = basis('iso-centred-door');
const flippedWindow = basis('iso-flipped-window');
const centredGate = basis('iso-centred-gate');
const flippedGate = basis('iso-flipped-gate');
const wallAxisY = 0.12 * 1000;
const halfDepth = (15 / card._cellCm) * card._gridPitch / 2;
result.isoOpeningDefaultCentred = centredDoor?.leaves.length === 1
@@ -57,7 +60,12 @@ const out = await page.evaluate(async () => {
);
result.isoGateFlipKeepsCentredOrigin = centredGate?.leaves.length === 2
&& centredGate.leaves.every((leaf) => Math.abs(leaf.hinge[1] - wallAxisY) < 1e-6)
&& centredGate.leaves.every((leaf) => Math.abs(leaf.turnDeg) === 10);
&& flippedGate?.leaves.length === 2
&& flippedGate.leaves.every((leaf) => Math.abs(leaf.hinge[1] - wallAxisY) < 1e-6)
&& centredGate.leaves.every((leaf) => Math.abs(leaf.turnDeg) === 10)
&& flippedGate.leaves.every((leaf) => Math.abs(leaf.turnDeg) === 10);
result.isoGateFlipReversesTurn = centredGate?.leaves[0]?.turnDeg
=== -flippedGate?.leaves[0]?.turnDeg;
const toggle = root().querySelector('[data-hp="projection-toggle"]');
result.labsSnapshotFrozen = Object.isFrozen(window.__hpLabs)
&& JSON.stringify(window.__hpLabs) === '["iso"]';
+39
View File
@@ -122,6 +122,45 @@ const res = await page.evaluate(async () => {
out.doorSavedFlipUsesEdge = /^translate\([^)]*\)$/.test(doorBodyTransform())
&& !/^translate\(\s*0\s+0\s*\)$/.test(doorBodyTransform());
const firstGateTurn = (id) => {
const transform = sr().querySelector(
`[data-hp="opening"][data-id="${id}"] .op-leaf`,
)?.style?.transform || '';
return Number(transform.match(/rotate\(([-+0-9.eE]+)deg\)/)?.[1]);
};
sp().openings = [
{ id: 'wtGateDefault', type: 'gate', x: 0.55, y: 0.20, angle: 90, length: 0.08,
flip_v: false },
{ id: 'wtGateFlipped', type: 'gate', x: 0.55, y: 0.30, angle: 90, length: 0.08,
flip_v: true },
];
await upd();
const sharedGateDefault = firstGateTurn('wtGateDefault');
const sharedGateFlipped = firstGateTurn('wtGateFlipped');
out.sharedGateFlipReversesTurn = Math.abs(sharedGateDefault) === 10
&& sharedGateFlipped === -sharedGateDefault;
const partitionsBefore = structuredClone(sp().partitions || []);
sp().partitions = [...partitionsBefore, {
id: 'wt-gate-partition', a: [0.15, 0.75], b: [0.45, 0.75], cm: 25,
}];
sp().openings = [
{ id: 'wtPartGateDefault', type: 'gate', x: 0.25, y: 0.75, angle: 0, length: 0.08,
flip_v: false, host: { kind: 'partition', id: 'wt-gate-partition', t: 1 / 3 } },
{ id: 'wtPartGateFlipped', type: 'gate', x: 0.35, y: 0.75, angle: 0, length: 0.08,
flip_v: true, host: { kind: 'partition', id: 'wt-gate-partition', t: 2 / 3 } },
];
await upd();
const partitionGateDefault = firstGateTurn('wtPartGateDefault');
const partitionGateFlipped = firstGateTurn('wtPartGateFlipped');
out.partitionGateFlipReversesTurn = Math.abs(partitionGateDefault) === 10
&& partitionGateFlipped === -partitionGateDefault;
sp().partitions = partitionsBefore;
sp().openings = [{
id: 'wt1', type: 'door', x: 0.55, y: 0.25, angle: 90, length: 0.09, flip_v: true,
}];
await upd();
sp().settings = { ...(sp().settings || {}), hide_openings: true, show_borders: true };
c._setMode('view');
await upd();
File diff suppressed because one or more lines are too long
+12 -12
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -5,8 +5,9 @@
- Door, window and gate symbols now sit on the centreline of thick room and
independent walls by default, consistently in placement preview, Flat,
Static and hidden Iso. Existing flipped doors/windows keep their explicit
edge alignment; gates remain centred while the flip changes only their 10°
opening direction, and jambs still span the full wall depth
edge alignment; gates remain centred while the flip reverses their 10°
opening direction on shared room and independent walls without a second
mirror inversion, and jambs still span the full wall depth
([#242](https://github.com/Matysh/houseplan-card/issues/242)).
- Space tabs can once again be reordered with a real mouse drag in an editor.
+3 -2
View File
@@ -12,8 +12,9 @@
оси толщины стен комнат и независимых стен — одинаково в предпросмотре,
плоском виде, Static и скрытой изометрии. Существующие перевёрнутые
двери/окна сохраняют явное выравнивание к грани; ворота остаются по центру,
а флаг меняет только направление их 10° открытия. Косяки по-прежнему
проходят через полную глубину стены
а флаг без повторной зеркальной инверсии меняет направление их 10° открытия
на общей стене комнат и независимой стене. Косяки по-прежнему проходят через
полную глубину стены
([#242](https://github.com/Matysh/houseplan-card/issues/242)).
- Вкладки пространств снова можно переставлять реальным перетаскиванием мышью
+67
View File
@@ -55,6 +55,73 @@
- [ ] `hide_decor`, the Background editor override and stored config remain
unchanged; no per-object under-plan compatibility flag is introduced.
## Opening symbol centreline (#242)
- [ ] Unit and browser checks prove that door/window/gate defaults stay on the
wall centreline, saved door/window `flip_v` uses the canonical edge, and
gate `flip_v` reverses the first-leaf 10° turn on shared room walls,
independent partitions and hidden Iso without translating the gate
[unit: `opening-symbol.test.mjs`, `iso-openings.test.mjs`; auto:
`smoke_wall_thickness.mjs`, `smoke_isometric_contract.mjs`; mutation:
`opening-gate-flip-cancels-turn`].
- [ ] Matrix v37 adds four dedicated semantic scenes. Before PNG comparison
they assert the saved flip value, wall centreline, visible-group offset,
full jamb depth, window glass membership and opposite gate turn signs:
`opening-symbol-room-wall-light`,
`opening-symbol-diagonal-partition-dark`,
`opening-symbol-flip-pairs-light`,
`isometric-opening-symbol-parity-dark`.
- [ ] The exact existing golden impact set below contains **67** scenes. It was
measured by comparing `actualSha256` for HEAD and `origin/dev` under the
same Chromium build; baseline status alone is not used because `dev`
already has unrelated pending pre-release candidates. Every listed frame
uses a shared fixture containing an affected opening or retains that plan
behind an editor/dialog. No other existing frame changed:
`isometric-geometry-view-dark`, `isometric-geometry-view-light`,
`isometric-live-layers-dark`, `isometric-no-borders-dark`,
`isometric-touch-kiosk-dark`, `isometric-large-warm-remount-dark`,
`geometry-view-dark-fit`, `geometry-view-light-fit`,
`room-label-parity-view-dark`, `room-label-parity-plan-dark`,
`room-label-parity-view-light`, `room-label-parity-plan-light`,
`day-cycle-dawn-dark`, `day-cycle-day-dark`, `day-cycle-dusk-dark`,
`day-cycle-night-dark`, `geometry-plan-editor-dark`,
`space-tab-drop-before-light`, `space-tab-drop-after-dark`,
`plan-snap-endpoint-light`, `plan-snap-line-gaps-dark`,
`junction-patch-resilience-plan-dark`,
`opening-placement-door-thick-wall-dark`,
`opening-placement-passage-thick-wall-dark`,
`opening-placement-passage-thick-wall-light`,
`geometry-devices-editor-dark`, `geometry-decor-editor-dark`,
`tray-wide-selection-en`, `tray-wide-tool-ru`,
`tray-medium-group-en`, `tray-medium-selection-ru`,
`tray-narrow-palette-en`, `tray-narrow-tool-ru`,
`geometry-diagonal-45-opening-dark`, `openings-thick-wall-dark`,
`lighting-glow-sun-dark`, `device-value-badge-positions-dark`,
`device-icon-state-table-light`, `device-icon-state-table-dark`,
`device-text-shell-long-light`, `device-text-shell-long-dark`,
`lighting-sun-window-state-only-dark`,
`lighting-fill-light-axis-split-dark`,
`lighting-fill-temp-axis-split-dark`,
`lighting-fill-lqi-axis-split-dark`, `lighting-temp-glow-dark`,
`lighting-temp-glow-light`, `lighting-custom-glow-dark`,
`lighting-opaque-glow-two-doorways-dark`,
`lighting-custom-glow-light`, `lighting-temp-glow-no-sources-dark`,
`lighting-temp-glow-room-override-dark`,
`lighting-manual-auto-spill-overlap-dark`, `hover-over-glow-dark`,
`hover-nested-room-dark`, `large-house-zoom-040-dark`,
`large-house-zoom-250-dark`, `large-house-warm-remount-dark`,
`device-dialog-desktop-en`, `device-help-popover-light-ru`,
`decor-color-popover-desktop-en`, `general-color-popover-desktop-en`,
`space-room-color-popover-desktop-ru`,
`backup-full-preview-desktop-en`,
`backup-plan-only-export-desktop-en`,
`optimize-preflight-dialog-dark-en`,
`optimize-preflight-dialog-light-ru`.
- [ ] Baselines for the 67 existing and four dedicated scenes are accepted
only from the reviewed full Linux pre-beta artifact. Local
`golden:accept` remains forbidden.
## Device icon design package (#179)
- [ ] Pure presentation tests cover lock/unlock, exact marker-only LQI bands
+13
View File
@@ -1598,6 +1598,19 @@ export const MUTANTS = [
replace: " if (!flipV || type === 'passage') return { ox: 0, oy: 0 };",
}],
},
{
id: 'opening-gate-flip-cancels-turn',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="shared renderer centres defaults" '
+ 'test/opening-symbol.test.mjs',
because: 'restoring the second flip_v inversion makes shared and partition gates emit '
+ 'the same first-leaf turn sign for both saved values even though their origin stays centred',
patches: [{
file: 'src/render/opening-symbol.ts',
find: ' const gateAngle = spec.face.side * 10 * amount;',
replace: ' const gateAngle = spec.face.side * sy * 10 * amount;',
}],
},
];
// --- механика ---------------------------------------------------------------
+5 -3
View File
@@ -80,7 +80,10 @@ function leafBasis(
top: number,
): IsoOpeningLeafBasis {
const sx = input.flipH ? -1 : 1;
const sy = input.flipV ? -1 : 1;
// Gate flip_v is represented by its resolved face.side/turnDeg. Mirroring
// the structural basis as well would cancel that direction on shared and
// partition hosts, just like a nested scaleY in the flat renderer.
const sy = input.type === 'gate' ? 1 : input.flipV ? -1 : 1;
const offset = openingSymbolOffset(input.type, input.flipV, input.angle, input.face);
const origin: PlanPoint = [input.x + offset.ox, input.y + offset.oy];
const hinge = add(origin, transformVector(localHinge, input.angle, sx, sy));
@@ -111,8 +114,7 @@ export function buildIsoOpeningBasis(
if (input.type === 'passage') {
leaves = [];
} else if (input.type === 'gate') {
const sy = input.flipV ? -1 : 1;
const turn = input.face.side * sy * 10;
const turn = input.face.side * 10;
leaves = [
leafBasis(input, 0, [-half, 0], [half, 0], turn, 0, wallHeight * 0.88),
leafBasis(input, 1, [half, 0], [-half, 0], -turn, 0, wallHeight * 0.88),
+10 -4
View File
@@ -64,6 +64,11 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
const leafHalf = 1.75 * visualScale;
const sx = spec.flipH ? -1 : 1;
const sy = spec.flipV ? -1 : 1;
// A gate is already symmetric around the wall axis. Its vertical flip is a
// direction command, not a mirror transform: face.side carries the resolved
// turn side and must remain observable instead of being cancelled by a
// second scaleY(-1).
const renderSy = spec.type === 'gate' ? 1 : sy;
// Default symbols sit on the wall centreline. A saved door/window flip keeps
// the explicit edge-aligned compatibility mode, but gate leaves always stay
@@ -107,9 +112,10 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
${glass}
</g>`;
} else if (spec.type === 'gate') {
// Gate leaves open only 10° towards the resolved exterior face. Conjugating
// their rotation through scaleY(-1) reverses the sign.
const gateAngle = spec.face.side * sy * 10 * amount;
// Gate leaves open only 10° towards the resolved face. Gate flip_v changes
// face.side at the resolver; there is deliberately no second inversion
// here, so shared and partition hosts expose opposite turn signs too.
const gateAngle = spec.face.side * 10 * amount;
body = svg`
<g transform="translate(${swingTx} ${swingTy})">
<g transform="translate(${-half} 0)">
@@ -137,7 +143,7 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
</g>`;
}
return svg`<g transform="scale(${sx} ${sy})">
return svg`<g transform="scale(${sx} ${renderSy})">
<line x1="${-half}" y1="${-jambHalf}" x2="${-half}" y2="${jambHalf}"
stroke="${spec.base}" stroke-width="${2.5 * visualScale}"></line>
<line x1="${half}" y1="${-jambHalf}" x2="${half}" y2="${jambHalf}"
+66 -2
View File
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { prepareGoldenFixture } from '../demo/golden/harness.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
import {
GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS, OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT,
} from '../demo/golden/matrix.mjs';
import { fixtureWallKey } from '../demo/fixtures/visual-matrix.mjs';
test('golden matrix has stable unique ids and bounded comparison thresholds', () => {
@@ -54,6 +56,24 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
assert.equal(['door', 'window', 'gate'].includes(scenario.openingGeometry.type), true, scenario.id);
assert.equal(Number.isFinite(scenario.openingGeometry.angle), true, scenario.id);
}
if (scenario.openingSymbolContract) {
const contract = scenario.openingSymbolContract;
assert.equal(['room', 'partition'].includes(contract.kind), true, scenario.id);
assert.equal(['flat', 'iso'].includes(contract.surface), true, scenario.id);
assert.equal(contract.wallCm > 0, true, scenario.id);
assert.equal(contract.openings.length >= 3, true, scenario.id);
assert.equal(new Set(contract.openings.map((opening) => opening.id)).size,
contract.openings.length, scenario.id);
for (const opening of contract.openings) {
assert.match(opening.id, /^[a-z0-9-]+$/, scenario.id);
assert.equal(['door', 'window', 'gate'].includes(opening.type), true, scenario.id);
assert.equal(['center', 'edge'].includes(opening.offset), true, scenario.id);
assert.equal(typeof opening.flipV, 'boolean', scenario.id);
assert.equal(Number.isFinite(opening.at) && opening.length > 0, true, scenario.id);
if (opening.type === 'gate') assert.equal(opening.offset, 'center', scenario.id);
}
assert.equal(contract.surface === 'iso', scenario.projection === 'iso', scenario.id);
}
if (scenario.tunnelContinuity) {
assert.match(scenario.tunnelContinuity.openingId, /^[a-z0-9-]+$/, scenario.id);
assert.equal(Number.isInteger(scenario.tunnelContinuity.insetPx)
@@ -301,7 +321,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, 36);
assert.equal(GOLDEN_MATRIX_VERSION, 37);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -463,6 +483,50 @@ test('diagonal opening golden asserts a real painted 45 degree symbol', () => {
assert.deepEqual(space.openings.map((opening) => opening.id), ['geo-diagonal-window']);
});
test('opening symbol goldens lock room, diagonal, flip-pair and hidden Iso contracts', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.openingSymbolContract);
assert.deepEqual(scenarios.map((item) => item.id), [
'opening-symbol-room-wall-light',
'opening-symbol-diagonal-partition-dark',
'opening-symbol-flip-pairs-light',
'isometric-opening-symbol-parity-dark',
]);
for (const scenario of scenarios) {
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space, scenario.id);
assert.equal(space.openings.length, scenario.openingSymbolContract.openings.length, scenario.id);
assert.equal(space.settings.show_borders, true, scenario.id);
assert.equal(space.openings.every((opening) => Number.isFinite(opening.x)
&& Number.isFinite(opening.y) && Number.isFinite(opening.angle)), true, scenario.id);
if (scenario.openingSymbolContract.kind === 'partition') {
assert.equal(space.partitions.length, 1, scenario.id);
assert.equal(space.openings.every((opening) => opening.host?.id === space.partitions[0].id),
true, scenario.id);
} else {
assert.equal(space.rooms.length, 2, scenario.id);
assert.equal(space.walls.some((wall) => wall.cm === scenario.openingSymbolContract.wallCm),
true, scenario.id);
}
}
const flipPair = scenarios.find((item) => item.id === 'opening-symbol-flip-pairs-light');
assert.deepEqual(flipPair.openingSymbolContract.openings.map((opening) => [
opening.type, opening.flipV, opening.offset,
]), [
['door', false, 'center'], ['door', true, 'edge'],
['window', false, 'center'], ['window', true, 'edge'],
['gate', false, 'center'], ['gate', true, 'center'],
]);
assert.equal(OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT.length, 67);
assert.equal(new Set(OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT).size, 67);
const scenarioIds = new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.id));
const testingDoc = readFileSync(new URL('../docs/TESTING.md', import.meta.url), 'utf8');
for (const id of OPENING_SYMBOL_EXISTING_GOLDEN_IMPACT) {
assert.equal(scenarioIds.has(id), true, id);
assert.match(testingDoc, new RegExp(`\\b${id}\\b`), id);
}
});
test('golden harness applies doorway, state and layout overrides to a cloned fixture', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'lighting-opaque-glow-two-doorways-dark');
const fixture = prepareGoldenFixture(scenario);
+2
View File
@@ -58,6 +58,8 @@ test('isometric symbols share centred defaults and explicit edge alignment', ()
}));
assert.equal(gate.leaves[0].hinge[1], 80);
assert.equal(gateFlipped.leaves[0].hinge[1], 80);
assert.deepEqual(gate.leaves.map((leaf) => leaf.turnDeg), [10, -10]);
assert.deepEqual(gateFlipped.leaves.map((leaf) => leaf.turnDeg), [-10, 10]);
assert.notDeepEqual(
projectIsoOpening(gate, 1).map((panel) => panel.d),
projectIsoOpening(gateFlipped, 1).map((panel) => panel.d),
+12
View File
@@ -98,6 +98,18 @@ test('shared renderer centres defaults and preserves explicit door/window edge a
})));
assert.match(gate, /translate\(0 0\)/);
assert.match(flippedGate, /translate\(0 0\)/);
assert.match(gate, /scale\(1 1\)/);
assert.match(flippedGate, /scale\(1 1\)/);
assert.match(gate, /rotate\(10deg\)/);
assert.match(gate, /rotate\(-10deg\)/);
assert.match(flippedGate, /rotate\(-10deg\)/);
assert.match(flippedGate, /rotate\(10deg\)/);
assert.equal(
gate.indexOf('rotate(10deg)') < gate.indexOf('rotate(-10deg)'), true,
'default gate turns its first leaf towards the resolved face',
);
assert.equal(
flippedGate.indexOf('rotate(-10deg)') < flippedGate.indexOf('rotate(10deg)'), true,
'flip_v reverses the first-leaf turn instead of cancelling in scaleY',
);
});