mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]';
|
||||
|
||||
@@ -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
Vendored
+12
-12
File diff suppressed because one or more lines are too long
+3
-2
@@ -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.
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
оси толщины стен комнат и независимых стен — одинаково в предпросмотре,
|
||||
плоском виде, Static и скрытой изометрии. Существующие перевёрнутые
|
||||
двери/окна сохраняют явное выравнивание к грани; ворота остаются по центру,
|
||||
а флаг меняет только направление их 10° открытия. Косяки по-прежнему
|
||||
проходят через полную глубину стены
|
||||
а флаг без повторной зеркальной инверсии меняет направление их 10° открытия
|
||||
на общей стене комнат и независимой стене. Косяки по-прежнему проходят через
|
||||
полную глубину стены
|
||||
([#242](https://github.com/Matysh/houseplan-card/issues/242)).
|
||||
|
||||
- Вкладки пространств снова можно переставлять реальным перетаскиванием мышью
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user