mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Корпус геометрий, household-сценарии и аудит доступности View
Полевая проверка основного View по #560: шесть обезличенных планов корпуса и цепочка import → Optimize → Optimize → Resize → сохранение с численными оракулами, семь household-путей с независимыми оракулами в двух ширинах карточки, аудит доступности с измеренными числами. Продуктовых правок нет: находки заведены отдельно (#564, #565, #566). Issue: #560 User-Visible: no
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
// Браузерная половина геометрического корпуса #560.
|
||||
//
|
||||
// Числовые оракулы корпуса живут в test/geometry-corpus.test.mjs и карточку не
|
||||
// поднимают. Здесь проверяется ровно то, что без карточки проверить нельзя:
|
||||
//
|
||||
// 1. штатный writer ресайза — перенос ЗАПИСЕЙ ТОЛЩИНЫ вслед за сдвинутым
|
||||
// ребром делает хост (`project()` в ResizeController), а не resize.ts;
|
||||
// 2. хвост цепочки «→ View»: план корпуса действительно рисуется, и важные
|
||||
// участки (комнаты, проём, штриховка стен) присутствуют при DPR 1 и 2.
|
||||
//
|
||||
// Рендер здесь НЕ эталон: сравниваются не пиксели, а присутствие и число узлов
|
||||
// плюс сохранность модели после жеста. Приёмка #560 прямо запрещает принимать
|
||||
// текущий рендер за единственный источник истины.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch, check, finish } from './serve.mjs';
|
||||
|
||||
const corpus = (name) => JSON.parse(readFileSync(
|
||||
new URL(`../test/fixtures/560-corpus/${name}.json`, import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
const { page, browser } = await launch();
|
||||
|
||||
/** Положить план корпуса в карточку как готовую модель v10. */
|
||||
const loadCorpus = async (config) => {
|
||||
await page.evaluate((incoming) => {
|
||||
const card = window.__card;
|
||||
const space = incoming.spaces[0];
|
||||
card._serverCfg.model_version = incoming.model_version;
|
||||
const target = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
for (const key of ['rooms', 'walls', 'wall_segments', 'openings', 'partitions']) {
|
||||
if (space[key]) target[key] = structuredClone(space[key]);
|
||||
else delete target[key];
|
||||
}
|
||||
target.cell_cm = space.cell_cm;
|
||||
for (const key of ['decor', 'wall_columns', 'room_drafts', 'open_spans']) delete target[key];
|
||||
card._geometryHistory.clear();
|
||||
card._resize.reset();
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
return card.updateComplete;
|
||||
}, config);
|
||||
await page.waitForTimeout(60);
|
||||
};
|
||||
|
||||
const enter = async (tool) => {
|
||||
await page.evaluate((next) => {
|
||||
const card = window.__card;
|
||||
if (!card._markup) card._setMode('plan');
|
||||
card._tool = next;
|
||||
card._resize.reset();
|
||||
card.requestUpdate();
|
||||
return card.updateComplete;
|
||||
}, tool);
|
||||
await page.waitForTimeout(40);
|
||||
};
|
||||
|
||||
const settle = () => page.evaluate(() => new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
const rendered = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const svg = root.querySelector('.stage svg');
|
||||
const rooms = [...root.querySelectorAll('[data-hp="room"]')];
|
||||
const box = svg?.viewBox?.baseVal;
|
||||
// Каждая комната обязана попадать в кадр: уехавшая за viewBox геометрия
|
||||
// «нарисована» и невидима одновременно — ровно тот случай, когда скриншот
|
||||
// зелёный, а пользователь видит пустой план.
|
||||
const inside = rooms.every((node) => {
|
||||
const bbox = node.getBBox();
|
||||
return box ? bbox.x + bbox.width > box.x - 1 && bbox.x < box.x + box.width + 1
|
||||
&& bbox.y + bbox.height > box.y - 1 && bbox.y < box.y + box.height + 1 : false;
|
||||
});
|
||||
return {
|
||||
rooms: rooms.length,
|
||||
roomsInsideViewBox: inside,
|
||||
openings: root.querySelectorAll('[data-hp="opening"],[data-hp="opening-tunnel"]').length,
|
||||
wallFills: root.querySelectorAll('.wallfill,.wallhatch,[data-hp="wall"]').length,
|
||||
strokeWidths: [...new Set(rooms.map((node) => getComputedStyle(node).strokeWidth))].length,
|
||||
};
|
||||
});
|
||||
|
||||
const thicknessMultiset = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
return (space.walls || []).map((wall) => wall.cm).sort((a, b) => a - b).join(',');
|
||||
});
|
||||
|
||||
const roomPolys = () => page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
return JSON.stringify(space.rooms.map((room) => [room.id, room.poly]));
|
||||
});
|
||||
|
||||
const pointer = (type, planX, planY, { cx, cy, pointerId = 91 } = {}) =>
|
||||
page.evaluate((args) => {
|
||||
const card = window.__card;
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const svg = stage.querySelector('svg');
|
||||
const point = new DOMPoint(args.planX, args.planY).matrixTransform(svg.getScreenCTM());
|
||||
const handles = [...card.renderRoot.querySelectorAll('.rszhandle')];
|
||||
const target = args.cx == null
|
||||
? handles.find((handle) => handle.getAttribute('aria-disabled') === 'false')
|
||||
: handles.find((handle) => Math.abs(Number(handle.getAttribute('cx')) - args.cx) < 1
|
||||
&& Math.abs(Number(handle.getAttribute('cy')) - args.cy) < 1);
|
||||
target?.dispatchEvent(new PointerEvent(args.type, {
|
||||
bubbles: true, cancelable: true, pointerId: args.pointerId,
|
||||
clientX: point.x, clientY: point.y, pointerType: 'mouse',
|
||||
buttons: args.type === 'pointerup' ? 0 : 1,
|
||||
}));
|
||||
return { sent: !!target, handle: target ? [Number(target.getAttribute('cx')), Number(target.getAttribute('cy'))] : null };
|
||||
}, { type, planX, planY, cx, cy, pointerId });
|
||||
|
||||
// --- 1. T-узел с тремя толщинами: рисуется и переживает штатный ресайз -------
|
||||
await loadCorpus(corpus('c2-t-junction-thicknesses'));
|
||||
await enter('plan');
|
||||
await settle();
|
||||
const c2view = await rendered();
|
||||
check('corpus.c2.rooms_rendered', c2view.rooms, 3);
|
||||
check('corpus.c2.rooms_inside_viewbox', c2view.roomsInsideViewBox, true);
|
||||
check('corpus.c2.wall_fills_present', c2view.wallFills > 0, true);
|
||||
const c2thickness = await thicknessMultiset();
|
||||
// Ожидание берётся из самой фикстуры, а не вписывается числом: вписанное число
|
||||
// разошлось бы с корпусом молча, и проверка стала бы декорацией.
|
||||
const c2fixture = corpus('c2-t-junction-thicknesses').spaces[0].walls
|
||||
.map((wall) => wall.cm).sort((a, b) => a - b).join(',');
|
||||
check('corpus.c2.thickness_multiset', c2thickness, c2fixture);
|
||||
check('corpus.c2.thickness_variety', new Set(c2fixture.split(',')).size >= 3, true);
|
||||
|
||||
await enter('resize');
|
||||
await settle();
|
||||
const handles = await page.evaluate(() => [...window.__card.renderRoot.querySelectorAll('.rszhandle')]
|
||||
.filter((handle) => handle.getAttribute('aria-disabled') === 'false').length);
|
||||
check('corpus.c2.enabled_handles', handles > 0, true);
|
||||
const target = await page.evaluate(() => {
|
||||
const handle = [...window.__card.renderRoot.querySelectorAll('.rszhandle')]
|
||||
.find((candidate) => candidate.getAttribute('aria-disabled') === 'false');
|
||||
return handle ? [Number(handle.getAttribute('cx')), Number(handle.getAttribute('cy'))] : null;
|
||||
});
|
||||
const down = await pointer('pointerdown', target[0], target[1], { cx: target[0], cy: target[1] });
|
||||
check('corpus.c2.resize_down_sent', down.sent, true);
|
||||
check('corpus.c2.resize_dragging', await page.evaluate(() => window.__card._resize.dragging), true);
|
||||
const step = 1000 / 240; // один шаг решётки в единицах плана
|
||||
await pointer('pointermove', target[0] + step, target[1], { cx: target[0], cy: target[1] });
|
||||
await settle();
|
||||
await pointer('pointerup', target[0] + step, target[1], { cx: target[0], cy: target[1] });
|
||||
await settle();
|
||||
await page.waitForTimeout(80);
|
||||
const c2after = await thicknessMultiset();
|
||||
// Главный оракул браузерной половины: набор толщин после штатного ресайза тот
|
||||
// же. Именно эту сохранность контроллер ресайза проверяет сам
|
||||
// (checkWallRecordsPreserved с exactMultiplicity) и откатывает жест иначе.
|
||||
check('corpus.c2.thickness_survives_resize', c2after, c2thickness);
|
||||
const c2rendered = await rendered();
|
||||
check('corpus.c2.rooms_after_resize', c2rendered.rooms, 3);
|
||||
check('corpus.c2.inside_viewbox_after_resize', c2rendered.roomsInsideViewBox, true);
|
||||
|
||||
// --- 2. Проём у вершины: рисуется и переживает перечитывание -----------------
|
||||
await loadCorpus(corpus('c4-opening-at-vertex'));
|
||||
await enter('plan');
|
||||
await settle();
|
||||
const c4view = await rendered();
|
||||
check('corpus.c4.rooms_rendered', c4view.rooms, 2);
|
||||
check('corpus.c4.opening_rendered', c4view.openings > 0, true);
|
||||
check('corpus.c4.rooms_inside_viewbox', c4view.roomsInsideViewBox, true);
|
||||
const c4before = await roomPolys();
|
||||
|
||||
// save → reload: тот же конфиг приходит заново, как после перезагрузки вкладки.
|
||||
await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(card._serverCfg);
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
return card.updateComplete;
|
||||
});
|
||||
await settle();
|
||||
check('corpus.c4.geometry_survives_reload', await roomPolys(), c4before);
|
||||
const c4reloaded = await rendered();
|
||||
check('corpus.c4.rooms_after_reload', c4reloaded.rooms, 2);
|
||||
check('corpus.c4.opening_after_reload', c4reloaded.openings > 0, true);
|
||||
|
||||
await finish(browser, { c2view, c2thickness, c2after, c4view, c4reloaded });
|
||||
@@ -0,0 +1,272 @@
|
||||
// Household-сценарии основного View (#560).
|
||||
//
|
||||
// Шесть путей домашнего пользователя, а не шесть проверок функций: открыть
|
||||
// карточку на проснувшемся экране, найти тревогу, прочитать значение, выполнить
|
||||
// разрешённое действие, переключить этаж, вернуться после обновления. У каждого
|
||||
// названы персона, вход, действие и НЕЗАВИСИМЫЙ наблюдаемый оракул: где можно —
|
||||
// не «класс появился», а вызов сервиса, доступное имя или содержимое модели.
|
||||
//
|
||||
// Две ширины, потому что домашний пользователь живёт в двух разных местах:
|
||||
// настенный планшет (штатная ширина демо-стенда, 780 px) и узкая колонка
|
||||
// телефона (390 px) — та самая, на которой ломается первый контакт.
|
||||
//
|
||||
// Граница честности: это синтетический стенд. Реальная установка владельца в
|
||||
// этом заходе читалась только на чтение (профиль в docs/QUALITY-560.md),
|
||||
// добровольцев и физического Companion в проверке не было.
|
||||
import { launch, check, finish, watchPage } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch();
|
||||
const out = {};
|
||||
|
||||
const settle = () => page.evaluate(() => new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
|
||||
/** Геометрия карточки успокоилась: два одинаковых замера подряд (#533). */
|
||||
const settledBox = async (id, attempts = 25) => {
|
||||
let previous = null;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await page.waitForTimeout(120);
|
||||
const now = await page.evaluate((markerId) => {
|
||||
const node = window.__card.renderRoot.querySelector(`[data-hp="device"][data-id="${markerId}"]`);
|
||||
if (!node) return null;
|
||||
const rect = node.getBoundingClientRect();
|
||||
return [Number((rect.x + rect.width / 2).toFixed(2)), Number((rect.y + rect.height / 2).toFixed(2)),
|
||||
Number(rect.width.toFixed(2))];
|
||||
}, id);
|
||||
if (previous && now && previous[0] === now[0] && previous[1] === now[1]) return now;
|
||||
previous = now;
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
|
||||
const setWidth = async (px) => {
|
||||
await page.evaluate(async (width) => {
|
||||
const card = window.__card;
|
||||
card.style.width = `${width}px`;
|
||||
card._lastValidStageSize = null;
|
||||
card._refitView?.();
|
||||
await card.updateComplete;
|
||||
}, px);
|
||||
await settle();
|
||||
};
|
||||
|
||||
// --- J1. «Экран проснулся» ---------------------------------------------------
|
||||
// Персона: домашний пользователь у настенного планшета. Вход: вкладка, которая
|
||||
// никогда не загружала редакторский чанк. Оракул: план показан (вуаль снята),
|
||||
// комнаты и устройства нарисованы, редакторский чанк не запрашивался.
|
||||
const requested = [];
|
||||
page.on('request', (request) => requested.push(new URL(request.url()).pathname));
|
||||
const j1 = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
return {
|
||||
booting: card._booting,
|
||||
rooms: root.querySelectorAll('[data-hp="room"]').length,
|
||||
devices: root.querySelectorAll('[data-hp="device"]').length,
|
||||
stageVisible: getComputedStyle(root.querySelector('.stage')).visibility,
|
||||
};
|
||||
});
|
||||
out.j1 = j1;
|
||||
check('j1.plan_revealed', j1.booting === false && j1.stageVisible === 'visible', true);
|
||||
check('j1.rooms_drawn', j1.rooms > 0, true);
|
||||
check('j1.devices_drawn', j1.devices > 0, true);
|
||||
|
||||
// --- J2. «Где протечка» ------------------------------------------------------
|
||||
// Вход: датчик протечки уходит в тревогу. Оракул независимый: доступное имя
|
||||
// маркера называет состояние, а не только цвет пятна.
|
||||
const j2 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const leak = card._devices.find((device) => device.id === 'd_leak') || card._devices[0];
|
||||
const entity = leak.primary || Object.keys(card.hass.states)[0];
|
||||
card.hass = { ...card.hass, states: { ...card.hass.states, [entity]: { ...card.hass.states[entity], state: 'on' } } };
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const node = card.renderRoot.querySelector(`[data-hp="device"][data-id="${leak.id}"]`);
|
||||
return {
|
||||
id: leak.id,
|
||||
label: node?.getAttribute('aria-label') || '',
|
||||
state: node?.dataset.state || null,
|
||||
alarmClass: node?.classList.contains('alarm') || false,
|
||||
role: node?.getAttribute('role') || null,
|
||||
};
|
||||
});
|
||||
out.j2 = j2;
|
||||
check('j2.alarm_has_accessible_state', /\S/.test(j2.label) && j2.state !== null, true);
|
||||
check('j2.alarm_not_colour_only', j2.label.split(',').length >= 2, true);
|
||||
|
||||
// --- J3. «Сколько в спальне» -------------------------------------------------
|
||||
// Оракул: числовое значение попадает в ДОСТУПНОЕ ИМЯ, а не только в рисунок.
|
||||
const j3 = await page.evaluate(() => {
|
||||
const card = window.__card;
|
||||
const nodes = [...card.renderRoot.querySelectorAll('[data-hp="device"]')];
|
||||
const withValue = nodes
|
||||
.map((node) => ({ id: node.dataset.id, label: node.getAttribute('aria-label') || '' }))
|
||||
.filter((item) => /\d/.test(item.label));
|
||||
return { total: nodes.length, withValue: withValue.length, sample: withValue.slice(0, 2) };
|
||||
});
|
||||
out.j3 = j3;
|
||||
check('j3.values_are_readable_by_name', j3.withValue > 0, true);
|
||||
|
||||
// --- J4. «Выключить свет с клавиатуры» ---------------------------------------
|
||||
// Вход: Tab до маркера на плане, затем Enter. Оракул: вызов сервиса, а не
|
||||
// внутренний флаг карточки.
|
||||
const tabStops = await (async () => {
|
||||
let stops = 0;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await page.keyboard.press('Tab');
|
||||
stops += 1;
|
||||
const reached = await page.evaluate(() => {
|
||||
const leaf = (() => {
|
||||
let node = document.activeElement;
|
||||
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
|
||||
return node;
|
||||
})();
|
||||
return leaf?.dataset?.hp === 'device' ? leaf.dataset.id : null;
|
||||
});
|
||||
if (reached) return { stops, id: reached };
|
||||
}
|
||||
return { stops, id: null };
|
||||
})();
|
||||
const j4 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const calls = [];
|
||||
const hass = card.hass;
|
||||
const original = hass.callService?.bind(hass);
|
||||
card.hass = { ...hass, callService: (...args) => { calls.push(`${args[0]}.${args[1]}`); return original?.(...args); } };
|
||||
await card.updateComplete;
|
||||
const leaf = (() => {
|
||||
let node = document.activeElement;
|
||||
while (node?.shadowRoot?.activeElement) node = node.shadowRoot.activeElement;
|
||||
return node;
|
||||
})();
|
||||
const focusVisible = leaf?.matches?.(':focus-visible') || false;
|
||||
const ring = leaf ? getComputedStyle(leaf).getPropertyValue('--device-ring-color').trim() : null;
|
||||
const idle = card.renderRoot.querySelector('[data-hp="device"]:not(:focus-visible)');
|
||||
const idleRing = idle ? getComputedStyle(idle).getPropertyValue('--device-ring-color').trim() : null;
|
||||
leaf?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
return { calls, focusVisible, ring, idleRing };
|
||||
});
|
||||
out.j4 = { ...j4, ...tabStops };
|
||||
check('j4.marker_reachable_by_tab', tabStops.id !== null, true);
|
||||
check('j4.focus_is_visible', j4.focusVisible && j4.ring !== j4.idleRing, true);
|
||||
check('j4.enter_calls_service', j4.calls.length > 0, true);
|
||||
|
||||
// --- J5. «Переключить этаж» --------------------------------------------------
|
||||
const j5 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = card.renderRoot;
|
||||
const tabs = [...root.querySelectorAll('[data-hp="space-tab"]')];
|
||||
const before = card._space;
|
||||
const other = tabs.find((tab) => tab.dataset.id !== before);
|
||||
const beforeRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
|
||||
other.click();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const afterRooms = [...root.querySelectorAll('[data-hp="room"]')].map((node) => node.dataset.id).join(',');
|
||||
const activeTab = root.querySelector('[data-hp="space-tab"].active');
|
||||
return {
|
||||
tabs: tabs.length, before, after: card._space,
|
||||
roomsChanged: beforeRooms !== afterRooms,
|
||||
activeTabIsCurrent: activeTab?.dataset.id === card._space,
|
||||
// Состояние вкладки для программы: есть ли он вообще, кроме класса.
|
||||
ariaCurrent: activeTab?.getAttribute('aria-current'),
|
||||
ariaPressed: activeTab?.getAttribute('aria-pressed'),
|
||||
ariaSelected: activeTab?.getAttribute('aria-selected'),
|
||||
};
|
||||
});
|
||||
out.j5 = j5;
|
||||
check('j5.floor_switched', j5.after !== j5.before, true);
|
||||
check('j5.plan_rerendered', j5.roomsChanged, true);
|
||||
check('j5.active_tab_matches_space', j5.activeTabIsCurrent, true);
|
||||
|
||||
// --- J6. «Вернулся после обновления» ----------------------------------------
|
||||
const j6 = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const chosen = card._space;
|
||||
card._serverCfg = structuredClone(card._serverCfg);
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return {
|
||||
chosen, after: card._space,
|
||||
rooms: card.renderRoot.querySelectorAll('[data-hp="room"]').length,
|
||||
devices: card.renderRoot.querySelectorAll('[data-hp="device"]').length,
|
||||
};
|
||||
});
|
||||
out.j6 = j6;
|
||||
check('j6.space_kept', j6.after === j6.chosen, true);
|
||||
check('j6.plan_redrawn', j6.rooms > 0 && j6.devices > 0, true);
|
||||
|
||||
// --- J7. Цель нажатия при плотной расстановке --------------------------------
|
||||
// Вход: пять маркеров в колонке с шагом 0,0333 нормированной единицы — самая
|
||||
// плотная расстановка, найденная на реальной установке владельца.
|
||||
// Оракул: в центре ВИДИМОГО маркера нажатие достаётся ему же.
|
||||
const dense = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const space = card._serverCfg.spaces.find((candidate) => candidate.id === card._space);
|
||||
const room = space.rooms.find((candidate) => candidate.id && candidate.area) || space.rooms[0];
|
||||
const ids = ['dense1', 'dense2', 'dense3', 'dense4', 'dense5'];
|
||||
card._serverCfg.markers = [...(card._serverCfg.markers || []), ...ids.map((id, index) => ({
|
||||
id, name: `Dense ${index + 1}`, binding: 'virtual', is_light: true, tap_action: 'toggle',
|
||||
space: space.id, area: room.area, room_id: room.id,
|
||||
}))];
|
||||
const layout = { ...card._layout };
|
||||
ids.forEach((id, index) => { layout[id] = { s: space.id, x: 0.5, y: 0.3 + index * 0.0333 }; });
|
||||
card._layout = layout;
|
||||
card._cfgEpoch++;
|
||||
card._regSignature = '';
|
||||
card._maybeRebuildDevices();
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
window.__journeyHits = [];
|
||||
const original = card._clickDevice?.bind(card);
|
||||
if (original) {
|
||||
card._clickDevice = (event, device) => { window.__journeyHits.push(device?.id || null); return original(event, device); };
|
||||
}
|
||||
return { ids };
|
||||
});
|
||||
|
||||
const ownerOfCentre = async (id) => {
|
||||
const centre = await settledBox(id);
|
||||
if (!centre) return null;
|
||||
return page.evaluate(([cx, cy]) => {
|
||||
let node = document.elementFromPoint(cx, cy);
|
||||
while (node?.shadowRoot?.elementFromPoint) {
|
||||
const deeper = node.shadowRoot.elementFromPoint(cx, cy);
|
||||
if (!deeper || deeper === node) break;
|
||||
node = deeper;
|
||||
}
|
||||
return node?.closest?.('[data-hp="device"]')?.dataset?.id || null;
|
||||
}, [centre[0], centre[1]]);
|
||||
};
|
||||
|
||||
const tablet = { size: (await settledBox('dense1'))?.[2] ?? null, owners: [] };
|
||||
for (const id of dense.ids) tablet.owners.push(await ownerOfCentre(id));
|
||||
const tabletCentre = await settledBox('dense1');
|
||||
await page.mouse.click(tabletCentre[0], tabletCentre[1]);
|
||||
await page.waitForTimeout(200);
|
||||
tablet.clickWentTo = await page.evaluate(() => window.__journeyHits.at(-1) || null);
|
||||
out.j7_tablet = tablet;
|
||||
// Настенный планшет: центр видимого маркера принадлежит ему. Это то свойство,
|
||||
// которое правка плотных целей обязана сохранить.
|
||||
check('j7.tablet_centre_belongs_to_its_marker',
|
||||
tablet.owners.every((owner, index) => owner === dense.ids[index]), true);
|
||||
check('j7.tablet_click_reaches_its_marker', tablet.clickWentTo, 'dense1');
|
||||
|
||||
// Узкая колонка телефона: тот же замер печатается в отчёт БЕЗ проверки —
|
||||
// расхождение здесь заведено отдельным issue, и свидетель приедет с правкой.
|
||||
// Вписывать текущее поведение проверкой значило бы узаконить дефект.
|
||||
await setWidth(390);
|
||||
const phone = { size: (await settledBox('dense1'))?.[2] ?? null, owners: [] };
|
||||
for (const id of dense.ids) phone.owners.push(await ownerOfCentre(id));
|
||||
const phoneCentre = await settledBox('dense1');
|
||||
const before = await page.evaluate(() => window.__journeyHits.length);
|
||||
await page.mouse.click(phoneCentre[0], phoneCentre[1]);
|
||||
await page.waitForTimeout(200);
|
||||
phone.clickWentTo = await page.evaluate((from) => window.__journeyHits.slice(from).at(-1) || null, before);
|
||||
out.j7_phone = phone;
|
||||
console.log('j7.phone (без проверки, см. docs/QUALITY-560.md):', JSON.stringify(phone));
|
||||
|
||||
await finish(browser, out);
|
||||
@@ -0,0 +1,201 @@
|
||||
# Полевая проверка View, аудит доступности и геометрический корпус (#560)
|
||||
|
||||
Дерево проверки: `dev` на 13.09.2026 (после #556). Всё, помеченное
|
||||
«проверено исполнением», снято прогоном на этом дереве; всё остальное
|
||||
помечено как чтение или арифметика.
|
||||
|
||||
## 0. Что было и чего не было
|
||||
|
||||
Было: чтение реальной установки владельца (только чтение, по его прямому
|
||||
разрешению), синтетический стенд `demo/` в двух ширинах, узловые тесты модели.
|
||||
|
||||
**Не было, и это не оговорка задним числом:** добровольцев кроме владельца,
|
||||
физического Companion на телефоне/планшете, записи в реальную установку,
|
||||
проверки со скринридером и с симуляцией цветовой слепоты. Пункты, которые без
|
||||
этого не проверяются, названы ниже гипотезами, а не находками.
|
||||
|
||||
## 1. Профиль реального плана (чтение, 13.09.2026)
|
||||
|
||||
Модель v10, `rev` 2740, интеграция 1.76.0-beta.1.
|
||||
|
||||
| что | сколько |
|
||||
|---|---|
|
||||
| пространств / комнат | 5 / 37 |
|
||||
| записей толщины / сегментов стен | 121 / — |
|
||||
| проёмов / перегородок / колонн | 35 / 7 / 2 |
|
||||
| маркеров | 156 |
|
||||
| шаг сетки по пространствам | 5, 1, 30, 1, 1 см |
|
||||
| толщины стен в одном пространстве | 20/22/28/29/33 · 20/30/40 · 4/15/17/33 · 10/15 |
|
||||
| минимальное ребро комнаты | 5 см (шаг 1 см) и 10 см (шаг 5 см) |
|
||||
| T-узлов по пространствам | 7 / 6 / 3 / 2 / 0 |
|
||||
| X-узлов (степень ≥ 4) | **ни одного** |
|
||||
| координат всего / с шумом ближе 1e-4 шага | 1010 / 23 |
|
||||
| почти осевых стен (`classifyNearAxisSegment`) | 0 |
|
||||
| проёмов, чей край лежит ровно в вершине комнаты | 1 |
|
||||
| записей `layout` на УДАЛЁННЫЕ пространства | 4 (на 2 пространства) |
|
||||
| ссылок на сущности HA в маркерах и проёмах | 42 |
|
||||
| из проверенных 25 ссылок не существует в HA | 1 |
|
||||
|
||||
Инварианты модели на этом конфиге чистые: `node scripts/model-invariants.mjs`
|
||||
сообщает «ссылки разрешимы, записи толщины находятся».
|
||||
|
||||
**Геометрия владельца в репозиторий не попала.** Фикстуры корпуса
|
||||
синтетические и воспроизводят перечисленные условия числами. Это и есть
|
||||
обезличивание: в репозитории нет ни плана дома, ни имён комнат, ни
|
||||
идентификаторов сущностей.
|
||||
|
||||
## 2. Household-сценарии
|
||||
|
||||
Стенд: `demo/smoke_household_journeys.mjs`, штатная ширина карточки 780 px
|
||||
(настенный планшет) и 390 px (узкая колонка телефона). У каждого пути назван
|
||||
независимый оракул — не «класс появился», а вызов сервиса, доступное имя или
|
||||
содержимое модели.
|
||||
|
||||
| путь | вход | оракул | результат |
|
||||
|---|---|---|---|
|
||||
| J1 «экран проснулся» | вкладка, не грузившая редакторский чанк | `_booting === false`, `.stage` видима, комнат 4, устройств 10 | пройден |
|
||||
| J2 «где протечка» | датчик протечки уходит в `on` | доступное имя называет состояние (`Sink leak sensor, Alarm, …`), `data-state="alarm"` | пройден, см. F4 |
|
||||
| J3 «сколько в спальне» | температурный маркер со значением | значение попадает в `aria-label` (`Temperature: 22.4°`), 7 из 10 маркеров несут число в имени | пройден |
|
||||
| J4 «выключить свет с клавиатуры» | Tab до маркера, затем Enter | вызван `light.turn_off`; кольцо фокуса `#0C82F0` против `transparent` у остальных | пройден; до первого маркера 15 остановок Tab |
|
||||
| J5 «переключить этаж» | клик по вкладке пространства | `_space` сменился, набор комнат перерисован, активная вкладка соответствует пространству | пройден, см. F3 |
|
||||
| J6 «вернулся после обновления» | конфиг приходит заново | выбранное пространство сохранено, план и устройства нарисованы | пройден |
|
||||
| J7 «нажать нужный светильник» | пять маркеров с шагом 0,0333 — самая плотная расстановка из поля | в центре ВИДИМОГО маркера нажатие достаётся ему же | 780 px — пройден; **390 px — не пройден, F1** |
|
||||
|
||||
## 3. Аудит доступности текущего View (проверено исполнением)
|
||||
|
||||
Что уже сделано правильно и должно остаться:
|
||||
|
||||
- маркер устройства с действием — `role="button"`, `tabindex="0"`, осмысленное
|
||||
`aria-label` с названием, состоянием и значением;
|
||||
- Enter и Space на маркере действительно вызывают действие (`_keyDevice` →
|
||||
`_clickDevice` → `light.turn_off` в прогоне);
|
||||
- фокус видим: `:focus-visible` красит кольцо `#0C82F0` (у остальных
|
||||
`transparent`), скриншот вокруг маркера меняется;
|
||||
- зона нажатия увеличена сознательно: `.dev::before` — круг
|
||||
`max(44px, --device-shell-size)`, то есть 44 px при любом размере маркера.
|
||||
Это выше минимума WCAG 2.5.8 (24 px) — и именно это порождает F1 при
|
||||
плотной расстановке.
|
||||
|
||||
Измеренные проблемы — F1…F5 ниже.
|
||||
|
||||
## 4. Геометрический корпус
|
||||
|
||||
`test/fixtures/560-corpus/` — шесть планов; `test/geometry-corpus.test.mjs` —
|
||||
26 проверок; `demo/smoke_geometry_corpus.mjs` — браузерная половина.
|
||||
|
||||
| фикстура | условие | откуда взято |
|
||||
|---|---|---|
|
||||
| `c1-short-edges` | уступ в два шага (10 см при шаге 5 см) | минимальное ребро поля |
|
||||
| `c2-t-junction-thicknesses` | T-узел, где сходятся три разные толщины | 7 T-узлов и 5 толщин в поле |
|
||||
| `c3-x-junction` | четыре комнаты в одной точке | **синтетика: в поле X-узлов нет** |
|
||||
| `c4-opening-at-vertex` | край проёма ровно в вершине комнаты | один такой проём в поле |
|
||||
| `c5-node-noise-near-axis` | шум у узла плюс честно наклонная стена | 23 шумных координаты в поле |
|
||||
| `c6-stale-layout` | `layout` на удалённое пространство | 4 таких записи в поле |
|
||||
|
||||
Цепочка на каждом плане: импорт → инварианты → Optimize → инварианты →
|
||||
Optimize → Resize → Optimize → сохранение и перечитывание. Оракулы численные:
|
||||
|
||||
- нарушений инвариантов ровно столько, сколько заявлено (для c1–c5 — ноль);
|
||||
- Optimize не меняет вход (immutability, как в #281);
|
||||
- приведённый план Optimize не трогает (`changed === false`), а второй проход
|
||||
не двигает ничего никогда — фикс-пойнт #477;
|
||||
- записи толщины целы (`checkWallRecordsPreserved`);
|
||||
- ключи записей канонические (`wallKey` пересчитывается независимо);
|
||||
- **наклонная стена не выпрямляется**: `classifyNearAxisSegment` не относит
|
||||
наклон 1 шаг на 60 к почти осевым, и Optimize оставляет её как есть. Это
|
||||
прямая проверка приёмки «корректная планировка не исправляется ради удобства
|
||||
рендера»; шум у узла, наоборот, снимается — и это правильно.
|
||||
|
||||
Рендер эталоном не является: браузерная половина проверяет присутствие и число
|
||||
узлов (комнаты, проём, заливка стен), попадание геометрии в `viewBox` и
|
||||
сохранность набора толщин после ШТАТНОГО ресайза — то, что чистыми функциями
|
||||
не проверяется, потому что перенос записей толщины делает хост карточки
|
||||
(`project()` в `ResizeController.move`), а не `resize.ts`.
|
||||
|
||||
**Граница узловой половины названа прямо:** вход её шага Resize — «рёбра
|
||||
сдвинуты, записи толщины на прежних координатах», состояние, которого штатный
|
||||
writer не создаёт. Поэтому там проверяется сходимость Optimize за ≤ 2 прохода,
|
||||
а не «за один».
|
||||
|
||||
## 5. Находки
|
||||
|
||||
### F1 (High) — в узкой колонке нажатие достаётся соседнему маркеру
|
||||
|
||||
Проверено исполнением на осевшей геометрии (замер повторяется до двух
|
||||
одинаковых подряд — правило #533).
|
||||
|
||||
При ширине карточки 390 px маркеры имеют размер 10,84 px, а расстояние между
|
||||
центрами при самой плотной расстановке поля (шаг 0,0333) — 11,8 px. Зона
|
||||
нажатия каждого маркера — круг 44 px (`.dev::before`, `pointer-events: auto`,
|
||||
`z-index: 3`), то есть ±22 px от центра. Круг соседа накрывает центр маркера
|
||||
целиком, и выигрывает тот, кто выше по порядку.
|
||||
|
||||
Что измерено: центр `dense1` (203; 461,08) принадлежит `dense2`
|
||||
(`elementFromPoint` и реальный `page.mouse.click` — обоими). Для пяти маркеров
|
||||
владельцы центров: `dense2, dense3, dense4, dense5, dense5`. То есть **ни один
|
||||
из первых четырёх маркеров нельзя нажать в его собственном центре**.
|
||||
|
||||
Порог: расхождение начинается, когда расстояние между центрами меньше 22 px
|
||||
(половина фиксированного круга). На 780 px тот же план даёт 23,6 px между
|
||||
центрами — проверка проходит, но запас 1,6 px.
|
||||
|
||||
Почему это важно: J3 «безопасно действовать» — на телефоне пользователь
|
||||
включает не тот свет, и никакого признака ошибки нет.
|
||||
|
||||
### F2 (Medium) — подписи комнат: контраст 2,01:1 при норме 4,5:1
|
||||
|
||||
Тёмная тема по умолчанию: цвет подписи `rgb(85,96,108)` на фоне стейджа
|
||||
`rgb(28,37,48)`, `opacity: 0.8`, размер 12,05 px. Контраст по чистому цвету
|
||||
2,41:1, с учётом смешивания полупрозрачности — **2,01:1**. WCAG 1.4.3 (AA) для
|
||||
такого размера требует 4,5:1.
|
||||
|
||||
Механизм: цвет подписи — это цвет комнаты (`disp.color`), а прозрачность —
|
||||
`min(1, room_opacity + 0.25)`. Отдельного токена для текста плана нет, поэтому
|
||||
подпись нельзя сделать читаемой, не перекрасив сами комнаты.
|
||||
|
||||
### F3 (Medium) — активная вкладка этажа не имеет программного состояния
|
||||
|
||||
`<button class="tab active">` не несёт ни `aria-current`, ни `aria-pressed`,
|
||||
ни `aria-selected` (проверено чтением атрибутов в прогоне: все три `null`), а
|
||||
полоса вкладок — обычный `div`, не `tablist`. Какой этаж открыт, сообщается
|
||||
только классом, то есть видом. WCAG 4.1.2.
|
||||
|
||||
### F4 (Low) — состояние в доступном имени повторяется дважды
|
||||
|
||||
`Sink leak sensor, Alarm, Alarm, LQI 117, medium signal` — слово состояния
|
||||
попадает в имя из двух источников. Скринридер произносит его дважды.
|
||||
|
||||
### F5 (Low) — подсказка с названием и метриками не показывается при фокусе
|
||||
|
||||
Один и тот же узел подсказки: при `pointerover` — `display: block` и текст
|
||||
«Ceiling light / Smart Bulb E27», при клавиатурном фокусе — `display: none`.
|
||||
Данные не теряются для скринридера (они есть в `aria-label`), но зрячий
|
||||
пользователь клавиатуры их не получает.
|
||||
|
||||
### F6 (Low) — записи `layout` на удалённые пространства переживают Optimize
|
||||
|
||||
Проверено на `c6-stale-layout`: Optimize видит их (`positionsUnresolved: 2`),
|
||||
но оставляет, и `npm run invariants` на экспорте пользователя сообщает о них
|
||||
как о нарушениях. В поле таких записей четыре на два удалённых пространства.
|
||||
Вреда пользователю нет; цена — ложный сигнал при будущем разборе.
|
||||
|
||||
## 6. Гипотезы UX (не находки, проверять с людьми)
|
||||
|
||||
- **U1.** Проём, чей `contact` удалён из HA, рисуется ОТКРЫТЫМ:
|
||||
`openingAmount` при отсутствующем состоянии возвращает 1 для дверей и
|
||||
калиток (для окон 0). Это отказ в безопасную сторону, но признака «состояние
|
||||
неизвестно» нет. В поле ровно один такой случай: калитка ссылается на
|
||||
`binary_sensor`, которого в HA больше не существует (404) — то есть на плане
|
||||
владельца калитка нарисована открытой постоянно.
|
||||
- **U2.** До первого маркера на плане 15 остановок Tab: сначала весь хром.
|
||||
- **U3.** Состояние маркера зрительно передаётся заливкой (`#F0A00C` против
|
||||
светлой/тёмной) при одинаковой иконке и размере. Светимости различаются
|
||||
сильно, поэтому нарушения 1.4.1 здесь скорее нет, но без проверки с
|
||||
симуляцией цветовой слепоты это гипотеза.
|
||||
|
||||
## 7. Что заведено отдельно
|
||||
|
||||
Продуктовые правки в этой задаче не делались — она research/tests-only.
|
||||
F1, F2–F5 и F6 заведены отдельными issue со ссылкой на #560; свидетель к
|
||||
каждой приедет вместе с правкой, а не поселится здесь зелёной проверкой,
|
||||
узаконивающей дефект.
|
||||
@@ -72,6 +72,50 @@ function relocateEditorPatch(patch, cardSource, editorSource) {
|
||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||
const MUTANT_DEFINITIONS = [
|
||||
{
|
||||
id: 'household-marker-drops-keyboard-reach',
|
||||
guard: 'node demo/smoke_household_journeys.mjs',
|
||||
because: '#560 J4: маркер с действием обязан доставаться клавиатурой — без tabindex он '
|
||||
+ 'остаётся нажимаемым мышью, и обычный прогон этого не замечает',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " tabindex=${interactive ? '0' : nothing}\n aria-label=${deviceAriaLabel}",
|
||||
replace: ' aria-label=${deviceAriaLabel}',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'household-enter-stops-acting',
|
||||
guard: 'node demo/smoke_household_journeys.mjs',
|
||||
because: '#560 J4: Enter и Space на маркере обязаны вызывать действие; фокус и роль при '
|
||||
+ 'этом остаются на месте, поэтому дефект виден только вызовом сервиса',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " if (ev.key !== 'Enter' && ev.key !== ' ') return;",
|
||||
replace: ' return;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'corpus-loses-its-short-edge',
|
||||
guard: 'node --test --test-name-pattern="c1-short-edges" test/geometry-corpus.test.mjs',
|
||||
because: '#560: фикстура, потерявшая своё условие, — это зелёный прогон, который ничего '
|
||||
+ 'не проверяет; корпус обязан замечать такую потерю, а не считать дальше',
|
||||
patches: [{
|
||||
file: 'test/fixtures/560-corpus/c1-short-edges.json',
|
||||
find: ' 0.24166666666666667,\n 0.15833333333333333\n ],\n [\n 0.24166666666666667,',
|
||||
replace: ' 0.2,\n 0.15833333333333333\n ],\n [\n 0.2,',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimize-reports-work-it-did-not-do',
|
||||
guard: 'node --test --test-name-pattern="импорт" test/geometry-corpus.test.mjs',
|
||||
because: '#477/#560: приведённый план Optimize трогать не должен; вечное changed=true '
|
||||
+ 'заново пишет конфиг, бьёт rev и множит записи истории на каждом нажатии',
|
||||
patches: [{
|
||||
file: 'src/plan-optimizer.ts',
|
||||
find: ' const changed = JSON.stringify(persistedConfig) !== original\n || JSON.stringify(persistedLayout) !== originalLayout;',
|
||||
replace: ' const changed = true;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'summary-hide-unmounts-before-animation',
|
||||
guard: 'node demo/smoke_summary_panel_polish.mjs',
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"model_version": 10,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "c1",
|
||||
"title": "c1",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "c1-r1",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.15833333333333333
|
||||
],
|
||||
[
|
||||
0.24166666666666667,
|
||||
0.15833333333333333
|
||||
],
|
||||
[
|
||||
0.24166666666666667,
|
||||
0.16666666666666666
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c1-w1",
|
||||
"c1-w2",
|
||||
"c1-w3",
|
||||
"c1-w4",
|
||||
"c1-w5",
|
||||
"c1-w6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.000000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.120833,0.166667@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.24166666666666667,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.241667,0.162500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.24166666666666667,
|
||||
0.15833333333333333
|
||||
],
|
||||
"b": [
|
||||
0.24166666666666667,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.245833,0.158333@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.24166666666666667,
|
||||
0.15833333333333333
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.15833333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.079167@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.15833333333333333
|
||||
]
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "c1-w6",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c1-w1",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c1-w5",
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.24166666666666667,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c1-w4",
|
||||
"a": [
|
||||
0.24166666666666667,
|
||||
0.15833333333333333
|
||||
],
|
||||
"b": [
|
||||
0.24166666666666667,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c1-w3",
|
||||
"a": [
|
||||
0.24166666666666667,
|
||||
0.15833333333333333
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.15833333333333333
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c1-w2",
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.15833333333333333
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"partitions": [],
|
||||
"openings": []
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
{
|
||||
"model_version": 10,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "c2",
|
||||
"title": "c2",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "c2-nw",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.25
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c2-w1",
|
||||
"c2-w2",
|
||||
"c2-w3",
|
||||
"c2-w4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c2-sw",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.125
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c2-w5",
|
||||
"c2-w6",
|
||||
"c2-w1",
|
||||
"c2-w7"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c2-east",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.25
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.125
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c2-w8",
|
||||
"c2-w9",
|
||||
"c2-w10",
|
||||
"c2-w2",
|
||||
"c2-w6"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.000000,0.062500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.000000,0.187500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.125000@0.0000",
|
||||
"cm": 22,
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.250000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.062500@1.5706",
|
||||
"cm": 33,
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.187500,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.187500@1.5706",
|
||||
"cm": 33,
|
||||
"a": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.187500,0.250000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.125000@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
]
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "c2-w7",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c2-w5",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c2-w4",
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c2-w1",
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"cm": 22
|
||||
},
|
||||
{
|
||||
"id": "c2-w3",
|
||||
"a": [
|
||||
0,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c2-w6",
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"cm": 33
|
||||
},
|
||||
{
|
||||
"id": "c2-w8",
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c2-w2",
|
||||
"a": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"cm": 33
|
||||
},
|
||||
{
|
||||
"id": "c2-w10",
|
||||
"a": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c2-w9",
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"partitions": [],
|
||||
"openings": []
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
{
|
||||
"model_version": 10,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "c3",
|
||||
"title": "c3",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "c3-sw",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.125
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c3-w1",
|
||||
"c3-w2",
|
||||
"c3-w3",
|
||||
"c3-w4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c3-se",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.125
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c3-w5",
|
||||
"c3-w6",
|
||||
"c3-w7",
|
||||
"c3-w2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c3-nw",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.25
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c3-w3",
|
||||
"c3-w8",
|
||||
"c3-w9",
|
||||
"c3-w10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c3-ne",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.125
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.25
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.25
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c3-w7",
|
||||
"c3-w11",
|
||||
"c3-w12",
|
||||
"c3-w8"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.000000,0.062500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.000000,0.187500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.125000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.250000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.062500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.187500,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.187500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.187500,0.125000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.187500,0.250000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.062500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.187500@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
]
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "c3-w4",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w1",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w10",
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w3",
|
||||
"a": [
|
||||
0,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w9",
|
||||
"a": [
|
||||
0,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w2",
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w5",
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w8",
|
||||
"a": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w7",
|
||||
"a": [
|
||||
0.125,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.125
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w12",
|
||||
"a": [
|
||||
0.125,
|
||||
0.25
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w6",
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.125
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c3-w11",
|
||||
"a": [
|
||||
0.25,
|
||||
0.125
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.25
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"partitions": [],
|
||||
"openings": []
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
{
|
||||
"model_version": 10,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "c4",
|
||||
"title": "c4",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "c4-left",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c4-w1",
|
||||
"c4-w2",
|
||||
"c4-w3",
|
||||
"c4-w4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c4-right",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
[
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c4-w5",
|
||||
"c4-w6",
|
||||
"c4-w7",
|
||||
"c4-w2"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.000000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.062500,0.166667@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.187500,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.187500,0.166667@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
]
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "c4-w4",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c4-w1",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c4-w3",
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c4-w2",
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c4-w5",
|
||||
"a": [
|
||||
0.125,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c4-w7",
|
||||
"a": [
|
||||
0.125,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c4-w6",
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"partitions": [],
|
||||
"openings": [
|
||||
{
|
||||
"id": "c4-o1",
|
||||
"type": "door",
|
||||
"x": 0.125,
|
||||
"y": 0.025,
|
||||
"angle": -90,
|
||||
"length": 0.05,
|
||||
"contact": null,
|
||||
"lock": null,
|
||||
"flip_h": false,
|
||||
"host": {
|
||||
"kind": "wall",
|
||||
"id": "c4-w2",
|
||||
"t": 0.15
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"model_version": 10,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "c5",
|
||||
"title": "c5",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "c5-r1",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
2.0833333333333333e-7
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c5-w1",
|
||||
"c5-w2",
|
||||
"c5-w3",
|
||||
"c5-w4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "c5-r2",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0.20833333333333334
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.2125
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.3333333333333333
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c5-w5",
|
||||
"c5-w6",
|
||||
"c5-w7",
|
||||
"c5-w8"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.125000,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
2.0833333333333333e-7
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
2.0833333333333333e-7
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.166667@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.000000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.212500@0.0167",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.20833333333333334
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.2125
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.275000@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0.2125
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.333333@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.3333333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.000000,0.270833@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.20833333333333334
|
||||
]
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "c5-w1",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
2.0833333333333333e-7
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c5-w2",
|
||||
"a": [
|
||||
0.25,
|
||||
2.0833333333333333e-7
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c5-w3",
|
||||
"a": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c5-w4",
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c5-w5",
|
||||
"a": [
|
||||
0,
|
||||
0.20833333333333334
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.2125
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c5-w6",
|
||||
"a": [
|
||||
0.25,
|
||||
0.2125
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c5-w7",
|
||||
"a": [
|
||||
0.25,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.3333333333333333
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c5-w8",
|
||||
"a": [
|
||||
0,
|
||||
0.3333333333333333
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.20833333333333334
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"partitions": [],
|
||||
"openings": []
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"config": {
|
||||
"model_version": 10,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "c6",
|
||||
"title": "c6",
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "c6-r1",
|
||||
"name": null,
|
||||
"area": null,
|
||||
"poly": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
[
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
[
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
],
|
||||
"wall_ids": [
|
||||
"c6-w1",
|
||||
"c6-w2",
|
||||
"c6-w3",
|
||||
"c6-w4"
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.125000,0.000000@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.250000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.125000,0.166667@0.0000",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.000000,0.083333@1.5706",
|
||||
"cm": 20,
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
],
|
||||
"wall_segments": [
|
||||
{
|
||||
"id": "c6-w1",
|
||||
"a": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c6-w2",
|
||||
"a": [
|
||||
0.25,
|
||||
0
|
||||
],
|
||||
"b": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c6-w3",
|
||||
"a": [
|
||||
0.25,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "c6-w4",
|
||||
"a": [
|
||||
0,
|
||||
0.16666666666666666
|
||||
],
|
||||
"b": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"partitions": [],
|
||||
"openings": []
|
||||
}
|
||||
],
|
||||
"markers": [
|
||||
{
|
||||
"id": "c6-m1",
|
||||
"binding": "virtual",
|
||||
"name": null,
|
||||
"space": "c6",
|
||||
"room_id": "c6-r1"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
},
|
||||
"layout": {
|
||||
"c6-m1": {
|
||||
"s": "c6",
|
||||
"x": 0.3,
|
||||
"y": 0.4
|
||||
},
|
||||
"c6-orphan-1": {
|
||||
"s": "c6-deleted",
|
||||
"x": 0.5,
|
||||
"y": 0.5
|
||||
},
|
||||
"c6-orphan-2": {
|
||||
"s": "c6-deleted",
|
||||
"x": 0.6,
|
||||
"y": 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Геометрический regression-корпус пользовательских планов (#560).
|
||||
//
|
||||
// Зачем он отдельно от 246 смоков и сотни модельных тестов. Каждый прежний
|
||||
// тест ставит одно условие и проверяет одно следствие. Полевой план так не
|
||||
// живёт: в нём короткие рёбра, T-узлы, пять разных толщин в одном
|
||||
// пространстве, проём, упирающийся в вершину, и шум у узлов — одновременно, и
|
||||
// проходит он не одну операцию, а цепочку import → Optimize → Optimize →
|
||||
// Resize → save/reload → export. Ломается согласованность как раз на стыках
|
||||
// (#253, #244, #252, #258), и ловил их до сих пор человек глазами.
|
||||
//
|
||||
// Оракулы здесь ЧИСЛЕННЫЕ и не зависят от рендера: инварианты модели,
|
||||
// сходимость Optimize к фикс-пойнту, сохранность записей толщины, равенство
|
||||
// после сериализации. Это прямое требование приёмки #560: корпус не имеет
|
||||
// права принимать текущий рендер за эталон, иначе он узаконит любой регресс,
|
||||
// который рендер отрисовал «похоже».
|
||||
//
|
||||
// Профиль поля, по которому подобраны условия (прочитан 13.09.2026 на
|
||||
// установке владельца, только чтение, модель v10, rev 2740): 5 пространств,
|
||||
// 37 комнат, 121 запись толщины, 35 проёмов, 7 перегородок, 156 маркеров;
|
||||
// толщины по пространствам 20/22/28/29/33, 20/30/40, 4/15/17/33, 10/15;
|
||||
// минимальное ребро комнаты 5 см при шаге сетки 1 см и 10 см при шаге 5 см;
|
||||
// T-узлов 7/6/3/2 по пространствам, X-узлов НИ ОДНОГО; координат 1010, из них
|
||||
// 23 с шумом ближе 1e-4 шага; почти осевых стен 0; один проём, чей край лежит
|
||||
// ровно в вершине комнаты; в layout четыре записи на два УДАЛЁННЫХ
|
||||
// пространства. Сама геометрия владельца в репозиторий не попадает: фикстуры
|
||||
// синтетические и воспроизводят названные условия числами, а не планом дома.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { applySafeResize, clampSafeResize, resolveSafeResize } from '../test-build/resize.js';
|
||||
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
|
||||
import { thicknessCmAt, wallCmToUnits } from '../test-build/wall-thickness.js';
|
||||
import { classifyNearAxisSegment } from '../test-build/near-axis.js';
|
||||
import {
|
||||
checkReferences, checkWallKeys, checkMixedRoleRecords, checkHiddenObstacles,
|
||||
checkPhysicalGeometry, checkWallRecordsPreserved, latticeProfile, wallKey,
|
||||
} from '../scripts/model-invariants.mjs';
|
||||
|
||||
const STEP = 1 / 240; // шаг решётки в нормированных единицах
|
||||
const clone = (value) => structuredClone(value);
|
||||
|
||||
const load = (name) => {
|
||||
const raw = JSON.parse(readFileSync(
|
||||
new URL(`./fixtures/560-corpus/${name}.json`, import.meta.url), 'utf8',
|
||||
));
|
||||
return { config: raw.config ?? raw, layout: raw.layout ?? {} };
|
||||
};
|
||||
|
||||
/** Все нарушения инвариантов одним списком — тот же набор, что у CLI (#254). */
|
||||
const violations = (config, layout = {}) => [
|
||||
...checkReferences({ config, layout }),
|
||||
...checkWallKeys(config),
|
||||
...checkMixedRoleRecords(config),
|
||||
...checkHiddenObstacles(config),
|
||||
...checkPhysicalGeometry(config),
|
||||
];
|
||||
|
||||
const kinds = (list) => list.map((item) => `${item.invariant}/${item.kind}`).sort();
|
||||
|
||||
/** Степени узлов: сколько рёбер комнат приходит в каждую точку. */
|
||||
function nodeDegrees(space) {
|
||||
const degree = new Map();
|
||||
for (const room of space.rooms || []) {
|
||||
for (const point of room.poly || []) {
|
||||
const key = `${point[0].toFixed(9)},${point[1].toFixed(9)}`;
|
||||
degree.set(key, (degree.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return degree;
|
||||
}
|
||||
|
||||
/** Длины рёбер комнат в шагах решётки. */
|
||||
function edgeSteps(space) {
|
||||
const out = [];
|
||||
for (const room of space.rooms || []) {
|
||||
const poly = room.poly || [];
|
||||
for (let index = 0; index < poly.length; index++) {
|
||||
const a = poly[index];
|
||||
const b = poly[(index + 1) % poly.length];
|
||||
out.push(Math.hypot(b[0] - a[0], b[1] - a[1]) / STEP);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Точки проёма: центр ± половина длины по его углу (angle в градусах). */
|
||||
function openingEnds(opening) {
|
||||
const radians = (opening.angle || 0) * Math.PI / 180;
|
||||
const half = (opening.length || 0) / 2;
|
||||
const dx = Math.cos(radians) * half;
|
||||
const dy = Math.sin(radians) * half;
|
||||
return [[opening.x - dx, opening.y - dy], [opening.x + dx, opening.y + dy]];
|
||||
}
|
||||
|
||||
const roomVertices = (space) => (space.rooms || []).flatMap((room) => room.poly || []);
|
||||
|
||||
const near = (a, b, eps = 1e-9) => Math.hypot(a[0] - b[0], a[1] - b[1]) < eps;
|
||||
|
||||
/** Вход Resize в единицах NORM_W — та же подготовка, что у #281. */
|
||||
const resizeInputs = (space) => ({
|
||||
rooms: (space.rooms || []).map((room) => ({
|
||||
id: room.id, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
|
||||
})),
|
||||
openings: (space.openings || []).map((opening) => ({
|
||||
id: opening.id,
|
||||
x: opening.x * NORM_W,
|
||||
y: opening.y * NORM_W,
|
||||
length: opening.length * NORM_W,
|
||||
angle: opening.angle,
|
||||
hosted: !!opening.host,
|
||||
})),
|
||||
obstacles: (space.partitions || []).map((partition) => ({
|
||||
kind: 'segment',
|
||||
a: partition.a.map((value) => value * NORM_W),
|
||||
b: partition.b.map((value) => value * NORM_W),
|
||||
half: wallCmToUnits(partition.cm, space.cell_cm, GRID_PITCH) / 2,
|
||||
})),
|
||||
});
|
||||
|
||||
const resizeOptions = (space, inputs, cm) => ({
|
||||
minDim: wallCmToUnits(30, space.cell_cm, GRID_PITCH),
|
||||
eps: GRID_PITCH * 0.05,
|
||||
step: GRID_PITCH,
|
||||
movingHalf: wallCmToUnits(cm, space.cell_cm, GRID_PITCH) / 2,
|
||||
obstacles: inputs.obstacles,
|
||||
});
|
||||
|
||||
/**
|
||||
* Первая подвижная ручка ресайза на плане: комната, ребро, разрешённый шаг.
|
||||
* Нужна именно подвижная — «включена, но ход нулевой» уже было дефектом #281.
|
||||
*/
|
||||
function firstMovableHandle(space) {
|
||||
const inputs = resizeInputs(space);
|
||||
for (const room of inputs.rooms) {
|
||||
for (let edge = 0; edge < room.poly.length; edge++) {
|
||||
const a = room.poly[edge];
|
||||
const b = room.poly[(edge + 1) % room.poly.length];
|
||||
const cm = thicknessCmAt(space.walls, a, b, STEP, NORM_W) || 15;
|
||||
const options = resizeOptions(space, inputs, cm);
|
||||
const resolution = resolveSafeResize(inputs.rooms, inputs.openings, room.id, edge, options);
|
||||
if (!resolution.enabled) continue;
|
||||
const outward = clampSafeResize(
|
||||
inputs.rooms, inputs.openings, resolution.plan, GRID_PITCH, GRID_PITCH, options,
|
||||
);
|
||||
if (!outward) continue;
|
||||
return { inputs, options, plan: resolution.plan, room: room.id, edge, d: outward };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Записать результат ресайза обратно в конфиг (единицы NORM_W → нормированные). */
|
||||
function writeResize(space, moved) {
|
||||
const next = clone(space);
|
||||
for (const room of next.rooms || []) {
|
||||
const poly = moved.polys[room.id];
|
||||
if (poly) room.poly = poly.map(([x, y]) => [x / NORM_W, y / NORM_W]);
|
||||
}
|
||||
for (const opening of next.openings || []) {
|
||||
const point = moved.openings[opening.id];
|
||||
if (point) { opening.x = point[0] / NORM_W; opening.y = point[1] / NORM_W; }
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Корпус. `condition` проверяется отдельным тестом: фикстура, потерявшая своё
|
||||
* условие, — это зелёный прогон, который ничего не проверяет, и такой тихий
|
||||
* успех в этом проекте уже дважды стоил дня (#171, #207).
|
||||
*/
|
||||
const CORPUS = [
|
||||
{
|
||||
file: 'c1-short-edges',
|
||||
what: 'короткие рёбра: уступ в два шага (10 см при шаге 5 см)',
|
||||
field: 'минимальное ребро комнаты в поле — 2 шага на первом этаже, 5 см на втором',
|
||||
fixedPointOnImport: true,
|
||||
condition(space) {
|
||||
const short = edgeSteps(space).filter((steps) => steps <= 2.001);
|
||||
assert.ok(short.length >= 2, `коротких рёбер ${short.length}, нужно ≥2`);
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'c2-t-junction-thicknesses',
|
||||
what: 'T-узел, в котором сходятся записи трёх разных толщин',
|
||||
field: 'в поле 7 T-узлов и пять толщин (20/22/28/29/33) в одном пространстве',
|
||||
fixedPointOnImport: true,
|
||||
condition(space) {
|
||||
const tee = [...nodeDegrees(space).entries()].filter(([, degree]) => degree >= 3);
|
||||
assert.ok(tee.length >= 1, 'T-узла нет');
|
||||
const thicknesses = new Set((space.walls || []).map((wall) => wall.cm));
|
||||
assert.ok(thicknesses.size >= 3, `толщин ${thicknesses.size}, нужно ≥3`);
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'c3-x-junction',
|
||||
what: 'X-узел: четыре комнаты в одной точке',
|
||||
field: 'в поле X-узлов НЕТ НИ ОДНОГО — это рискованный сосед, не наблюдение',
|
||||
synthetic: true,
|
||||
fixedPointOnImport: true,
|
||||
condition(space) {
|
||||
const cross = [...nodeDegrees(space).values()].filter((degree) => degree >= 4);
|
||||
assert.ok(cross.length >= 1, 'узла степени 4 нет');
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'c4-opening-at-vertex',
|
||||
what: 'проём, чей край лежит ровно в вершине комнаты',
|
||||
field: 'в поле один такой проём',
|
||||
fixedPointOnImport: true,
|
||||
condition(space) {
|
||||
const vertices = roomVertices(space);
|
||||
const touching = (space.openings || []).filter((opening) => openingEnds(opening)
|
||||
.some((end) => vertices.some((vertex) => near(vertex, end, STEP * 1e-3))));
|
||||
assert.equal(touching.length, 1, 'проём должен упираться краем в вершину');
|
||||
assert.ok(touching[0].host?.kind === 'wall' && touching[0].host.id,
|
||||
'проём должен быть подвешен на сегмент стены');
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'c5-node-noise-near-axis',
|
||||
what: 'шум у узла плюс честно наклонная стена',
|
||||
field: 'в поле 23 координаты из 1010 лежат ближе 1e-4 шага от узла',
|
||||
fixedPointOnImport: false, // именно шум Optimize и снимает
|
||||
condition(space) {
|
||||
assert.ok(latticeProfile({ config: { spaces: [space] } }).noise >= 1, 'шума у узла нет');
|
||||
const slanted = (space.rooms || []).some((room) => {
|
||||
const poly = room.poly || [];
|
||||
return poly.some((a, index) => {
|
||||
const b = poly[(index + 1) % poly.length];
|
||||
const dx = Math.abs(b[0] - a[0]);
|
||||
const dy = Math.abs(b[1] - a[1]);
|
||||
return dx > STEP && dy > STEP * 0.5 && dy < dx; // наклон, но не осевой
|
||||
});
|
||||
});
|
||||
assert.ok(slanted, 'наклонной стены нет');
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'c6-stale-layout',
|
||||
what: 'записи layout на пространство, которого в конфиге больше нет',
|
||||
field: 'в поле четыре такие записи на два удалённых пространства',
|
||||
fixedPointOnImport: false,
|
||||
expectedViolations: ['references/layout_space', 'references/layout_space'],
|
||||
condition(space, layout) {
|
||||
const known = new Set([space.id]);
|
||||
const orphans = Object.values(layout).filter((entry) => !known.has(entry.s));
|
||||
assert.equal(orphans.length, 2, 'осиротевших записей layout должно быть две');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of CORPUS) {
|
||||
const label = `${item.file}${item.synthetic ? ' (синтетический)' : ''}`;
|
||||
|
||||
test(`корпус #560 ${label}: фикстура несёт своё условие — ${item.what}`, () => {
|
||||
const { config, layout } = load(item.file);
|
||||
assert.equal(config.model_version, 10, 'корпус ведётся на актуальной модели');
|
||||
item.condition(config.spaces[0], layout);
|
||||
});
|
||||
|
||||
test(`корпус #560 ${label}: импорт → Optimize → Optimize`, () => {
|
||||
const { config, layout } = load(item.file);
|
||||
const expected = item.expectedViolations ?? [];
|
||||
assert.deepEqual(kinds(violations(config, layout)), expected.slice().sort(),
|
||||
'импорт: инварианты');
|
||||
|
||||
const guard = clone(config);
|
||||
const first = optimizePlans(config, layout);
|
||||
assert.deepEqual(config, guard, 'Optimize не имеет права менять вход (#281)');
|
||||
assert.deepEqual(kinds(violations(first.config, first.layout)), expected.slice().sort(),
|
||||
'после Optimize новых нарушений нет');
|
||||
assert.equal(first.changed, item.fixedPointOnImport ? false : true,
|
||||
item.fixedPointOnImport
|
||||
? 'приведённый план Optimize трогать не должен'
|
||||
: 'фикстура заявлена как требующая приведения');
|
||||
|
||||
const second = optimizePlans(clone(first.config), clone(first.layout));
|
||||
assert.equal(second.changed, false, 'второй Optimize — фикс-пойнт (#477)');
|
||||
assert.deepEqual(second.config, first.config, 'второй Optimize ничего не двигает');
|
||||
|
||||
// Записи толщины — отдельный оракул: именно их теряли #253 и #258.
|
||||
assert.deepEqual(
|
||||
checkWallRecordsPreserved(config.spaces[0].walls, first.config.spaces[0].walls),
|
||||
[], 'Optimize сохранил записи толщины',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Граница этой проверки названа прямо: перенос ЗАПИСЕЙ ТОЛЩИНЫ вслед за
|
||||
* сдвинутым ребром делает не `resize.ts`, а хост карточки — `project()` в
|
||||
* `ResizeController.move` возвращает `afterWalls`, и чистой функции для этого
|
||||
* в модуле нет. Поэтому здесь проверяется то, что проверяемо без карточки:
|
||||
* ручка действительно подвижна, геометрия после сдвига согласована, записи
|
||||
* толщины целы, и Optimize сходится за ограниченное число проходов. Полный
|
||||
* путь со штатным writer'ом проверяет браузерный смок корпуса.
|
||||
*/
|
||||
test(`корпус #560 ${label}: Resize ребра сохраняет согласованность`, (t) => {
|
||||
const { config, layout } = load(item.file);
|
||||
const optimized = optimizePlans(clone(config), clone(layout));
|
||||
const space = optimized.config.spaces[0];
|
||||
const handle = firstMovableHandle(space);
|
||||
if (!handle) return t.skip('подвижной ручки ресайза на этом плане нет');
|
||||
|
||||
const moved = applySafeResize(handle.inputs.rooms, handle.inputs.openings, handle.plan, handle.d);
|
||||
const resized = clone(optimized.config);
|
||||
resized.spaces[0] = writeResize(space, moved);
|
||||
|
||||
const after = optimizePlans(clone(resized), clone(optimized.layout));
|
||||
const expected = item.expectedViolations ?? [];
|
||||
assert.deepEqual(kinds(violations(after.config, after.layout)), expected.slice().sort(),
|
||||
'после Resize и Optimize новых нарушений нет');
|
||||
assert.deepEqual(
|
||||
checkWallRecordsPreserved(space.walls, after.config.spaces[0].walls),
|
||||
[], 'ресайз не потерял ни одной толщины (#253)',
|
||||
);
|
||||
// Сходимость, а не «один проход»: вход здесь — рёбра сдвинуты, а записи
|
||||
// толщины остались на прежних координатах, потому что их переносит хост.
|
||||
// Из такого состояния Optimize имеет право убирать остатки в два приёма;
|
||||
// важно, что он ОСТАНАВЛИВАЕТСЯ, а не переписывает план бесконечно.
|
||||
let passes = 0;
|
||||
let current = after;
|
||||
while (current.changed && passes < 5) {
|
||||
current = optimizePlans(clone(current.config), clone(current.layout));
|
||||
passes += 1;
|
||||
}
|
||||
assert.equal(current.changed, false, `Optimize не сошёлся за ${passes} проходов`);
|
||||
assert.ok(passes <= 2, `после Resize Optimize сходится за ${passes} проходов, ожидалось ≤2`);
|
||||
assert.deepEqual(kinds(violations(current.config, current.layout)), expected.slice().sort(),
|
||||
'сошедшийся план согласован');
|
||||
});
|
||||
|
||||
test(`корпус #560 ${label}: сохранение и перечитывание плана`, () => {
|
||||
const { config, layout } = load(item.file);
|
||||
const optimized = optimizePlans(clone(config), clone(layout));
|
||||
const reloaded = JSON.parse(JSON.stringify(optimized.config));
|
||||
assert.deepEqual(reloaded, optimized.config, 'план переживает сериализацию без потерь');
|
||||
const again = optimizePlans(reloaded, JSON.parse(JSON.stringify(optimized.layout)));
|
||||
assert.equal(again.changed, false, 'перечитанный план не требует приведения');
|
||||
});
|
||||
}
|
||||
|
||||
test('корпус #560: ключи записей толщины канонические', () => {
|
||||
for (const item of CORPUS) {
|
||||
const { config } = load(item.file);
|
||||
if (!item.fixedPointOnImport) continue; // c5/c6 приводит сам Optimize
|
||||
for (const wall of config.spaces[0].walls || []) {
|
||||
assert.equal(wall.key, wallKey(wall.a, wall.b),
|
||||
`${item.file}: ключ записи разошёлся с координатами`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('корпус #560: наклонная стена не считается почти осевой', () => {
|
||||
// Прямая проверка приёмки «корректная планировка не исправляется ради
|
||||
// удобства рендера»: честный наклон 1 шаг на 60 не должен попадать в класс,
|
||||
// который Optimize канонизирует. Шум у узла — попадает, и это правильно.
|
||||
const { config } = load('c5-node-noise-near-axis');
|
||||
const slanted = config.spaces[0].rooms.find((room) => room.id === 'c5-r2');
|
||||
const [a, b] = [slanted.poly[0], slanted.poly[1]];
|
||||
assert.equal(classifyNearAxisSegment(a, b), null, 'наклон не классифицируется как почти осевой');
|
||||
const optimized = optimizePlans(clone(config), {});
|
||||
const after = optimized.config.spaces[0].rooms.find((room) => room.id === 'c5-r2');
|
||||
assert.deepEqual(after.poly[0], a, 'Optimize не выпрямил наклонную стену');
|
||||
assert.deepEqual(after.poly[1], b, 'Optimize не выпрямил наклонную стену');
|
||||
});
|
||||
Reference in New Issue
Block a user