diff --git a/demo/smoke_real_plan_masonry.mjs b/demo/smoke_real_plan_masonry.mjs index 7642b62b..caa020eb 100644 --- a/demo/smoke_real_plan_masonry.mjs +++ b/demo/smoke_real_plan_masonry.mjs @@ -18,8 +18,18 @@ import { readFileSync } from 'node:fs'; import { launch, checkAll, finish } from './serve.mjs'; -const FIXTURE = new URL('../test/fixtures/real-plan-second-floor.json', import.meta.url); -const { space } = JSON.parse(readFileSync(FIXTURE, 'utf8')); +/** + * Реальные планы, каждый со своим классом дефектов (#285, #286). + * + * Второй этаж — стыки: коридор многостенного узла съедает соседнюю кладку. + * Первый этаж — другое: у него есть виртуальные пролёты, колонны и два + * сплошных ребра, у которых вообще нет записи толщины. Ни то, ни другое + * синтетика не воспроизводит. + */ +const PLANS = [ + { file: 'real-plan-second-floor.json', gapCount: 4, gapSteps: 181 }, + { file: 'real-plan-first-floor.json', gapCount: 0, gapSteps: 0 }, +]; /** * Признанный долг beta.9, ведётся в #284 вместе с #271/#275. @@ -42,7 +52,7 @@ const KNOWN_GAP_STEPS = 181; const { page, browser } = await launch(); const out = {}; -const measured = await page.evaluate(async (space) => { +const measure = (space) => page.evaluate(async (space) => { const card = window.__card; card._serverCfg = { spaces: [space], markers: [], settings: {} }; card._cfgEpoch = (card._cfgEpoch || 0) + 1; @@ -69,9 +79,25 @@ const measured = await page.evaluate(async (space) => { // достаточно грубо, чтобы прогон оставался быстрым. const SAMPLE = STEP / 4; + // Виртуальный пролёт — объявленный разрыв, а не дефект: там кладки быть не + // должно. Без этого исключения план с виртуальными границами покраснел бы + // на собственном контракте. + const spans = (space.open_spans || []).map((s) => [s.a, s.b]); + const onDeclaredSpan = (x, y) => spans.some(([a, b]) => { + const dx = b[0] - a[0], dy = b[1] - a[1]; + const len2 = dx * dx + dy * dy; + if (len2 < 1e-18) return false; + const t = ((x - a[0]) * dx + (y - a[1]) * dy) / len2; + if (t < -0.02 || t > 1.02) return false; + const px = a[0] + dx * Math.max(0, Math.min(1, t)); + const py = a[1] + dy * Math.max(0, Math.min(1, t)); + return Math.hypot(x - px, y - py) <= 1 / 240; + }); + const gaps = []; let sampled = 0; let missing = 0; + let skipped = 0; for (const room of space.rooms) { const poly = room.poly; for (let i = 0; i < poly.length; i++) { @@ -84,6 +110,14 @@ const measured = await page.evaluate(async (space) => { const t = k / count; const x = a[0] + (b[0] - a[0]) * t; const y = a[1] + (b[1] - a[1]) * t; + if (onDeclaredSpan(x, y)) { + skipped++; + if (runStart !== null) { + gaps.push({ room: room.id, edge: i, steps: (t - runStart) * length / STEP }); + runStart = null; + } + continue; + } sampled++; const solid = wall.isPointInFill(at(x, y)); if (!solid) { @@ -102,6 +136,7 @@ const measured = await page.evaluate(async (space) => { const worst = gaps.slice().sort((x, y) => y.steps - x.steps).slice(0, 5); return { sampled, + skipped, missing, gapCount: gaps.length, totalGapSteps: gaps.reduce((sum, g) => sum + g.steps, 0), @@ -109,15 +144,27 @@ const measured = await page.evaluate(async (space) => { }; }, space); -console.log(JSON.stringify(measured, null, 2)); -out.wallPathFound = !measured.error; -out.sampledEnough = measured.sampled > 5000; -out.gapCountMatchesKnownDebt = measured.gapCount === KNOWN_GAP_COUNT; -out.gapLengthMatchesKnownDebt = Math.abs(measured.totalGapSteps - KNOWN_GAP_STEPS) < 1; -if (!out.gapCountMatchesKnownDebt || !out.gapLengthMatchesKnownDebt) { - console.log(`Долг изменился: было ${KNOWN_GAP_COUNT} разрывов на ${KNOWN_GAP_STEPS} шага,` - + ` стало ${measured.gapCount} на ${measured.totalGapSteps.toFixed(2)}.` - + ' Меньше — обновите числа и приложите прогон; больше — это регрессия.'); +for (const plan of PLANS) { + const { space } = JSON.parse(readFileSync( + new URL(`../test/fixtures/${plan.file}`, import.meta.url), 'utf8')); + const measured = await measure(space); + const tag = plan.file.replace('real-plan-', '').replace('.json', ''); + console.log(tag, JSON.stringify(measured, null, 2)); + out[`${tag}:wallPath`] = !measured.error; + out[`${tag}:sampledEnough`] = measured.sampled > 5000; + if (plan.gapCount === null) { + console.log(`ИЗМЕРЕНИЕ ${tag}: разрывов ${measured.gapCount},` + + ` суммарно ${measured.totalGapSteps.toFixed(2)} шага`); + } else { + out[`${tag}:gapCount`] = measured.gapCount === plan.gapCount; + out[`${tag}:gapLength`] = Math.abs(measured.totalGapSteps - plan.gapSteps) < 1; + if (!out[`${tag}:gapCount`] || !out[`${tag}:gapLength`]) { + console.log(`Долг ${tag} изменился: было ${plan.gapCount} разрывов на` + + ` ${plan.gapSteps} шага, стало ${measured.gapCount} на` + + ` ${measured.totalGapSteps.toFixed(2)}. Меньше — обновите числа и приложите` + + ' прогон; больше — это регрессия.'); + } + } } checkAll(out); await finish(browser, out); diff --git a/test/fixtures/real-plan-first-floor.json b/test/fixtures/real-plan-first-floor.json new file mode 100644 index 00000000..7a4cc129 --- /dev/null +++ b/test/fixtures/real-plan-first-floor.json @@ -0,0 +1,583 @@ +{ + "note": "Privacy-minimised geometry of a real first floor (issue #286 (gate introduced in #285)). Room names, ids and every device binding are removed; only geometry, thickness, virtual spans and openings remain. Coordinates are unchanged on purpose: this plan carries the coordinate noise that synthetic fixtures do not.", + "space": { + "id": "real-first-floor", + "title": "Real first floor", + "cell_cm": 5, + "view_box": [ + 0, + 0, + 1, + 1 + ], + "settings": { + "show_borders": true, + "show_names": false, + "fill_mode": "custom", + "custom_fill": { + "c": "#607d8b", + "a": 0 + }, + "glow_enabled": false + }, + "rooms": [ + { + "id": "room-a", + "name": "Room A", + "poly": [ + [ + 0.070833333, + 0.4375 + ], + [ + 0.070833333, + 0.05 + ], + [ + 0.8875, + 0.05 + ], + [ + 0.8875, + 0.345833333 + ], + [ + 0.691666667, + 0.345833333 + ], + [ + 0.691666667, + 0.55 + ], + [ + 0.620833333, + 0.55 + ], + [ + 0.620833333, + 0.425 + ], + [ + 0.420833333, + 0.425 + ], + [ + 0.420833333, + 0.4375 + ] + ] + }, + { + "id": "room-b", + "name": "Room B", + "poly": [ + [ + 0.204166667, + 0.645833333 + ], + [ + 0.204166667, + 0.4375 + ], + [ + 0.420833333, + 0.4375 + ], + [ + 0.420833333, + 0.645833333 + ] + ] + }, + { + "id": "room-c", + "name": "Room C", + "poly": [ + [ + 0.2375, + 0.866666667 + ], + [ + 0.2375, + 0.645833333 + ], + [ + 0.420833333, + 0.645833333 + ], + [ + 0.420833333, + 0.866666667 + ] + ] + }, + { + "id": "room-d", + "name": "Room D", + "poly": [ + [ + 0.070833333, + 0.866666667 + ], + [ + 0.070833333, + 0.645833333 + ], + [ + 0.2375, + 0.645833333 + ], + [ + 0.2375, + 0.866666667 + ] + ] + }, + { + "id": "room-e", + "name": "Room E", + "poly": [ + [ + 0.420833333, + 0.866666667 + ], + [ + 0.420833333, + 0.425 + ], + [ + 0.620833333, + 0.425 + ], + [ + 0.620833333, + 0.866666667 + ] + ] + }, + { + "id": "room-f", + "name": "Room F", + "poly": [ + [ + 0.620833333, + 0.866666667 + ], + [ + 0.620833333, + 0.55 + ], + [ + 0.8875, + 0.55 + ], + [ + 0.8875, + 0.866666667 + ] + ] + }, + { + "id": "room-g", + "name": "Room G", + "poly": [ + [ + 0.070833333, + 0.645833333 + ], + [ + 0.070833333, + 0.4375 + ], + [ + 0.204166667, + 0.4375 + ], + [ + 0.204166667, + 0.645833333 + ] + ] + }, + { + "id": "room-h", + "name": "Room H", + "poly": [ + [ + 0.691666667, + 0.345833333 + ], + [ + 0.979166667, + 0.345833333 + ], + [ + 0.979166667, + 0.55 + ], + [ + 0.691666667, + 0.55 + ] + ] + } + ], + "walls": [ + { + "key": "0.479167,0.050000@0.0000", + "cm": 29, + "a": [ + 0.070833333, + 0.05 + ], + "b": [ + 0.8875, + 0.05 + ] + }, + { + "key": "0.420833,0.645833@1.5706", + "cm": 20, + "a": [ + 0.420833333, + 0.866666667 + ], + "b": [ + 0.420833333, + 0.425 + ] + }, + { + "key": "0.070833,0.245833@1.5706", + "cm": 29, + "a": [ + 0.070833333, + 0.4375 + ], + "b": [ + 0.070833333, + 0.05 + ] + }, + { + "key": "0.245833,0.437500@0.0000", + "cm": 33, + "a": [ + 0.420833333, + 0.4375 + ], + "b": [ + 0.070833333, + 0.4375 + ] + }, + { + "key": "0.620833,0.708333@1.5706", + "cm": 20, + "a": [ + 0.620833333, + 0.55 + ], + "b": [ + 0.620833333, + 0.866666667 + ] + }, + { + "key": "0.887500,0.708333@1.5706", + "cm": 29, + "a": [ + 0.8875, + 0.55 + ], + "b": [ + 0.8875, + 0.866666667 + ] + }, + { + "key": "0.887500,0.195833@1.5706", + "cm": 29, + "a": [ + 0.8875, + 0.05 + ], + "b": [ + 0.8875, + 0.345833333 + ] + }, + { + "key": "0.754167,0.550000@0.0000", + "cm": 20, + "a": [ + 0.620833333, + 0.55 + ], + "b": [ + 0.8875, + 0.55 + ] + }, + { + "key": "0.754167,0.866667@0.0000", + "cm": 29, + "a": [ + 0.8875, + 0.866666667 + ], + "b": [ + 0.620833333, + 0.866666667 + ] + }, + { + "key": "0.070833,0.758333@1.5706", + "cm": 29, + "a": [ + 0.070833333, + 0.866666667 + ], + "b": [ + 0.070833333, + 0.645833333 + ] + }, + { + "key": "0.237500,0.758333@1.5706", + "cm": 20, + "a": [ + 0.2375, + 0.866666667 + ], + "b": [ + 0.2375, + 0.645833333 + ] + }, + { + "key": "0.312500,0.645833@0.0000", + "cm": 20, + "a": [ + 0.420833333, + 0.645833333 + ], + "b": [ + 0.204166667, + 0.645833333 + ] + }, + { + "key": "0.070833,0.541667@1.5706", + "cm": 29, + "a": [ + 0.070833333, + 0.645833333 + ], + "b": [ + 0.070833333, + 0.4375 + ] + }, + { + "key": "0.204167,0.541667@1.5706", + "cm": 20, + "a": [ + 0.204166667, + 0.645833333 + ], + "b": [ + 0.204166667, + 0.4375 + ] + }, + { + "key": "0.979167,0.445833@1.5706", + "cm": 28, + "a": [ + 0.979166667, + 0.345833333 + ], + "b": [ + 0.979166667, + 0.55 + ] + }, + { + "key": "0.520833,0.866667@0.0000", + "cm": 29, + "a": [ + 0.620833333, + 0.866666667 + ], + "b": [ + 0.420833333, + 0.866666667 + ] + }, + { + "key": "0.791667,0.345833@0.0000", + "cm": 22, + "a": [ + 0.8875, + 0.345833333 + ], + "b": [ + 0.691666667, + 0.345833333 + ] + }, + { + "key": "0.329167,0.866667@0.0000", + "cm": 29, + "a": [ + 0.420833333, + 0.866666667 + ], + "b": [ + 0.2375, + 0.866666667 + ] + }, + { + "key": "0.154167,0.645833@0.0000", + "cm": 20, + "a": [ + 0.070833333, + 0.645833333 + ], + "b": [ + 0.2375, + 0.645833333 + ] + }, + { + "key": "0.154167,0.866667@0.0000", + "cm": 29, + "a": [ + 0.2375, + 0.866666667 + ], + "b": [ + 0.070833333, + 0.866666667 + ] + }, + { + "key": "0.691667,0.400000@1.5706", + "cm": 22, + "a": [ + 0.691666667, + 0.345833333 + ], + "b": [ + 0.691666667, + 0.454166667 + ] + }, + { + "key": "0.933333,0.550000@0.0000", + "cm": 22, + "a": [ + 0.979166667, + 0.55 + ], + "b": [ + 0.8875, + 0.55 + ] + }, + { + "key": "0.933333,0.345833@0.0000", + "cm": 22, + "a": [ + 0.888881904, + 0.345833333 + ], + "b": [ + 0.979166667, + 0.345833333 + ] + }, + { + "key": "0.887500,0.345833@0.0000", + "cm": 15, + "a": [ + 0.8875, + 0.345833333 + ], + "b": [ + 0.888881904, + 0.345833333 + ] + } + ], + "partitions": [ + { + "id": "partition_partition_partition_partition-msjey_30638872", + "a": [ + 0.691666667, + 0.345833333 + ], + "b": [ + 0.604166667, + 0.345833333 + ], + "cm": 22 + } + ], + "open_spans": [ + { + "a": [ + 0.420833333, + 0.425 + ], + "b": [ + 0.620833333, + 0.425 + ] + }, + { + "a": [ + 0.620833333, + 0.425 + ], + "b": [ + 0.620833333, + 0.541666667 + ] + }, + { + "a": [ + 0.691666667, + 0.454166667 + ], + "b": [ + 0.691666667, + 0.55 + ] + } + ], + "openings": [], + "wall_columns": [ + { + "id": "column_column_column_column-msjeztk0_5a939_96c417e2", + "shape": "square", + "center": [ + 0.4, + 0.441666667 + ], + "cm": 15 + }, + { + "id": "column-mt1olibe", + "shape": "square", + "center": [ + 0.416666667, + 0.345833333 + ], + "cm": 29 + } + ] + } +} diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs index ec0ba21c..68441d53 100644 --- a/test/model-invariants.test.mjs +++ b/test/model-invariants.test.mjs @@ -369,3 +369,52 @@ test('модели проекта не несут шума решётки (#282) + `${profile.worstNoise ? ` — ${profile.worstNoise.owner}` : ''}`); } }); + +// ------------------ реальные планы как фикстуры (#285, #286) ----------------- +// Синтетика не воспроизводит два класса сразу: координатный шум и конфигурации +// живого плана. Здесь закреплены свойства, ради которых эти фикстуры и лежат в +// репозитории. Если кто-то «почистит» их координаты, фикстуры потеряют смысл — +// и тест скажет об этом раньше, чем это выяснится через месяц. + +const REAL_PLANS = [ + { file: 'real-plan-first-floor.json', minNoise: 100, zeroThicknessSolidEdges: 2 }, + { file: 'real-plan-second-floor.json', minNoise: 100, zeroThicknessSolidEdges: 0 }, +]; + +test('реальные планы сохраняют координатный шум, ради которого их взяли (#286)', () => { + for (const plan of REAL_PLANS) { + const { space } = JSON.parse( + readFileSync(resolve(repoRoot, 'test/fixtures', plan.file), 'utf8')); + const profile = latticeProfile({ config: { spaces: [space] }, layout: {} }); + assert.ok(profile.noise >= plan.minNoise, + `${plan.file}: шума ${profile.noise}, ожидалось не меньше ${plan.minNoise} —` + + ' фикстура канонизирована и больше не воспроизводит свой класс дефектов'); + assert.equal(checkWallKeys({ spaces: [space] }, { notes: [] }).length, 0, + `${plan.file}: запись толщины объявлена неразрешимой`); + } +}); + +test('реальные планы: сплошные рёбра без записи толщины закреплены числом (#286)', async () => { + // Ребро, объявленное сплошным, но с нулевой толщиной, рисуется без кладки. + // На первом этаже таких два, длиной по 2 шага, и соседние тела их накрывают — + // поэтому браузерный смок разрывов не видит. Число закреплено здесь: станет + // больше или короче накрытие — увидим до того, как это станет дыркой. + const { spaceModels, GRID_STEP_N, GRID_PITCH, NORM_W } = + await import('../test-build/space-geometry.js'); + const { wallIntervals } = await import('../test-build/wall-thickness.js'); + const { resolveOpenCuts } = await import('../test-build/open-spans.js'); + for (const plan of REAL_PLANS) { + const { space } = JSON.parse( + readFileSync(resolve(repoRoot, 'test/fixtures', plan.file), 'utf8')); + const model = spaceModels({ spaces: [space], markers: [], settings: {} })[0]; + const cuts = resolveOpenCuts( + model.rooms, space.open_spans ?? null, NORM_W, GRID_PITCH * 0.02, true); + const intervals = wallIntervals( + model.rooms, space.walls, cuts, GRID_STEP_N, + space.cell_cm || 5, GRID_PITCH, NORM_W); + const zero = intervals.filter((item) => item.kind && !(item.cm > 0)); + assert.equal(zero.length, plan.zeroThicknessSolidEdges, + `${plan.file}: сплошных рёбер без толщины ${zero.length},` + + ` ожидалось ${plan.zeroThicknessSolidEdges}`); + } +});