mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Publish corrected 0.4.1 pack, preserve old cactus objects as a read-only alias, and cover the catalog, renderers, editor, PDF and visual scene. Issue: #606 User-Visible: yes
835 lines
47 KiB
JavaScript
835 lines
47 KiB
JavaScript
// Библиотека мебели в декор-слое (docs/FURNITURE.md, задача владельца №2):
|
||
// 1) в панели декора есть инструмент «Мебель», он открывает ПАЛИТРУ с
|
||
// обязательными группами (мебель / техника / сантехника) и превью;
|
||
// 2) выбранный символ ставится кликом по плану в РЕАЛЬНОМ размере — через
|
||
// cell_cm пространства, и сразу оказывается выделенным в «выбрать»;
|
||
// 3) поля ширины/глубины в палитре меняют размер ДО размещения;
|
||
// 4) мышь показывает точный полупрозрачный будущий символ без записи в
|
||
// конфиг; touch/pen не рисуют hover и сохраняют только чистый tap;
|
||
// 5) магнит к стене: предмет прижимается к ближайшей стене и доворачивается
|
||
// по ней; Shift временно обходит магнит, сохраняя grid snap;
|
||
// 6) у выделенного предмета расширенная рамка: угловые и четыре одноосевые
|
||
// ручки; угол сохраняет пропорции без Shift, с Shift оси независимы;
|
||
// поворот свободный, а Shift привязывает его к 45°;
|
||
// 7) во время растягивания угла показываются живые плашки размеров;
|
||
// 8) фигура пишется в конфиг и переживает пересборку, хуки card-mod на месте.
|
||
// ПАДАЕТ на сборке до этой задачи: инструмента «Мебель» нет, палитры нет,
|
||
// kind:'furniture' не рисуется, магнита и плашек не существует. Смок при этом
|
||
// null-safe — он показывает СПИСОК провалов, а не одно исключение.
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
const { page, browser } = await launch();
|
||
const res = await page.evaluate(async () => {
|
||
const out = {};
|
||
const c = window.__card;
|
||
const sr = () => c.shadowRoot || c.renderRoot;
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;
|
||
const stageEl = () => sr().querySelector('.stage');
|
||
const el = (id) => (id ? sr().querySelector(`[data-hp-live-editor] .decorlayer [data-id="${id}"]`)
|
||
|| sr().querySelector(`.decorlayer [data-id="${id}"]`) : null);
|
||
const attr = (id, n) => { const e = el(id); return e ? e.getAttribute(n) : null; };
|
||
const toScreen = (x, y) => {
|
||
const r = stageEl().getBoundingClientRect();
|
||
const v = c._viewOr(c._baseVb());
|
||
return { clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||
clientY: r.top + ((y - v.y) / v.h) * r.height };
|
||
};
|
||
const ev = (type, target, x, y, extra = {}) => {
|
||
// null-safe: на сборке ДО этой задачи фигуры нет вовсе, а значит нет и
|
||
// её координат — жест просто не происходит, и смок печатает список
|
||
// провалов вместо одного исключения
|
||
if (!target || !Number.isFinite(x) || !Number.isFinite(y)) return;
|
||
const { clientX, clientY } = toScreen(x, y);
|
||
target.dispatchEvent(new PointerEvent(type, { bubbles: true, composed: true,
|
||
cancelable: true, pointerId: 11, clientX, clientY, button: 0, isPrimary: true, ...extra }));
|
||
};
|
||
const PITCH = 1000 / 240; // GRID_PITCH — одна клетка сетки
|
||
const CELL = 5; // cell_cm демо-пространства (по умолчанию)
|
||
const cmToUnits = (cm) => (cm / CELL) * PITCH;
|
||
const near = (a, b, tol) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= tol;
|
||
const pe = (id) => { const e = el(id); return e ? getComputedStyle(e).pointerEvents : null; };
|
||
const box = () => c._decorBoxOf(sofaNow());
|
||
|
||
const reducedMotion = c._reducedMotion;
|
||
c._reducedMotion = true;
|
||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||
await new Promise((resolve) => {
|
||
const observer = new ResizeObserver(() => {
|
||
observer.disconnect();
|
||
resolve();
|
||
});
|
||
observer.observe(stageEl());
|
||
});
|
||
while (c._modeTransitionBusy || c._refitRaf || c._pendingRefitSize) {
|
||
await new Promise(requestAnimationFrame);
|
||
}
|
||
c.requestUpdate();
|
||
await c.updateComplete;
|
||
c._reducedMotion = reducedMotion;
|
||
out.initialModeSettled = !c._modeTransitionBusy && !c._refitRaf && !c._pendingRefitSize;
|
||
c._curSpaceCfg.decor = [];
|
||
c._decorTool = 'select'; c._decorSel = null; c._furnPalette = null; c._furnCategory = null;
|
||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||
out.cellCmIsFive = c._cellCm === CELL;
|
||
|
||
// ================= 1. инструмент и палитра ==============================
|
||
const toolBtn = [...sr().querySelectorAll('.decorbar .dtool')]
|
||
.find((b) => /мебел|furnitur/i.test(b.textContent || ''));
|
||
out.furnitureToolInTheBar = !!toolBtn;
|
||
out.noPaletteBeforeTheTool = !sr().querySelector('.furnpalette');
|
||
if (toolBtn) toolBtn.click();
|
||
await c.updateComplete;
|
||
out.toolIsSelected = c._decorTool === 'furniture';
|
||
const pal = () => sr().querySelector('.furnpalette');
|
||
const openFurniture = async () => {
|
||
if (c._decorTool !== 'furniture') {
|
||
c._editorSecondary.openPalette();
|
||
c._decorTool = 'furniture';
|
||
c._furnPalette = null;
|
||
c._furnCategory = null;
|
||
c.requestUpdate();
|
||
await c.updateComplete;
|
||
}
|
||
};
|
||
out.paletteOpens = !!pal();
|
||
const groups = [...(pal()?.querySelectorAll('.furngroup') || [])]
|
||
.map((g) => g.getAttribute('data-group'));
|
||
out.groupsArePresent = ['furniture', 'appliance', 'sanitary'].every((g) => groups.includes(g));
|
||
out.everyGroupHasSymbols = groups.length > 0 && [...pal().querySelectorAll('.furnrow')]
|
||
.every((r) => r.querySelectorAll('.furnitem').length > 0);
|
||
const category = (id) => pal()?.querySelector(`.furnitem[data-category="${id}"]`);
|
||
out.firstLevelHasNoPlanVariants = !pal()?.querySelector('.furnitem[data-symbol]');
|
||
out.requiredCategoriesArePresent = ['sofa', 'toilet', 'washer']
|
||
.every((id) => !!category(id));
|
||
// #606: the former cactus drawing is an exercise machine, so this category
|
||
// now opens onto a real plan variant instead of being hidden.
|
||
out.exerciseCategoryIsVisible = !!category('exercise');
|
||
out.newCategoriesAreVisible = ['computer', 'hood', 'oven', 'exercise'].every((id) => !!category(id));
|
||
out.paletteShowsThirtyThreeCategories = pal()
|
||
? pal().querySelectorAll('.furnitem[data-category]').length === 33 : false;
|
||
category('exercise')?.click(); await c.updateComplete;
|
||
out.exerciseCategoryHasOneVariant = pal()?.querySelectorAll('.furnitem[data-symbol]').length === 1
|
||
&& !!pal()?.querySelector('.furnitem[data-symbol="exercise"]')
|
||
&& !pal()?.querySelector('.furnitem[data-symbol="cactus"]');
|
||
pal()?.querySelector('.furnback')?.click(); await c.updateComplete;
|
||
category('plant')?.click(); await c.updateComplete;
|
||
out.plantCategoryHasOnlyPlant = pal()?.querySelectorAll('.furnitem[data-symbol]').length === 1
|
||
&& !!pal()?.querySelector('.furnitem[data-symbol="plant"]');
|
||
pal()?.querySelector('.furnback')?.click(); await c.updateComplete;
|
||
const categoryD = category('sofa')?.querySelector('svg.furncatprev path')?.getAttribute('d') || '';
|
||
out.categoryUsesFrontArtwork = categoryD.length > 10;
|
||
category('sofa')?.click(); await c.updateComplete;
|
||
out.categoryDrillsToVariants = c._furnCategory === 'sofa'
|
||
&& !!pal()?.querySelector('.furnback');
|
||
const item = (sym) => pal()?.querySelector(`.furnitem[data-symbol="${sym}"]`);
|
||
const pick = (sym) => { const b = item(sym); if (b) b.click(); };
|
||
out.sofaIsInThePalette = !!item('sofa');
|
||
out.sofaVariantsAreInThePalette = ['sofa', 'sofa_three_seat', 'sofa_corner_right']
|
||
.every((id) => !!item(id));
|
||
const prevD = item('sofa')?.querySelector('svg.furnprev path')?.getAttribute('d') || '';
|
||
out.previewIsDrawn = prevD.length > 10;
|
||
const previewEl = item('sofa')?.querySelector('svg.furnprev');
|
||
const previewRect = previewEl?.getBoundingClientRect();
|
||
const itemRect = item('sofa')?.getBoundingClientRect();
|
||
out.previewStaysInsideItsButton = !!previewRect && !!itemRect
|
||
&& getComputedStyle(previewEl).position !== 'absolute'
|
||
&& previewRect.width <= 42 && previewRect.height <= 42
|
||
&& previewRect.left >= itemRect.left - 1 && previewRect.right <= itemRect.right + 1
|
||
&& previewRect.top >= itemRect.top - 1 && previewRect.bottom <= itemRect.bottom + 1;
|
||
out.namesAreLocalised = /\S/.test(item('sofa')?.querySelector('span')?.textContent || '');
|
||
|
||
// An unarmed explicit palette closes on the first press anywhere outside
|
||
// the card, consumes only that press/click pair, and returns to Select.
|
||
const outside = document.createElement('button');
|
||
const unrelated = document.createElement('button');
|
||
let outsideClicks = 0, unrelatedClicks = 0;
|
||
outside.addEventListener('click', () => outsideClicks++);
|
||
unrelated.addEventListener('click', () => unrelatedClicks++);
|
||
document.body.append(outside, unrelated);
|
||
outside.dispatchEvent(new PointerEvent('pointerdown', {
|
||
bubbles: true, composed: true, cancelable: true, pointerId: 71, pointerType: 'mouse',
|
||
}));
|
||
outside.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true }));
|
||
unrelated.click();
|
||
await c.updateComplete;
|
||
out.paletteOutsideDismissIsConsumed = c._decorTool === 'select' && !pal() && outsideClicks === 0;
|
||
out.paletteDismissTailLeavesUnrelatedClick = unrelatedClicks === 1;
|
||
outside.remove(); unrelated.remove();
|
||
[...sr().querySelectorAll('.decorbar .dtool')]
|
||
.find((b) => /мебел|furnitur/i.test(b.textContent || ''))?.click();
|
||
await c.updateComplete;
|
||
out.paletteReopensAfterDismiss = c._decorTool === 'furniture' && !!pal();
|
||
category('sofa')?.click(); await c.updateComplete;
|
||
|
||
// ================= 2. размер по умолчанию и поля =========================
|
||
pick('sofa'); await c.updateComplete;
|
||
out.pickArmsTheSymbol = c._furnPalette?.symbol === 'sofa';
|
||
out.defaultSizeIsTheSymbols = c._furnPalette?.w === 180 && c._furnPalette?.h === 90;
|
||
const wIn = () => pal()?.querySelector('input.furnw');
|
||
const hIn = () => pal()?.querySelector('input.furnh');
|
||
out.sizeFieldsAppear = !!wIn() && !!hIn();
|
||
out.fieldsShowMetres = +(wIn()?.value ?? NaN) === 1.8 && +(hIn()?.value ?? NaN) === 0.9;
|
||
out.selectedItemIsMarked = !!item('sofa')?.classList.contains('on');
|
||
|
||
// ================= 3. точное превью без мутации ==========================
|
||
const ghost = () => sr().querySelector('[data-hp-live-editor] .furniture-placement-preview')
|
||
|| sr().querySelector('.furniture-placement-preview');
|
||
const beforePreview = {
|
||
decor: c._decorList.length,
|
||
epoch: c._cfgEpoch,
|
||
undo: c._geometryHistory.undoName,
|
||
redo: c._geometryHistory.redoName,
|
||
};
|
||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||
await settleLive();
|
||
const firstPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||
const firstTransform = ghost()?.getAttribute('transform');
|
||
out.mouseHoverShowsRealSymbol = ghost()?.getAttribute('data-symbol') === 'sofa'
|
||
&& (ghost()?.getAttribute('d') || '').length > 20;
|
||
out.previewIsTransientAndInert = ghost()?.getAttribute('aria-hidden') === 'true'
|
||
&& getComputedStyle(ghost()).pointerEvents === 'none'
|
||
&& near(Number(getComputedStyle(ghost()).opacity), 0.55, 1e-6);
|
||
out.previewDoesNotMutateConfigOrHistory = c._decorList.length === beforePreview.decor
|
||
&& c._cfgEpoch === beforePreview.epoch
|
||
&& c._geometryHistory.undoName === beforePreview.undo
|
||
&& c._geometryHistory.redoName === beforePreview.redo;
|
||
if (wIn()) {
|
||
wIn().value = '1.85';
|
||
wIn().dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
await c.updateComplete;
|
||
out.sizeUpdatesPreviewWithoutPointerMove = c._editorRuntime?._furniturePreviewPlacement()?.w > firstPreview?.w
|
||
&& ghost()?.getAttribute('transform') !== firstTransform;
|
||
if (wIn()) {
|
||
wIn().value = '1.8';
|
||
wIn().dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
await c.updateComplete;
|
||
stageEl().dispatchEvent(new PointerEvent('pointerleave', {
|
||
bubbles: true, composed: true, pointerId: 11, pointerType: 'mouse',
|
||
}));
|
||
await settleLive();
|
||
out.pointerLeaveClearsPreview = !ghost() && c._furnPreviewInput === null;
|
||
|
||
// Unknown/stale symbols fail dark: neither an invisible ghost nor a decor
|
||
// record is allowed. Restoring a valid selection resumes the same session.
|
||
c._furnPalette = { symbol: 'future_unknown_symbol', w: 180, h: 90 };
|
||
await c.updateComplete;
|
||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||
await settleLive();
|
||
const beforeUnknown = c._decorList.length;
|
||
ev('pointerdown', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||
await c.updateComplete;
|
||
out.unknownSymbolFailsDark = !ghost() && c._decorList.length === beforeUnknown
|
||
&& c._decorTool === 'furniture';
|
||
c._furnPalette = { symbol: 'sofa', w: 180, h: 90 };
|
||
await c.updateComplete;
|
||
|
||
// ================= 4. размещение в реальном размере ======================
|
||
// середина комнаты r1 (40..550 × 140..580) — до стен дальше порога магнита
|
||
ev('pointermove', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||
await settleLive();
|
||
const committedPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||
const committedPreviewStroke = Number(ghost()?.getAttribute('stroke-width'));
|
||
ev('pointerdown', stageEl(), 300, 300, { pointerType: 'mouse' });
|
||
await c.updateComplete;
|
||
const sofa = c._decorList.find((s) => s.kind === 'furniture');
|
||
const sofaId = sofa?.id ?? null;
|
||
const sofaNow = () => c._decorList.find((s) => s.id === sofaId) || {};
|
||
out.placedOnClick = sofa?.symbol === 'sofa';
|
||
out.realWidthThroughCellCm = near((sofa?.w ?? NaN) * 1000, cmToUnits(180), 1e-6);
|
||
out.realDepthThroughCellCm = near((sofa?.h ?? NaN) * 1000, cmToUnits(90), 1e-6);
|
||
out.centredOnTheClick = !!sofa
|
||
&& near((sofa.x + sofa.w / 2) * 1000, 300, 1e-6)
|
||
&& near((sofa.y + sofa.h / 2) * 1000, 300, 1e-6);
|
||
out.noAngleWhenStraight = !!sofa && sofa.angle === undefined;
|
||
out.previewAndCommitAreIdentical = !!sofa && !!committedPreview
|
||
&& ['x', 'y', 'w', 'h'].every((k) => near(sofa[k], committedPreview[k], 1e-12))
|
||
&& (sofa.angle || 0) === committedPreview.angle;
|
||
out.previewAndCommitShareThePhysicalStroke = Number.isFinite(committedPreviewStroke)
|
||
&& near(Number(attr(sofaId, 'stroke-width')), committedPreviewStroke, 1e-9);
|
||
out.previewClearsAfterCommit = !ghost() && c._furnPreviewInput === null;
|
||
out.selectedRightAway = !!sofaId && c._decorSel === sofaId;
|
||
out.toolWentBackToSelect = c._decorTool === 'select';
|
||
out.paletteDisarmed = c._furnPalette === null;
|
||
// …и она НАРИСОВАНА, со всеми хуками card-mod (docs/STYLING-HOOKS.md §3)
|
||
out.rendered = el(sofaId)?.tagName?.toLowerCase() === 'path';
|
||
out.hookHp = attr(sofaId, 'data-hp') === 'decor';
|
||
out.hookKind = attr(sofaId, 'data-kind') === 'furniture';
|
||
out.hookSymbol = attr(sofaId, 'data-symbol') === 'sofa';
|
||
out.strokedNotFilled = attr(sofaId, 'fill') === 'none'
|
||
&& attr(sofaId, 'stroke') === c._decorStyle.color;
|
||
out.pathIsAtRealSize = (attr(sofaId, 'd') || '').length > 20
|
||
&& /scale\(/.test(attr(sofaId, 'transform') || '')
|
||
&& attr(sofaId, 'vector-effect') === 'non-scaling-stroke';
|
||
|
||
// ================= 5. свой размер до размещения ==========================
|
||
await openFurniture();
|
||
category('toilet')?.click(); await c.updateComplete;
|
||
pick('toilet'); await c.updateComplete;
|
||
if (wIn()) {
|
||
wIn().value = '0.5';
|
||
wIn().dispatchEvent(new Event('input', { bubbles: true }));
|
||
}
|
||
await c.updateComplete;
|
||
out.fieldEditsTheArmedSize = Math.abs((c._furnPalette?.w ?? 0) - 50) < 1e-6;
|
||
ev('pointerdown', stageEl(), 300, 400);
|
||
await c.updateComplete;
|
||
const wc = c._decorList.find((s) => s.symbol === 'toilet');
|
||
out.typedSizeIsUsed = near((wc?.w ?? NaN) * 1000, cmToUnits(50), 1e-6)
|
||
&& near((wc?.h ?? NaN) * 1000, cmToUnits(70), 1e-6);
|
||
|
||
// ================= 6. магнит к стене =====================================
|
||
// #445: make the living-room top and shared right wall physically thick.
|
||
// Exact endpoints are persisted in normalised config coordinates; the
|
||
// magnet must consume their room-facing render surfaces, not their axes.
|
||
c._curSpaceCfg.walls = [
|
||
{ key: '0.295833,0.141667@0.0000', cm: 20, a: [0.04, 0.14], b: [0.55, 0.14] },
|
||
{ key: '0.550000,0.358333@1.5706', cm: 20, a: [0.55, 0.14], b: [0.55, 0.58] },
|
||
];
|
||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||
const wallHalf = cmToUnits(20) / 2;
|
||
const firstSurfaceSnapshot = c._editorRuntime?._furnWalls;
|
||
out.wallSurfaceCandidatesAreCached = !!firstSurfaceSnapshot
|
||
&& firstSurfaceSnapshot === c._editorRuntime?._furnWalls;
|
||
|
||
await openFurniture();
|
||
category('bed')?.click(); await c.updateComplete;
|
||
pick('bed_double'); await c.updateComplete;
|
||
ev('pointermove', stageEl(), 300, 140 + wallHalf, { pointerType: 'mouse' });
|
||
await settleLive();
|
||
const thickWallPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||
ev('pointerdown', stageEl(), 300, 140 + wallHalf);
|
||
await c.updateComplete;
|
||
const bed = c._decorList.find((s) => s.symbol === 'bed_double');
|
||
const depth = cmToUnits(200);
|
||
out.wallMagnetPullsItFlat = !!bed
|
||
&& near((bed.y + bed.h / 2) * 1000, 140 + wallHalf + depth / 2, 1e-6);
|
||
out.wallMagnetKeepsTheAngle = !!bed && (bed.angle || 0) === 0; // стена горизонтальна
|
||
out.wallMagnetQuantisesAlongTheWall = !!bed
|
||
&& Math.abs((bed.x + bed.w / 2) * 1000 - 300) <= PITCH + 1e-6;
|
||
out.thickWallPreviewAndCommitAreIdentical = !!bed && !!thickWallPreview
|
||
&& ['x', 'y', 'w', 'h'].every((key) => near(bed[key], thickWallPreview[key], 1e-12))
|
||
&& (bed.angle || 0) === thickWallPreview.angle;
|
||
|
||
// #447: the same finite outer atom also has its exterior physical face.
|
||
// Raw intent above the wall must never be pulled through to the room side;
|
||
// preview, commit and exact-axis drag all share that decision.
|
||
await openFurniture();
|
||
c._furnPalette = { symbol: 'chair', w: 50, h: 50 };
|
||
c.requestUpdate(); await c.updateComplete;
|
||
const exteriorIntentY = 140 - wallHalf;
|
||
ev('pointermove', stageEl(), 300, exteriorIntentY, { pointerType: 'mouse' });
|
||
await settleLive();
|
||
const exteriorPreview = c._editorRuntime?._furniturePreviewPlacement()
|
||
? JSON.parse(JSON.stringify(c._editorRuntime._furniturePreviewPlacement())) : null;
|
||
const exteriorDepth = cmToUnits(50);
|
||
const exteriorCentreY = 140 - wallHalf - exteriorDepth / 2;
|
||
out.exteriorPreviewStaysOutside = !!exteriorPreview
|
||
&& near((exteriorPreview.y + exteriorPreview.h / 2) * 1000, exteriorCentreY, 1e-6)
|
||
&& Math.abs(Math.abs(exteriorPreview.angle) - 180) < 1e-6;
|
||
ev('pointerdown', stageEl(), 300, exteriorIntentY);
|
||
await c.updateComplete;
|
||
const exteriorChair = c._decorList.find((shape) => shape.symbol === 'chair');
|
||
const exteriorChairId = exteriorChair?.id ?? null;
|
||
out.exteriorCommitMatchesPreview = !!exteriorChair && !!exteriorPreview
|
||
&& ['x', 'y', 'w', 'h'].every((key) => near(exteriorChair[key], exteriorPreview[key], 1e-12))
|
||
&& (exteriorChair.angle || 0) === exteriorPreview.angle;
|
||
ev('pointerdown', el(exteriorChairId), 300, exteriorCentreY);
|
||
ev('pointermove', stageEl(), 300, 140);
|
||
await settleLive();
|
||
const exteriorAxisDrag = c._decorList.find((shape) => shape.id === exteriorChairId);
|
||
out.exteriorExactAxisDragPreservesSide = !!exteriorAxisDrag
|
||
&& near((exteriorAxisDrag.y + exteriorAxisDrag.h / 2) * 1000, exteriorCentreY, 1e-6)
|
||
&& Math.abs(Math.abs(exteriorAxisDrag.angle || 0) - 180) < 1e-6;
|
||
ev('pointerup', stageEl(), 300, 140);
|
||
await c.updateComplete;
|
||
|
||
// The regular decor snap resolves this pointer to the shared wall axis.
|
||
// Surface-side selection must still use the raw point on the r2 side.
|
||
await openFurniture();
|
||
category('chair')?.click(); await c.updateComplete;
|
||
pick('chair'); await c.updateComplete;
|
||
const sharedIntentX = 550 + wallHalf / 2;
|
||
ev('pointermove', stageEl(), sharedIntentX, 300, { pointerType: 'mouse' });
|
||
await settleLive();
|
||
ev('pointerdown', stageEl(), sharedIntentX, 300);
|
||
await c.updateComplete;
|
||
const sharedChair = c._decorList.find((s) => s.symbol === 'chair' && s.id !== exteriorChairId);
|
||
const chairDepth = (sharedChair?.h ?? NaN) * 1000;
|
||
const rightSurfaceCentre = 550 + wallHalf + chairDepth / 2;
|
||
out.sharedWallUsesRawPointerSide = !!sharedChair
|
||
&& near((sharedChair.x + sharedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
|
||
|
||
// Once a piece is on the r2 face, an exact-axis drag preserves that face
|
||
// instead of resolving an arbitrary room/input-order tie.
|
||
const sharedChairId = sharedChair?.id ?? null;
|
||
const sharedChairY = sharedChair ? (sharedChair.y + sharedChair.h / 2) * 1000 : NaN;
|
||
ev('pointerdown', el(sharedChairId), rightSurfaceCentre, sharedChairY);
|
||
ev('pointermove', stageEl(), 550, sharedChairY);
|
||
await settleLive();
|
||
const axisDraggedChair = c._decorList.find((s) => s.id === sharedChairId);
|
||
out.exactAxisDragPreservesWallSide = !!axisDraggedChair
|
||
&& near((axisDraggedChair.x + axisDraggedChair.w / 2) * 1000, rightSurfaceCentre, 1e-6);
|
||
ev('pointerup', stageEl(), 550, sharedChairY);
|
||
await c.updateComplete;
|
||
|
||
// Shift обходит магнит, но оставляет обычную привязку декора к сетке.
|
||
await openFurniture();
|
||
category('bed')?.click(); await c.updateComplete;
|
||
pick('bed_single'); await c.updateComplete;
|
||
ev('pointerdown', stageEl(), 301.7, 151.3, { shiftKey: true });
|
||
await c.updateComplete;
|
||
const free = c._decorList.find((s) => s.symbol === 'bed_single');
|
||
out.shiftBypassesTheWallMagnet = !!free
|
||
&& !near((free.y + free.h / 2) * 1000, 151.3, 1e-6)
|
||
&& free.angle === undefined;
|
||
|
||
// Coarse pointers have no hover. A cancelled/dragged/second-contact gesture
|
||
// must not become an accidental furniture save. Keep this after the palette
|
||
// click checks because these synthetic pointer streams deliberately have no
|
||
// browser-generated compatibility click tail.
|
||
await openFurniture();
|
||
category('chair')?.click(); await c.updateComplete;
|
||
pick('chair'); await c.updateComplete;
|
||
const beforeTouch = c._decorList.length;
|
||
ev('pointerdown', stageEl(), 330, 330, { pointerType: 'touch', pointerId: 21 });
|
||
ev('pointermove', stageEl(), 350, 350, { pointerType: 'touch', pointerId: 21 });
|
||
ev('pointerup', stageEl(), 350, 350, { pointerType: 'touch', pointerId: 21 });
|
||
ev('pointerdown', stageEl(), 330, 330, { pointerType: 'touch', pointerId: 22 });
|
||
ev('pointercancel', stageEl(), 330, 330, { pointerType: 'touch', pointerId: 22 });
|
||
ev('pointerdown', stageEl(), 330, 330, { pointerType: 'touch', pointerId: 23 });
|
||
ev('pointerdown', stageEl(), 340, 340, { pointerType: 'touch', pointerId: 24, isPrimary: false });
|
||
ev('pointerup', stageEl(), 330, 330, { pointerType: 'touch', pointerId: 23 });
|
||
ev('pointerup', stageEl(), 340, 340, { pointerType: 'touch', pointerId: 24, isPrimary: false });
|
||
await c.updateComplete;
|
||
out.touchCancelMoveAndSecondContactDoNotSave = c._decorList.length === beforeTouch
|
||
&& !ghost() && c._furnTouchPending === null;
|
||
|
||
// …магнит работает и при ПЕРЕТАСКИВАНИИ: тянем диван к общей толстой стене
|
||
// со стороны гостиной. BACK обязан лечь на x=550-half, а не на ось x=550.
|
||
c._editorRuntime._clearFurniturePreview();
|
||
c._decorTool = 'select'; c._furnPalette = null; c._furnCategory = null;
|
||
c._decorSel = sofaId; c.requestUpdate();
|
||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||
ev('pointerdown', el(sofaId), 300, 300);
|
||
out.dragStarted = !!sofaId && c._decorMove?.id === sofaId;
|
||
ev('pointermove', stageEl(), 540, 300);
|
||
await settleLive();
|
||
const pulled = sofaNow();
|
||
out.dragMagnetTurnsItToTheWall = Math.abs(Math.abs(pulled.angle || 0) - 90) < 1e-6;
|
||
out.dragMagnetPressesTheBack =
|
||
near((pulled.x + pulled.w / 2) * 1000,
|
||
550 - wallHalf - cmToUnits(90) / 2, 1e-6);
|
||
ev('pointermove', stageEl(), 300, 300, { shiftKey: true });
|
||
await settleLive();
|
||
const shiftDragGridIndex = (sofaNow().x * 1000) / PITCH;
|
||
out.shiftDragRemainsGridBound = near(shiftDragGridIndex, Math.round(shiftDragGridIndex), 1e-6);
|
||
ev('pointerup', stageEl(), 300, 300, { shiftKey: true });
|
||
await c.updateComplete;
|
||
|
||
// ================= 7. рамка, углы, плашки, поворот =======================
|
||
// Previous movement deliberately exercised the wall magnet and may have
|
||
// rotated the piece. Isolate the transform fixture at 0° and without flips.
|
||
c._curSpaceCfg.decor = c._decorList.map((shape) => {
|
||
if (shape.id !== sofaId) return shape;
|
||
const clean = { ...shape };
|
||
delete clean.angle; delete clean.flip_h; delete clean.flip_v;
|
||
return clean;
|
||
});
|
||
c._cfgEpoch++;
|
||
c._decorSel = sofaId; c.requestUpdate();
|
||
await c.updateComplete; await sleep(60); await c.updateComplete;
|
||
const frame = () => sr().querySelector('[data-hp-live-editor] .dtframe') || sr().querySelector('.dtframe');
|
||
out.frameOnSelection = !!frame();
|
||
out.furnitureFrameHasFourSideHandles = !!frame()
|
||
&& frame().classList.contains('dtfurnitureframe')
|
||
&& frame().querySelectorAll('.dthandle').length === 9
|
||
&& frame().querySelectorAll('.dtknob').length === 9
|
||
&& frame().querySelectorAll('.dthandle.dtedge').length === 4
|
||
&& !!frame().querySelector('.dtrot');
|
||
out.sideHandlesExposeAxisCursors = [...frame().querySelectorAll('.dthandle.dtedge')]
|
||
.every((handle) => ['ew-resize', 'ns-resize'].includes(getComputedStyle(handle).cursor));
|
||
out.rotationHandleUsesCircularCursor = getComputedStyle(frame().querySelector('.dtrot')).cursor.includes('url(');
|
||
const rOf = (sel) => { const e = frame()?.querySelector(sel); return e ? +e.getAttribute('r') : NaN; };
|
||
out.handleSizeIsTheTaskOneSize = Math.abs(rOf('.dtknob') * 4 - rOf('.dthandle.dtrot'))
|
||
/ Math.max(rOf('.dthandle.dtrot'), 1e-9) < 0.05;
|
||
const frameBox = frame()?.querySelector('.dtbox');
|
||
out.frameBoxIsTheShapeBox = !!frameBox
|
||
&& near(+frameBox.getAttribute('width'), sofaNow().w * 1000, 1e-6)
|
||
&& near(+frameBox.getAttribute('height'), sofaNow().h * 1000, 1e-6);
|
||
|
||
const visiblePath = el(sofaId);
|
||
const hitPath = sr().querySelector(`.dfurniturehit[data-id="${sofaId}"]`);
|
||
out.selectionHaloUsesTheRealArtworkPath = !!visiblePath && !!hitPath
|
||
&& hitPath.getAttribute('d') === visiblePath.getAttribute('d')
|
||
&& hitPath.getAttribute('transform') === visiblePath.getAttribute('transform')
|
||
&& Number(hitPath.getAttribute('stroke-width')) > Number(visiblePath.getAttribute('stroke-width'))
|
||
&& getComputedStyle(hitPath).pointerEvents === 'stroke'
|
||
&& /transparent|rgba\(0, 0, 0, 0\)/.test(getComputedStyle(hitPath).stroke);
|
||
|
||
// Shift: independent, continuous width/depth + live plates.
|
||
const before = { w: sofaNow().w, h: sofaNow().h };
|
||
const b = box() || { x: 0, y: 0, w: 0, h: 0 };
|
||
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
|
||
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
|
||
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
|
||
// рисуются последними, чтобы выигрывать нажатие у осевых на мелкой мебели),
|
||
// и оно имеет право меняться. Юго-восточный угол — второй `nwse`.
|
||
const cornerHandles = handles.filter((el) => !el.classList.contains('dtrot')
|
||
&& !el.classList.contains('dtedge'));
|
||
const se = cornerHandles[2] || stageEl(); // SE — [1, 1, 'nwse']
|
||
ev('pointerdown', se, b.x + b.w, b.y + b.h);
|
||
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
|
||
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
|
||
await settleLive();
|
||
const grown = sofaNow();
|
||
out.cornerGrowsTheWidth = grown.w > before.w && near(grown.w * 1000, b.w + 100, 1e-6);
|
||
out.cornerGrowsTheDepthINDEPENDENTLY = grown.h > before.h
|
||
&& near(grown.h * 1000, b.h + 20, 1e-6);
|
||
out.shiftResizeIsSubGrid = !near((grown.h * 1000) / PITCH,
|
||
Math.round((grown.h * 1000) / PITCH), 1e-4);
|
||
out.oppositeCornerStaysPut = near(grown.x * 1000, b.x, 1e-6) && near(grown.y * 1000, b.y, 1e-6);
|
||
const plates = [...sr().querySelectorAll('.measurelabel.furnmeasure')];
|
||
out.twoLivePlates = plates.length === 2;
|
||
out.platesShowRealLengths = plates.length === 2
|
||
&& plates.every((p) => /\d/.test(p.textContent))
|
||
&& plates.some((p) => /m|м|′/.test(p.textContent));
|
||
// Without Shift the original ratio is preserved, still without grid snap.
|
||
ev('pointermove', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||
await settleLive();
|
||
const proportional = sofaNow();
|
||
const wCells = (proportional.w * 1000) / PITCH;
|
||
out.defaultResizePreservesRatio = near(proportional.w / proportional.h, before.w / before.h, 1e-5);
|
||
out.defaultResizeIsSubGrid = !near(wCells, Math.round(wCells), 1e-4);
|
||
ev('pointerup', stageEl(), b.x + b.w + 101.7, b.y + b.h + 21.3);
|
||
await c.updateComplete;
|
||
out.cornerDragEnded = !c._dtDrag;
|
||
out.noPlatesAfterTheDrag = !sr().querySelector('.measurelabel.furnmeasure');
|
||
|
||
// A middle handle changes only one axis and keeps the opposite edge fixed.
|
||
const edgeBefore = { ...sofaNow() };
|
||
const eb = box();
|
||
const rightEdge = [...frame().querySelectorAll('.dthandle.dtedge')].find((handle) =>
|
||
near(+handle.getAttribute('cx'), eb.x + eb.w, 1e-6));
|
||
ev('pointerdown', rightEdge, eb.x + eb.w, eb.y + eb.h / 2);
|
||
ev('pointermove', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
|
||
await settleLive();
|
||
const edgeAfter = sofaNow();
|
||
out.edgeHandleChangesOneAxisContinuously = near(edgeAfter.h, edgeBefore.h, 1e-9)
|
||
&& near(edgeAfter.w * 1000, edgeBefore.w * 1000 + 13.7, 1e-6)
|
||
&& near(edgeAfter.x, edgeBefore.x, 1e-9) && near(edgeAfter.y, edgeBefore.y, 1e-9);
|
||
ev('pointerup', stageEl(), eb.x + eb.w + 13.7, eb.y + eb.h / 2);
|
||
await c.updateComplete;
|
||
|
||
// Crossing the fixed corner toggles only the crossed axis. pointercancel
|
||
// must restore the exact pre-gesture object and create no persisted flip.
|
||
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
|
||
const cb = box();
|
||
const crossHandles = [...frame().querySelectorAll('.dthandle')]
|
||
.filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
|
||
const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
|
||
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
|
||
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
|
||
await settleLive();
|
||
const crossed = sofaNow();
|
||
out.crossingTogglesOnlyHorizontalFlip = crossed.flip_h === true && !crossed.flip_v
|
||
&& crossed.w > 0 && crossed.h > 0 && near(crossed.x * 1000 + crossed.w * 1000, cb.x, 1e-6);
|
||
ev('pointercancel', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
|
||
await c.updateComplete;
|
||
out.crossingCancelRestoresTheObject = JSON.stringify(sofaNow()) === JSON.stringify(crossBefore);
|
||
|
||
// Furniture rotation is free; Shift snaps to the nearest 45°.
|
||
await sleep(40); await c.updateComplete;
|
||
const cx = (sofaNow().x + sofaNow().w / 2) * 1000;
|
||
const cy = (sofaNow().y + sofaNow().h / 2) * 1000;
|
||
const piv = c._dtPivot ? c._dtPivot(sofaNow()) : [NaN, NaN];
|
||
out.rotatesAboutTheCENTRE = near(piv[0], cx, 1e-6) && near(piv[1], cy, 1e-6);
|
||
ev('pointerdown', frame()?.querySelector('.dthandle.dtrot'), cx + 100, cy);
|
||
out.rotateStarted = c._dtDrag?.kind === 'rotate';
|
||
ev('pointermove', stageEl(), cx + 100, cy + 100); // ровно 45°
|
||
await settleLive();
|
||
out.rotates45 = near(sofaNow().angle || 0, 45, 1e-6);
|
||
ev('pointermove', stageEl(), cx + 100, cy + 2); // ~1.1° → липнет к 0
|
||
await settleLive();
|
||
const fine = sofaNow().angle;
|
||
out.rotationIsFreeWithoutShift = fine > 0.5 && fine < 5;
|
||
ev('pointermove', stageEl(), cx + 100, cy + 2, { shiftKey: true });
|
||
await settleLive();
|
||
out.shiftSnapsRotationTo45 = (sofaNow().angle || 0) === 0;
|
||
ev('pointermove', stageEl(), cx + 100, cy + 100);
|
||
await settleLive();
|
||
ev('pointerup', stageEl(), cx + 100, cy + 100);
|
||
await c.updateComplete;
|
||
out.rotationIsRendered = /rotate\(/.test(attr(sofaId, 'transform') || '');
|
||
|
||
// ================= 8. конфиг и пересборка ================================
|
||
const stored = JSON.parse(JSON.stringify(sofaNow()));
|
||
out.storedShapeIsComplete = stored.kind === 'furniture' && typeof stored.symbol === 'string'
|
||
&& ['x', 'y', 'w', 'h'].every((k) => Number.isFinite(stored[k]));
|
||
c.hass = { ...c.hass }; // полная пересборка
|
||
c._cfgEpoch++; c.requestUpdate();
|
||
await c.updateComplete; await sleep(40); await c.updateComplete;
|
||
const after = sofaNow();
|
||
out.survivesARebuild = after.symbol === stored.symbol
|
||
&& near(after.w, stored.w, 1e-12) && near(after.h, stored.h, 1e-12)
|
||
&& near(after.x, stored.x, 1e-12) && near(after.y, stored.y, 1e-12);
|
||
out.stillRenderedAfterRebuild = !!el(sofaId);
|
||
c._decorTool = 'select';
|
||
c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, after);
|
||
await c.updateComplete;
|
||
out.objectDialogSelectsStoredSymbol = sr().querySelector(
|
||
'.body select.namein option:checked',
|
||
)?.value === stored.symbol;
|
||
const sizeInputs = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
|
||
const flipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
|
||
out.furniturePropertiesExposeSignedSizesAndTwoFlips = sizeInputs.length === 2 && flipInputs.length === 2;
|
||
if (sizeInputs[0]) {
|
||
sizeInputs[0].value = String(-Math.abs(Number(sizeInputs[0].value)));
|
||
sizeInputs[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||
await c.updateComplete;
|
||
}
|
||
out.negativeWidthSynchronisesHorizontalCheckbox = !!c._decorShapeDialog?.flipH
|
||
&& !!sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')[0]?.checked;
|
||
const currentFlipInputs = [...sr().querySelectorAll('hp-dialog .dfill input[type="checkbox"]')];
|
||
if (currentFlipInputs[1]) {
|
||
currentFlipInputs[1].checked = true;
|
||
currentFlipInputs[1].dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
||
await c.updateComplete;
|
||
}
|
||
const currentSizes = [...sr().querySelectorAll('hp-dialog input[type="number"][step="any"]')];
|
||
out.verticalCheckboxSynchronisesNegativeDepth = !!c._decorShapeDialog?.flipV
|
||
&& Number(currentSizes[1]?.value) < 0;
|
||
if (currentSizes[0]) {
|
||
currentSizes[0].value = '0';
|
||
currentSizes[0].dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||
await c.updateComplete;
|
||
}
|
||
out.zeroSizeBlocksSave = sr().querySelector('hp-dialog button.btn.primary')?.disabled === true
|
||
&& sr().querySelector('hp-dialog input[type="number"][step="any"]')?.getAttribute('aria-invalid') === 'true';
|
||
c._decorShapeDialog = {
|
||
...c._decorShapeDialog,
|
||
sizeWField: String(-Math.abs(Number(stored.w) * 1000 * CELL / PITCH / 100)),
|
||
};
|
||
c._decorSaveShape(); await c.updateComplete;
|
||
const propertySaved = sofaNow();
|
||
out.propertySavePersistsPositiveExtentsAndFlags = propertySaved.w > 0 && propertySaved.h > 0
|
||
&& propertySaved.flip_h === true && propertySaved.flip_v === true
|
||
&& c._decorShapeDialog === null;
|
||
|
||
// ================= 9. инертность и удаление ==============================
|
||
c._decorTool = 'furniture'; c._furnPalette = null; await c.updateComplete;
|
||
out.inertUnderTheStamp = pe(sofaId) === 'none';
|
||
const n0 = c._decorList.length;
|
||
ev('pointerdown', stageEl(), 320, 320); // символ НЕ выбран
|
||
await c.updateComplete;
|
||
out.noSymbolNoStamp = c._decorList.length === n0;
|
||
out.unarmedCanvasDismissReturnsToSelect = c._decorTool === 'select' && !pal();
|
||
out.unarmedCanvasDismissLeavesNoClickSuppression = c._suppressClick === false;
|
||
c._decorTool = 'erase'; await c.updateComplete;
|
||
ev('pointerdown', el(sofaId), 300, 300);
|
||
await c.updateComplete;
|
||
// Eraser is intentionally two-step now; the generic decor smoke verifies
|
||
// the dialog itself, while this furniture scenario confirms the action.
|
||
c._confirmDecorErase();
|
||
await c.updateComplete;
|
||
out.eraseRemovesIt = !!sofaId && !c._decorList.some((s) => s.id === sofaId);
|
||
// а в режиме просмотра мебель видна и не кликается
|
||
c._setMode('view'); await c.updateComplete; await sleep(30);
|
||
const left = c._decorList.find((s) => s.kind === 'furniture');
|
||
out.visibleInView = !!left && !!el(left.id);
|
||
out.inertInView = !!left && pe(left.id) === 'none';
|
||
|
||
// #606: a saved 0.4.0 cactus is a read-only alias, not a 61st palette item.
|
||
// It must request the lazy artwork and remain editable without changing the
|
||
// saved id or transform until the person deliberately changes the variant.
|
||
const legacy = {
|
||
id: 'legacy-cactus', kind: 'furniture', symbol: 'cactus',
|
||
x: 0.3, y: 0.3, w: 0.17, h: 0.29, angle: 30,
|
||
flip_h: true, color: '#123456', opacity: 0.8, width_cm: 2,
|
||
};
|
||
c._curSpaceCfg.decor = [legacy]; c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||
const legacyDrawing = attr(legacy.id, 'd');
|
||
out.savedCactusDrawsInView = typeof legacyDrawing === 'string' && legacyDrawing.length > 20;
|
||
c._curSpaceCfg.decor = [{ ...legacy, symbol: 'exercise' }];
|
||
c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||
out.savedCactusUsesExerciseArtwork = legacyDrawing === attr(legacy.id, 'd');
|
||
c._curSpaceCfg.decor = [legacy]; c._cfgEpoch++; c.requestUpdate(); await c.updateComplete;
|
||
sr().querySelectorAll('.modetab')[2].click(); await c.updateComplete;
|
||
while (c._modeTransitionBusy || c._refitRaf || c._pendingRefitSize) await new Promise(requestAnimationFrame);
|
||
c._decorTool = 'select'; c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, legacy);
|
||
await c.updateComplete;
|
||
const symbolSelect = () => sr().querySelector('hp-dialog select.namein');
|
||
out.legacyPropertiesShowExercise = symbolSelect()?.selectedOptions[0]?.value === 'exercise'
|
||
&& c._decorShapeDialog?.symbol === 'cactus';
|
||
c._decorSaveShape(); await c.updateComplete;
|
||
const savedLegacy = c._decorList.find((shape) => shape.id === legacy.id);
|
||
out.unrelatedSaveKeepsLegacyIdAndTransform = savedLegacy?.symbol === 'cactus'
|
||
&& ['x', 'y', 'w', 'h', 'angle', 'flip_h', 'color', 'opacity', 'width_cm']
|
||
.every((key) => near(Number(savedLegacy[key]), Number(legacy[key]), 1e-8)
|
||
|| savedLegacy[key] === legacy[key]);
|
||
if (savedLegacy) {
|
||
c._decorShapeDbl({ preventDefault() {}, stopPropagation() {} }, savedLegacy);
|
||
await c.updateComplete;
|
||
if (symbolSelect()) {
|
||
symbolSelect().value = 'plant';
|
||
symbolSelect().dispatchEvent(new Event('change', { bubbles: true }));
|
||
await c.updateComplete;
|
||
symbolSelect().value = 'exercise';
|
||
symbolSelect().dispatchEvent(new Event('change', { bubbles: true }));
|
||
await c.updateComplete;
|
||
}
|
||
c._decorSaveShape(); await c.updateComplete;
|
||
}
|
||
out.explicitVariantChangeWritesExercise = c._decorList.find((shape) => shape.id === legacy.id)?.symbol === 'exercise';
|
||
return out;
|
||
});
|
||
|
||
// #361: this is deliberately a pixel assertion, not another DOM-attribute
|
||
// comparison. `vector-effect=non-scaling-stroke` can make the attributes look
|
||
// perfectly consistent while Chromium paints furniture at the wrong camera
|
||
// zoom. Bright magenta isolates the fixture from the normal plan artwork.
|
||
const renderStrokeFixture = async (zoom, angle = 0, viewport = null) => {
|
||
if (viewport) {
|
||
await page.setViewportSize(viewport);
|
||
await page.evaluate((width) => {
|
||
const host = document.querySelector('#host');
|
||
if (host instanceof HTMLElement) host.style.width = `${width}px`;
|
||
}, Math.max(320, viewport.width - 40));
|
||
}
|
||
await page.evaluate(async ({ zoom, angle }) => {
|
||
const c = window.__card;
|
||
c._setMode('view');
|
||
await c.updateComplete;
|
||
for (let i = 0; i < 80 && c._modeTransitionBusy; i++)
|
||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
c._curSpaceCfg.decor = [
|
||
{ id: 'stroke-control', kind: 'line', x1: 0.18, y1: 0.18, x2: 0.50, y2: 0.18,
|
||
color: '#ff00ff', opacity: 1, width_cm: 8 },
|
||
// Designer artwork: deliberately very wide and shallow so a leaked local
|
||
// x/y scale produces obviously different horizontal/vertical strokes.
|
||
{ id: 'stroke-designer', kind: 'furniture', symbol: 'coffee_table',
|
||
x: 0.18, y: 0.30, w: 0.38, h: 0.09, angle,
|
||
color: '#ff00ff', opacity: 1, width_cm: 8 },
|
||
// #593: `fridge` до этой задачи рисовался примитивом из unit box, и
|
||
// фикстура доказывала, что ВТОРОЙ источник пути ведёт себя как обычный
|
||
// декор. Источник теперь один, но предмет оставлен в фикстуре намеренно:
|
||
// именно эти 12 ID сменили рисунок, и именно у них регрессия была бы
|
||
// незаметна — их пропорции и координаты изменились полностью.
|
||
{ id: 'stroke-converted', kind: 'furniture', symbol: 'fridge',
|
||
x: 0.62, y: 0.27, w: 0.11, h: 0.20, angle,
|
||
color: '#ff00ff', opacity: 1, width_cm: 8 },
|
||
];
|
||
c._cfgEpoch++;
|
||
c._zoom = 1;
|
||
c._view = null;
|
||
c.requestUpdate();
|
||
await c.updateComplete;
|
||
c._applyView(zoom, 450, 350);
|
||
c.requestUpdate();
|
||
await c.updateComplete;
|
||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||
}, { zoom, angle });
|
||
|
||
const samples = await page.evaluate(() => {
|
||
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
||
const sample = (id, a, b) => {
|
||
const node = root.querySelector(`.decorlayer [data-id="${id}"]`);
|
||
const matrix = node?.getScreenCTM?.();
|
||
if (!node || !matrix) return null;
|
||
const map = ([x, y]) => {
|
||
const point = new DOMPoint(x, y).matrixTransform(matrix);
|
||
return { x: point.x, y: point.y };
|
||
};
|
||
return { a: map(a), b: map(b) };
|
||
};
|
||
const line = root.querySelector('.decorlayer [data-id="stroke-control"]');
|
||
const x1 = Number(line?.getAttribute('x1'));
|
||
const x2 = Number(line?.getAttribute('x2'));
|
||
const y = Number(line?.getAttribute('y1'));
|
||
return {
|
||
control: sample('stroke-control', [x1 + (x2 - x1) * 0.35, y], [x1 + (x2 - x1) * 0.65, y]),
|
||
// coffee_table 0.4.0: прямые участки контура — верхняя грань y = 0
|
||
// (x от 2.5 до 117.5) и правая x = 120 (y от 2.5 до 57.5).
|
||
designerH: sample('stroke-designer', [30, 0], [90, 0]),
|
||
designerV: sample('stroke-designer', [120, 18], [120, 45]),
|
||
// fridge 0.4.0: верхняя грань y = 0, x от 2.143 до 57.857.
|
||
converted: sample('stroke-converted', [15, 0], [45, 0]),
|
||
};
|
||
});
|
||
const png = (await page.screenshot({ type: 'png' })).toString('base64');
|
||
return page.evaluate(async ({ png, samples }) => {
|
||
const image = new Image();
|
||
image.src = `data:image/png;base64,${png}`;
|
||
await image.decode();
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = image.naturalWidth;
|
||
canvas.height = image.naturalHeight;
|
||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||
ctx.drawImage(image, 0, 0);
|
||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||
const magenta = (x, y) => {
|
||
x = Math.round(x); y = Math.round(y);
|
||
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) return false;
|
||
const i = (y * canvas.width + x) * 4;
|
||
return pixels[i] > 150 && pixels[i + 1] < 130 && pixels[i + 2] > 150 && pixels[i + 3] > 80;
|
||
};
|
||
const thickness = (segment) => {
|
||
if (!segment) return NaN;
|
||
const dx = segment.b.x - segment.a.x;
|
||
const dy = segment.b.y - segment.a.y;
|
||
const length = Math.hypot(dx, dy);
|
||
if (!(length > 8)) return NaN;
|
||
const tx = dx / length, ty = dy / length;
|
||
const nx = -ty, ny = tx;
|
||
const cx = (segment.a.x + segment.b.x) / 2;
|
||
const cy = (segment.a.y + segment.b.y) / 2;
|
||
const widths = [];
|
||
for (const along of [-8, -4, 0, 4, 8]) {
|
||
const hits = [];
|
||
for (let normal = -24; normal <= 24; normal++) {
|
||
if (magenta(cx + tx * along + nx * normal, cy + ty * along + ny * normal))
|
||
hits.push(normal);
|
||
}
|
||
if (hits.length) widths.push(hits.at(-1) - hits[0] + 1);
|
||
}
|
||
widths.sort((a, b) => a - b);
|
||
return widths.length ? widths[Math.floor(widths.length / 2)] : NaN;
|
||
};
|
||
return Object.fromEntries(Object.entries(samples).map(([key, value]) => [key, thickness(value)]));
|
||
}, { png, samples });
|
||
};
|
||
|
||
const z1 = await renderStrokeFixture(1);
|
||
const z2 = await renderStrokeFixture(2);
|
||
const rotated = await renderStrokeFixture(1, 30);
|
||
const rotatedZ2 = await renderStrokeFixture(2, 30);
|
||
const compact = await renderStrokeFixture(1, 0, { width: 620, height: 760 });
|
||
const finite = (...values) => values.every((value) => Number.isFinite(value) && value > 0);
|
||
const closePx = (a, b, tolerance = 2) => finite(a, b) && Math.abs(a - b) <= tolerance;
|
||
const doubles = (a, b) => finite(a, b) && b / a >= 1.6 && b / a <= 2.4;
|
||
res.rasterFixturePainted = finite(
|
||
z1.control, z1.designerH, z1.designerV, z1.converted,
|
||
z2.control, z2.designerH, z2.converted, rotated.designerH,
|
||
rotatedZ2.designerH, compact.control, compact.designerH, compact.converted,
|
||
);
|
||
res.furnitureFollowsPhysicalCameraZoom = doubles(z1.control, z2.control)
|
||
&& doubles(z1.designerH, z2.designerH)
|
||
&& doubles(z1.converted, z2.converted)
|
||
&& doubles(rotated.designerH, rotatedZ2.designerH);
|
||
res.everyFurniturePathMatchesOrdinaryDecor = closePx(z1.designerH, z1.control)
|
||
&& closePx(z1.converted, z1.control)
|
||
&& closePx(z2.designerH, z2.control)
|
||
&& closePx(z2.converted, z2.control);
|
||
res.anisotropicResizeKeepsBothAxesEqual = closePx(z1.designerH, z1.designerV);
|
||
res.rotatedArtworkKeepsTheSameThickness = closePx(rotated.designerH, z1.designerH);
|
||
res.viewportResizeRecalculatesTheSharedPhysicalStroke = closePx(compact.designerH, compact.control)
|
||
&& closePx(compact.converted, compact.control)
|
||
&& Math.abs(compact.control - z1.control) >= 1;
|
||
checkAll(res);
|
||
await finish(browser, res);
|