diff --git a/scripts/model-invariants.mjs b/scripts/model-invariants.mjs index 7908fec0..c45bb482 100644 --- a/scripts/model-invariants.mjs +++ b/scripts/model-invariants.mjs @@ -392,6 +392,93 @@ export function latticeProfile({ config, layout = {} } = {}) { }; } +/** + * Инвариант 4: запись толщины не описывает сразу общую и наружную стену (#287). + * + * Откуда это взялось. Владелец прислал пару экспортов до и после ресайза: одна + * комната из общей пары сдвинулась на 43 шага, вторая осталась. Нижние 43 шага + * стены перестали быть общими — стали наружными, — но продолжают нести 20 см + * бывшей общей границы, тогда как соседние наружные несут 30 см. Толщина + * сохранена ПО КЛЮЧУ, а не по роли ребра. + * + * Проверить это можно в одном состоянии, без пары «до и после»: признак — + * **одна запись толщины, чей пролёт частью общий, а частью наружный**. Такую + * запись пользователь не мог задать осознанно: он назначал толщину границе + * между двумя комнатами либо наружной стене, но не обоим сразу. + * + * Роль считается из полигонов и не требует ни сборки, ни продуктового кода: + * участок общий, если его накрывает ребро другой комнаты. + */ +const SHARE_TOLERANCE = 1e-6; + +const segmentsOfSpace = (space) => { + const out = []; + for (const room of space?.rooms || []) { + const poly = roomPolygon(room); + if (!poly) continue; + for (const [a, b] of edgesOf(poly)) out.push({ room: String(room?.id ?? '?'), a, b }); + } + return out; +}; + +/** Доля пролёта записи, накрытая ребром ДРУГОЙ комнаты, по точкам выборки. */ +const roleProfile = (wall, segments, samples = 41) => { + const a = point(wall?.a), b = point(wall?.b); + if (!a || !b) return null; + const dx = b[0] - a[0], dy = b[1] - a[1]; + const length = Math.hypot(dx, dy); + if (!(length > SHARE_TOLERANCE)) return null; + let shared = 0; + let outer = 0; + // Концы записи не выбираются: конец — это узел, а не участок. В узле стена + // законно касается рёбер двух комнат, и включение концов давало «95% + // наружного» на каждой наружной стене, упирающейся в общую. + for (let i = 1; i < samples; i++) { + const t = i / samples; + const p = [a[0] + dx * t, a[1] + dy * t]; + // Считаются РАЗНЫЕ комнаты, а не рёбра: в углу одной комнаты точка лежит + // сразу на двух её рёбрах. Проверено мутантом: после исключения концов + // (ниже) подсчёт рёбер на реальных планах даёт тот же результат, то есть + // сам по себе этот выбор не несущий — он оставлен как смысловая страховка, + // а не как то, чем держится проверка. Мутанта на него не ставлю: он + // выживает, а выживающий мутант хуже отсутствующего. + const owners = new Set(); + for (const segment of segments) { + if (distToSegment(p, segment.a, segment.b) <= SHARE_TOLERANCE) owners.add(segment.room); + } + if (owners.size >= 2) shared++; + else if (owners.size === 1) outer++; + } + return { shared, outer, samples: samples - 1 }; +}; + +export function checkMixedRoleRecords(config) { + const violations = []; + for (const space of Array.isArray(config?.spaces) ? config.spaces : []) { + const spaceId = String(space?.id ?? '?'); + const segments = segmentsOfSpace(space); + if (!segments.length) continue; + for (const wall of space?.walls || []) { + const profile = roleProfile(wall, segments); + if (!profile) continue; + // Оба вида в одной записи, и ни один не является краевым шумом выборки. + const edge = Math.max(2, Math.round(profile.samples * 0.05)); + if (profile.shared >= edge && profile.outer >= edge) { + const outerShare = profile.outer / profile.samples; + violations.push({ + invariant: 'wall_roles', kind: 'mixed_role_record', + owner: `${spaceId}:${wall?.key ?? '?'}`, + reference: `${wall?.cm} см`, + detail: `пролёт записи частью общий, частью наружный` + + ` (${(outerShare * 100).toFixed(0)}% наружного) — толщина сохранена` + + ' по ключу, а не по роли ребра', + }); + } + } + } + return violations; +} + /** Разобрать любой из трёх форматов, в которых приходит конфигурация. */ export function readModel(text) { const parsed = JSON.parse(text); @@ -479,6 +566,7 @@ function report(violations, notes = []) { geometry_exception: 'Сбой проверки канонической геометрии стен', lost: 'Потерянные записи толщины', wall_key: 'Записи толщины, которые не найдутся по ключу', + mixed_role_record: 'Записи толщины, описывающие сразу общую и наружную стену', }; for (const [kind, list] of byKind) { lines.push(`${titles[kind] || kind}: ${list.length}`); @@ -514,6 +602,7 @@ function main(argv) { const violations = [ ...checkReferences(model, { notes }), ...checkWallKeys(model.config, { notes }), + ...checkMixedRoleRecords(model.config), ...checkPhysicalGeometry(model.config), ]; if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2)); diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 9ca71bff..d7f13bd5 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -479,6 +479,19 @@ export const MUTANTS = [ replace: ' if (false) {', }], }, + { + id: 'invariant-roles-sample-endpoints', + guard: 'node --test --test-name-pattern="реальные планы проекта эту проверку" ' + + 'test/model-invariants.test.mjs', + because: 'конец записи — узел, а не участок: там стена законно касается двух комнат, ' + + 'и включение концов в выборку даёт ложное срабатывание на каждой наружной стене, ' + + 'упирающейся в общую (#287)', + patches: [{ + file: 'scripts/model-invariants.mjs', + find: ' for (let i = 1; i < samples; i++) {', + replace: ' for (let i = 0; i <= samples; i++) {', + }], + }, { id: 'invariant-keys-cry-wolf', guard: 'node --test --test-name-pattern="старый и неразбираемый compatibility key" ' diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index 68441d53..47220102 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -5,8 +5,8 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - checkReferences, checkWallKeys, checkWallRecordsPreserved, keyMidpoint, - latticeProfile, readModel, wallKey, + checkMixedRoleRecords, checkReferences, checkWallKeys, checkWallRecordsPreserved, + keyMidpoint, latticeProfile, readModel, wallKey, } from '../scripts/model-invariants.mjs'; import { wallKey as productWallKey } from '../test-build/wall-thickness.js'; import { GRID_STEP_N } from '../test-build/space-geometry.js'; @@ -418,3 +418,75 @@ test('реальные планы: сплошные рёбра без запис + ` ожидалось ${plan.zeroThicknessSolidEdges}`); } }); + +// ------- инвариант 4: запись толщины не смешивает общее и наружное (#287) ---- +// Воспроизведение дефекта владельца из `66.json`: ресайз сдвинул одну комнату +// общей пары, вторая осталась, и нижняя часть стены перестала быть общей — +// но продолжает нести толщину бывшей общей границы. + +const pair = (leftPoly, wall) => ({ + spaces: [{ + id: 'sp', cell_cm: 1, + rooms: [ + { id: 'left', poly: leftPoly }, + { id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] }, + ], + walls: [wall], + }], +}); + +test('#287: до ресайза общая стена нарушением не считается', () => { + assert.deepEqual(checkMixedRoleRecords(pair( + [[0, 0], [1, 0], [1, 1], [0, 1]], { key: 'k', cm: 20, a: [1, 0], b: [1, 1] }, + )), []); +}); + +test('#287: после частичного ресайза запись описывает и общее, и наружное', () => { + const found = checkMixedRoleRecords(pair( + [[0, 0], [1, 0], [1, 1.2], [0, 1.2]], { key: 'k2', cm: 20, a: [1, 0], b: [1, 1.2] }, + )); + assert.equal(found.length, 1); + assert.equal(found[0].kind, 'mixed_role_record'); + assert.match(found[0].detail, /частью общий, частью наружный/); + assert.match(found[0].detail, /наружного/); +}); + +test('#287: наружный угол одной комнаты — не общая граница', () => { + // Считать надо РАЗНЫЕ комнаты, а не рёбра: в углу точка лежит сразу на двух + // рёбрах одной комнаты. Подсчёт рёбер давал ложное срабатывание на каждой + // наружной стене. + assert.deepEqual(checkMixedRoleRecords({ + spaces: [{ + id: 'sp', cell_cm: 1, + rooms: [{ id: 'only', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }], + walls: [{ key: 'k', cm: 30, a: [0, 0], b: [1, 0] }], + }], + }), []); +}); + +test('#287: наружная стена, упирающаяся в общую, нарушением не считается', () => { + // Конец записи — это узел, а не участок: там стена законно касается рёбер + // двух комнат. Включение концов в выборку давало «95% наружного» на каждой + // такой стене, то есть ложное срабатывание на ровном месте. + const found = checkMixedRoleRecords({ + spaces: [{ + id: 'sp', cell_cm: 1, + rooms: [ + { id: 'left', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }, + { id: 'right', poly: [[1, 0], [2, 0], [2, 1], [1, 1]] }, + ], + // наружная стена левой комнаты, оба конца упираются в общую границу x=1 + walls: [{ key: 'outer', cm: 30, a: [0, 0], b: [1, 0] }], + }], + }); + assert.deepEqual(found, []); +}); + +test('#287: реальные планы проекта эту проверку проходят', () => { + for (const file of ['real-plan-first-floor.json', 'real-plan-second-floor.json']) { + const { space } = JSON.parse( + readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8')); + assert.deepEqual(checkMixedRoleRecords({ spaces: [space] }), [], + `${file}: ложное срабатывание на реальном плане`); + } +});