mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Ревью r1, Medium: наблюдение о записи на удалённом пространстве говорило «владелец жив» и про ключ, который ни во что не резолвится. Это заявление о доказанности там, где её нет: продукт в этом состоянии ничего не удаляет не потому, что владелец жив, а потому, что не знает — `space-reference-repair` ведёт `live`, `absent` и `unverified`, три состояния, а не два. Проверка ведёт те же три. Нерезолвящийся ключ получает наблюдение `unknown_owner` с той же формулировкой, что и в живом пространстве: «владелец не найден в конфигурации (возможно устройство HA)». Тест сверяет теперь и ТЕКСТ причины — вид наблюдения без текста эту асимметрию пропускал, ровно так она и проехала. Issue: #566 User-Visible: no
924 lines
48 KiB
JavaScript
924 lines
48 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Инварианты модели плана (#254).
|
||
*
|
||
* npm run invariants -- --config <файл> # экспорт, config/get или сырой config
|
||
* npm run invariants -- --config <файл> --layout <файл>
|
||
* npm run invariants -- --config <файл> --near-axis --json
|
||
*
|
||
* Зачем это существует. Самый дорогой класс дефектов проекта — не ошибки
|
||
* формул, а потеря согласованности между геометрией и ссылками на неё: #253
|
||
* (ресайз потерял запись толщины), #244 (маркеры на удалённые пространства),
|
||
* #252 (37 забытых позиций в layout), #248, #126. Каждый раз это находил
|
||
* человек глазами. Здесь те же вопросы задаются машинно и одинаково.
|
||
*
|
||
* Structural geometry is the exception: the CLI deliberately imports the
|
||
* compiled production preparation instead of maintaining a second boolean
|
||
* model which can drift from the renderer (#278).
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { checkOptimizeGeometry } from '../test-build/plan-geometry-preflight.js';
|
||
import { classifyNearAxisSegment } from '../test-build/near-axis.js';
|
||
import { checkWallRecordsPreserved } from '../test-build/wall-record-preservation.js';
|
||
export { checkWallRecordsPreserved } from '../test-build/wall-record-preservation.js';
|
||
import {
|
||
LATTICE_GRID_N as GRID_N,
|
||
LATTICE_NOISE_STEPS as NOISE_STEPS,
|
||
} from '../test-build/coordinate-canonicalization.js';
|
||
|
||
/** Доля шага сетки, в пределах которой запись считается лежащей на ребре. */
|
||
const EDGE_TOLERANCE = 0.004;
|
||
|
||
const GRID_STEP_N = 1 / GRID_N;
|
||
|
||
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
||
const point = (value) => (Array.isArray(value) && isFiniteNumber(value[0])
|
||
&& isFiniteNumber(value[1]) ? [value[0], value[1]] : null);
|
||
|
||
const roomPolygon = (room) => {
|
||
const poly = Array.isArray(room?.poly) ? room.poly.map(point).filter(Boolean) : [];
|
||
if (poly.length >= 3) return poly;
|
||
// Прямоугольная запись старого формата — тоже полигон, просто записанный иначе.
|
||
if ([room?.x, room?.y, room?.w, room?.h].every(isFiniteNumber)) {
|
||
return [[room.x, room.y], [room.x + room.w, room.y],
|
||
[room.x + room.w, room.y + room.h], [room.x, room.y + room.h]];
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const edgesOf = (poly) => poly.map((a, index) => [a, poly[(index + 1) % poly.length]]);
|
||
|
||
/** Deduplicated physical near-axis segments, including both room-owner copies. */
|
||
export function nearAxisProfile(config) {
|
||
const spaces = [];
|
||
let total = 0;
|
||
const keyOf = (a, b) => {
|
||
const ka = `${a[0]},${a[1]}`, kb = `${b[0]},${b[1]}`;
|
||
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
||
};
|
||
for (const space of config?.spaces || []) {
|
||
const found = new Set();
|
||
for (const room of space?.rooms || []) {
|
||
const poly = roomPolygon(room);
|
||
if (!poly) continue;
|
||
for (const [a, b] of edgesOf(poly)) {
|
||
if (classifyNearAxisSegment(a, b)) found.add(keyOf(a, b));
|
||
}
|
||
}
|
||
for (const draft of space?.room_drafts || []) {
|
||
for (let index = 0; index + 1 < (draft?.points || []).length; index++) {
|
||
const a = point(draft.points[index]), b = point(draft.points[index + 1]);
|
||
if (a && b && classifyNearAxisSegment(a, b)) found.add(`draft:${draft.id}:${index}`);
|
||
}
|
||
}
|
||
for (const partition of space?.partitions || []) {
|
||
const a = point(partition?.a), b = point(partition?.b);
|
||
if (a && b && classifyNearAxisSegment(a, b)) {
|
||
found.add(`partition:${partition.id || keyOf(a, b)}`);
|
||
}
|
||
}
|
||
const count = found.size;
|
||
spaces.push({ spaceId: String(space?.id || ''), count });
|
||
total += count;
|
||
}
|
||
return { total, spaces };
|
||
}
|
||
|
||
const distToSegment = (p, a, b) => {
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const len2 = dx * dx + dy * dy;
|
||
if (len2 < 1e-18) return Math.hypot(p[0] - a[0], p[1] - a[1]);
|
||
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
|
||
t = Math.max(0, Math.min(1, t));
|
||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||
};
|
||
|
||
/** Все отрезки пространства, на которых законно жить записи толщины. */
|
||
const carriers = (space) => {
|
||
const out = [];
|
||
for (const room of space?.rooms || []) {
|
||
const poly = roomPolygon(room);
|
||
if (poly) out.push(...edgesOf(poly));
|
||
}
|
||
for (const partition of space?.partitions || []) {
|
||
const a = point(partition?.a), b = point(partition?.b);
|
||
if (a && b) out.push([a, b]);
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const onSomeCarrier = (span, list, tolerance) => list.some(([a, b]) =>
|
||
distToSegment(span[0], a, b) <= tolerance && distToSegment(span[1], a, b) <= tolerance);
|
||
|
||
/**
|
||
* Инвариант 2: каждая ссылка указывает на существующий объект.
|
||
*
|
||
* Каждое нарушение адресуется: что ссылается, куда и чего не нашли. Пустой
|
||
* список — не «наверное всё хорошо», а «проверено».
|
||
*/
|
||
export function checkReferences({ config, layout = {} } = {}, { notes = [] } = {}) {
|
||
const violations = [];
|
||
const spaces = Array.isArray(config?.spaces) ? config.spaces : [];
|
||
const spaceIds = new Set(spaces.map((space) => String(space?.id ?? '')).filter(Boolean));
|
||
const markerIds = new Set((config?.markers || [])
|
||
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
|
||
const activeMarkerIds = new Set((config?.markers || [])
|
||
.filter((marker) => marker?.removed !== true)
|
||
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
|
||
const activeLightMarkerIds = new Set((config?.markers || [])
|
||
.filter((marker) => marker?.removed !== true && marker?.is_light === true)
|
||
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
|
||
const roomIdsBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
|
||
new Set((space?.rooms || []).map((room) => String(room?.id ?? '')).filter(Boolean))]));
|
||
const areasBySpace = new Map(spaces.map((space) => [String(space?.id ?? ''),
|
||
new Set((space?.rooms || []).map((room) => String(room?.area ?? '')).filter(Boolean))]));
|
||
const add = (kind, owner, reference, detail) =>
|
||
violations.push({ invariant: 'references', kind, owner, reference, detail });
|
||
|
||
for (const marker of config?.markers || []) {
|
||
if (marker?.removed) continue;
|
||
const markerId = String(marker.id ?? '?');
|
||
const space = marker?.space == null ? '' : String(marker.space);
|
||
if (space && !spaceIds.has(space)) {
|
||
add('marker_space', markerId, space, 'пространства не существует');
|
||
}
|
||
const room = marker?.room_id == null ? '' : String(marker.room_id);
|
||
if (room && spaceIds.has(space) && !roomIdsBySpace.get(space)?.has(room)) {
|
||
add('marker_room', markerId, room, 'комнаты не существует в пространстве маркера');
|
||
}
|
||
const segments = marker?.vacuum?.segment_map;
|
||
if (segments && typeof segments === 'object' && !Array.isArray(segments)) {
|
||
for (const [segment, value] of Object.entries(segments)) {
|
||
const roomId = String(value ?? '');
|
||
if (roomId && spaceIds.has(space) && !roomIdsBySpace.get(space)?.has(roomId)) {
|
||
add('vacuum_room', `${markerId}:${segment}`, roomId,
|
||
'комнаты сегмента не существует в пространстве маркера');
|
||
}
|
||
}
|
||
}
|
||
for (const value of Array.isArray(marker?.controls) ? marker.controls : []) {
|
||
if (typeof value !== 'string' || !value.startsWith('marker:')) continue;
|
||
const target = value.slice('marker:'.length);
|
||
if (!activeLightMarkerIds.has(target)) {
|
||
add('marker_control', markerId, target,
|
||
activeMarkerIds.has(target)
|
||
? 'маркер-цель не является источником света'
|
||
: 'активного маркера-цели не существует');
|
||
}
|
||
}
|
||
const badgeSource = marker?.value_badge?.source;
|
||
if (badgeSource?.kind === 'derived_marker_state') {
|
||
const ref = String(badgeSource.ref ?? '');
|
||
const target = ref.startsWith('marker:') ? ref.slice('marker:'.length) : '';
|
||
if (!target || !activeLightMarkerIds.has(target)) {
|
||
add('marker_badge', markerId, ref || '?',
|
||
target && activeMarkerIds.has(target)
|
||
? 'маркер-источник не является источником света'
|
||
: 'активного маркера-источника не существует');
|
||
}
|
||
}
|
||
const valueSource = marker?.value_source;
|
||
if (valueSource?.kind === 'derived_marker_state') {
|
||
const ref = String(valueSource.ref ?? '');
|
||
const target = ref.startsWith('marker:') ? ref.slice('marker:'.length) : '';
|
||
if (!target || !activeLightMarkerIds.has(target)) {
|
||
add('marker_value_source', markerId, ref || '?',
|
||
target && activeMarkerIds.has(target)
|
||
? 'маркер-цель не является источником света'
|
||
: 'активного маркера-цели не существует');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Владельцы позиций, разрешимые по одной конфигурации: комнаты и области
|
||
// любого пространства. Нужны, чтобы судить запись на удалённом пространстве
|
||
// тем же правилом, которым её судит сам продукт (#566).
|
||
const allRoomIds = new Set(spaces.flatMap((space) => (space?.rooms || [])
|
||
.map((room) => String(room?.id ?? '')).filter(Boolean)));
|
||
const allAreas = new Set(spaces.flatMap((space) => (space?.rooms || [])
|
||
.map((room) => String(room?.area ?? '')).filter(Boolean)));
|
||
const removedMarkerIds = new Set((config?.markers || [])
|
||
.filter((marker) => marker?.removed === true)
|
||
.map((marker) => String(marker?.id ?? '')).filter(Boolean));
|
||
|
||
for (const [key, position] of Object.entries(layout || {})) {
|
||
const space = position?.s == null ? '' : String(position.s);
|
||
if (space && !spaceIds.has(space)) {
|
||
// Пространства нет — но это ещё не нарушение (#566). `Optimize`
|
||
// (`space-reference-repair.ts`) удаляет такую запись только когда МОЖЕТ
|
||
// ДОКАЗАТЬ, что владелец тоже исчез, и сознательно сохраняет её, когда
|
||
// владелец жив или доказательств нет: иначе удаление уносит расстановку
|
||
// пользователя. Значит конфиг, только что прошедший Optimize, законно
|
||
// содержит такие записи, и объявлять их нарушением — врать про модель.
|
||
//
|
||
// Судим тем же правилом и ТЕМИ ЖЕ ТРЕМЯ состояниями, что продукт: `absent`
|
||
// (нарушение), `live` и `unverified` (наблюдения). Разница между двумя
|
||
// последними — не педантизм: сказать «владелец жив» про ключ, который ни
|
||
// во что не резолвится, значит заявить доказанность там, где её нет, и
|
||
// следующий разбор поверит записи вместо проверки (r1 Medium).
|
||
const roomOwner = key.startsWith('rl_') ? key.slice(3) : '';
|
||
const areaOwner = key.startsWith('grp_') ? key.slice(4) : '';
|
||
const owner = roomOwner
|
||
? (allRoomIds.has(roomOwner) ? 'live' : 'absent')
|
||
: areaOwner
|
||
? (allAreas.has(areaOwner) ? 'live' : 'absent')
|
||
: removedMarkerIds.has(key) ? 'absent'
|
||
: activeMarkerIds.has(key) ? 'live'
|
||
: 'unverified';
|
||
if (owner === 'absent') {
|
||
add('layout_space', key, space,
|
||
'пространства не существует, и владельца позиции тоже нет');
|
||
} else if (owner === 'live') {
|
||
notes.push({ invariant: 'references', kind: 'stale_layout_space', owner: key,
|
||
reference: space,
|
||
detail: 'пространства не существует, владелец жив — Optimize хранит позицию намеренно' });
|
||
} else {
|
||
// Тот же факт, что у ветки `unknown_owner` ниже, и та же формулировка:
|
||
// владельца в конфигурации не видно, отличить устройство HA от мусора
|
||
// по одному конфигу нельзя. Продукт в этом состоянии тоже ничего не
|
||
// удаляет — но не потому, что владелец жив, а потому, что не знает.
|
||
notes.push({ invariant: 'references', kind: 'unknown_owner', owner: key,
|
||
reference: key,
|
||
detail: 'владелец не найден в конфигурации (возможно устройство HA); пространства тоже нет' });
|
||
}
|
||
continue;
|
||
}
|
||
// Владелец позиции: подпись комнаты, групповая метка области либо маркер.
|
||
if (key.startsWith('rl_')) {
|
||
const roomId = key.slice(3);
|
||
const rooms = roomIdsBySpace.get(space);
|
||
if (rooms && !rooms.has(roomId)) {
|
||
add('layout_owner', key, roomId, 'комнаты не существует в этом пространстве');
|
||
}
|
||
continue;
|
||
}
|
||
if (key.startsWith('grp_')) {
|
||
const area = key.slice(4);
|
||
const areas = areasBySpace.get(space);
|
||
if (areas && areas.size && !areas.has(area)) {
|
||
add('layout_owner', key, area, 'в этом пространстве нет комнаты с такой областью');
|
||
}
|
||
continue;
|
||
}
|
||
if (!markerIds.has(key)) {
|
||
// Позиция может принадлежать устройству HA, у которого ещё нет записи в
|
||
// `markers`: маркеры создаются лениво, а позиция сохраняется сразу. По
|
||
// одной конфигурации это не отличить от мусора, поэтому — наблюдение, а
|
||
// не нарушение. Проверка с ложными срабатываниями умирает первой.
|
||
notes.push({ invariant: 'references', kind: 'unknown_owner', owner: key,
|
||
reference: key, detail: 'владелец не найден в конфигурации (возможно устройство HA)' });
|
||
}
|
||
}
|
||
|
||
for (const space of spaces) {
|
||
const spaceId = String(space?.id ?? '?');
|
||
const roomIds = roomIdsBySpace.get(spaceId) || new Set();
|
||
const wallSegments = new Map((space?.wall_segments || [])
|
||
.map((segment) => [String(segment?.id ?? ''), segment]).filter(([id]) => id));
|
||
for (const room of space?.rooms || []) {
|
||
const roomId = String(room?.id ?? '?');
|
||
for (const target of Array.isArray(room?.open_to) ? room.open_to : []) {
|
||
const targetId = String(target ?? '');
|
||
if (targetId && !roomIds.has(targetId)) {
|
||
add('room_open_to', `${spaceId}:${roomId}`, targetId,
|
||
'комнаты назначения не существует в том же пространстве');
|
||
}
|
||
}
|
||
if (Number(config?.model_version || 0) >= 8) {
|
||
const wallIds = Array.isArray(room?.wall_ids) ? room.wall_ids : [];
|
||
const poly = roomPolygon(room) || [];
|
||
if (wallIds.length !== poly.length) {
|
||
add('room_wall_ids', `${spaceId}:${roomId}`, String(wallIds.length),
|
||
`ожидалось по одному id для ${poly.length} рёбер`);
|
||
}
|
||
for (const target of wallIds) {
|
||
const targetId = String(target ?? '');
|
||
if (!targetId || !wallSegments.has(targetId)) {
|
||
add('room_wall_ids', `${spaceId}:${roomId}`, targetId || '?',
|
||
'сегмента стены не существует в том же пространстве');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const partitionIds = new Set((space?.partitions || [])
|
||
.map((partition) => String(partition?.id ?? '')).filter(Boolean));
|
||
for (const opening of space?.openings || []) {
|
||
const host = opening?.host;
|
||
if (!host) {
|
||
// #316 §3.3: since model v9 an unhosted contour opening is a valid
|
||
// degraded state (the migration keeps an opening with no in-place
|
||
// carrier as data, inert until re-placed). Only model v8 documents
|
||
// still require an explicit host on every opening.
|
||
if (Number(config?.model_version || 0) === 8) {
|
||
add('opening_host', `${spaceId}:${opening?.id ?? '?'}`, '?',
|
||
'в model v8 у проёма нет явной стены-хоста');
|
||
}
|
||
continue;
|
||
}
|
||
const target = String(host.id ?? '');
|
||
const exists = host?.kind === 'partition'
|
||
? partitionIds.has(target)
|
||
: host?.kind === 'wall' && wallSegments.has(target);
|
||
if (!target || !exists) {
|
||
add('opening_host', `${spaceId}:${opening?.id ?? '?'}`, target || '?',
|
||
'стены-хоста не существует в том же пространстве');
|
||
}
|
||
}
|
||
const list = carriers(space);
|
||
const tolerance = EDGE_TOLERANCE;
|
||
for (const wall of space?.walls || []) {
|
||
const a = point(wall?.a), b = point(wall?.b);
|
||
if (!a || !b) continue; // запись только с ключом — совместимость, не ссылка
|
||
if (!list.length || !onSomeCarrier([a, b], list, tolerance)) {
|
||
add('wall_carrier', `${spaceId}:${wall?.key ?? '?'}`, `${wall?.cm} см`,
|
||
'запись толщины не лежит ни на одном ребре комнаты и ни на одной перегородке');
|
||
}
|
||
}
|
||
for (const span of space?.open_spans || []) {
|
||
const a = point(span?.a), b = point(span?.b);
|
||
if (!a || !b) continue;
|
||
if (!list.length || !onSomeCarrier([a, b], list, tolerance)) {
|
||
add('open_span_carrier', `${spaceId}:${span?.id ?? '?'}`, 'open_span',
|
||
'виртуальный проём не лежит на границе существующих комнат');
|
||
}
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
/**
|
||
* Копия ключа отрезка из `src/wall-thickness.ts`.
|
||
*
|
||
* Дублировать формулу приходится: модуль сознательно читает сырой JSON без
|
||
* сборки, а `wallKey` живёт в TypeScript. Дубль величины, видимой в двух
|
||
* местах, — ровно тот дефект, который проект ловил трижды (#233, #234, #258),
|
||
* поэтому копия прикреплена тестом: `test/model-invariants.test.mjs` берёт
|
||
* настоящий `wallKey` из `test-build` и сверяет с этой копией на наборе
|
||
* отрезков, включая попадающие в ничью округления. Разойдутся — покраснеет.
|
||
*/
|
||
const quantise = (value, pitch) => (!(pitch > 0) || !Number.isFinite(value)
|
||
? value : Math.round(value / pitch) * pitch);
|
||
|
||
const keyEpsilon = (pitch) => Math.max(Math.abs(pitch) * 1e-6, 1e-9);
|
||
const canonicalKeyCoordinate = (value, pitch) => {
|
||
if (!(pitch > 0) || !Number.isFinite(value)) return value;
|
||
const snapped = quantise(value, pitch);
|
||
return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;
|
||
};
|
||
|
||
const segmentDirection = (a, b) => {
|
||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const length = Math.hypot(dx, dy);
|
||
if (length < 1e-12) return [1, 0];
|
||
dx /= length; dy /= length;
|
||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) return [-dx, -dy];
|
||
return [dx, dy];
|
||
};
|
||
|
||
export function wallKey(a, b, pitch = GRID_STEP_N) {
|
||
const ca = [canonicalKeyCoordinate(a[0], pitch), canonicalKeyCoordinate(a[1], pitch)];
|
||
const cb = [canonicalKeyCoordinate(b[0], pitch), canonicalKeyCoordinate(b[1], pitch)];
|
||
const mx = quantise((ca[0] + cb[0]) / 2, pitch);
|
||
const my = quantise((ca[1] + cb[1]) / 2, pitch);
|
||
const [dx, dy] = segmentDirection(ca, cb);
|
||
let angle = Math.atan2(dy, dx);
|
||
if (angle < 0) angle += Math.PI;
|
||
const bucket = Math.round(angle * 1800) / 1800;
|
||
const precision = pitch > 0 && pitch < 0.01 ? 6 : pitch < 1 ? 4 : 2;
|
||
return `${mx.toFixed(precision)},${my.toFixed(precision)}@${bucket.toFixed(4)}`;
|
||
};
|
||
|
||
/** Середина, записанная в ключе. `null` — ключ не разбирается как координаты. */
|
||
export function keyMidpoint(key) {
|
||
const match = /^(-?[0-9.]+),(-?[0-9.]+)@(-?[0-9.]+)$/.exec(String(key ?? ''));
|
||
if (!match) return null;
|
||
const x = Number(match[1]), y = Number(match[2]);
|
||
return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
|
||
}
|
||
|
||
/**
|
||
* Диагностика совместимого ключа записи толщины (#258, #259).
|
||
*
|
||
* После #258 точная пара `a/b` является строгой идентичностью того же span и
|
||
* разрешается до legacy midpoint fallback. Поэтому любой отличный или даже
|
||
* неразбираемый compatibility key у записи с валидными endpoints — наблюдение,
|
||
* а не нарушение: runtime найдёт запись по endpoints, а явный Optimize
|
||
* перепишет стабильный key. Legacy key-only запись проверить и исправить по
|
||
* догадке нельзя, поэтому она по-прежнему пропускается.
|
||
*/
|
||
export function checkWallKeys(config, { notes = [] } = {}) {
|
||
const violations = [];
|
||
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
|
||
const spaceId = String(space?.id ?? '?');
|
||
for (const wall of space?.walls || []) {
|
||
const a = point(wall?.a), b = point(wall?.b);
|
||
// Запись только с ключом — совместимость: сверять не с чем, и это не
|
||
// повод объявлять её сломанной.
|
||
if (!a || !b) continue;
|
||
if (typeof wall?.key !== 'string' || !wall.key) continue;
|
||
const expected = wallKey(a, b);
|
||
if (wall.key === expected) continue;
|
||
const owner = `${spaceId}:${wall.key}`;
|
||
const stored = keyMidpoint(wall.key);
|
||
const drift = stored
|
||
? Math.hypot(stored[0] - (a[0] + b[0]) / 2, stored[1] - (a[1] + b[1]) / 2)
|
||
/ GRID_STEP_N
|
||
: null;
|
||
const driftText = drift === null ? 'ключ не разбирается как координаты'
|
||
: `середина отличается на ${drift.toFixed(2)} шага`;
|
||
notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,
|
||
reference: expected,
|
||
detail: `${driftText}; запись находится по точной паре endpoints,`
|
||
+ ' явный Optimize перепишет совместимый ключ' });
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
/**
|
||
* Стадия 0 из ADR #282: профиль отклонений от решётки.
|
||
*
|
||
* Весь класс #258/#279/#248 держится на одном свойстве представления: узел
|
||
* решётки это `k/240`, число без точного двоичного представления, а хранимая
|
||
* координата это float. Сколько реальной геометрии лежит вне решётки и
|
||
* насколько далеко — не знает никто, а Optimize берётся «убрать шум
|
||
* координат», не имея проверяемого определения шума.
|
||
*
|
||
* Здесь ничего не судится и не чинится: считается профиль. Три населения
|
||
* разделены намеренно, потому что это разные проблемы, а не разные степени
|
||
* одной:
|
||
*
|
||
* exact — точно на узле, делать нечего;
|
||
* noise — рядом с узлом, но не точно. ЭТО класс дефектов: ключ стены
|
||
* попадает не в тот бакет, Optimize не сходится, «почти
|
||
* ортогональная» стена рисует клин;
|
||
* offGrid — далеко от узла. Законная по нынешней модели геометрия: авторские
|
||
* координаты фикстур (0.06 — это 14.4 шага от узла) ничем не
|
||
* запрещены. Объявить их нарушением значит получить проверку,
|
||
* которую отключат в первую неделю.
|
||
*
|
||
* Граница между noise и offGrid — доля шага, а не абсолют: `NOISE_STEPS`.
|
||
* Взята на четыре порядка ниже шага, то есть заведомо ниже любого осмысленного
|
||
* пользовательского ввода и заведомо выше двоичного мусора одиночной операции.
|
||
*/
|
||
const latticeDeviation = (value) => {
|
||
const steps = value * GRID_N;
|
||
return Math.abs(steps - Math.round(steps));
|
||
};
|
||
|
||
/** Все координаты модели с адресом, по которому их можно найти глазами. */
|
||
function* modelCoordinates(config, layout = {}) {
|
||
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
|
||
const spaceId = String(space?.id ?? '?');
|
||
for (const room of space?.rooms || []) {
|
||
const poly = Array.isArray(room?.poly) ? room.poly : [];
|
||
for (const [index, p] of poly.entries()) {
|
||
const point = pointOf(p);
|
||
if (point) yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}#${index}`, point };
|
||
}
|
||
const origin = pointOf([room?.x, room?.y]);
|
||
if (origin) yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}.origin`, point: origin };
|
||
const size = pointOf([room?.w, room?.h]);
|
||
if (size) yield { kind: 'room', owner: `${spaceId}:${room?.id ?? '?'}.size`, point: size };
|
||
}
|
||
for (const part of space?.partitions || []) {
|
||
for (const end of ['a', 'b']) {
|
||
const point = pointOf(part?.[end]);
|
||
if (point) yield { kind: 'partition', owner: `${spaceId}:${part?.id ?? '?'}.${end}`, point };
|
||
}
|
||
}
|
||
for (const wall of space?.walls || []) {
|
||
for (const end of ['a', 'b']) {
|
||
const point = pointOf(wall?.[end]);
|
||
if (point) yield { kind: 'wall', owner: `${spaceId}:${wall?.key ?? '?'}.${end}`, point };
|
||
}
|
||
}
|
||
for (const span of space?.open_spans || []) {
|
||
for (const end of ['a', 'b']) {
|
||
const point = pointOf(span?.[end]);
|
||
if (point) yield { kind: 'open_span', owner: `${spaceId}:${span?.id ?? '?'}.${end}`, point };
|
||
}
|
||
}
|
||
for (const column of space?.wall_columns || []) {
|
||
const point = pointOf(column?.center);
|
||
if (point) yield { kind: 'column', owner: `${spaceId}:${column?.id ?? '?'}`, point };
|
||
}
|
||
for (const opening of space?.openings || []) {
|
||
const point = pointOf([opening?.x, opening?.y]);
|
||
if (point) yield { kind: 'opening', owner: `${spaceId}:${opening?.id ?? '?'}`, point };
|
||
}
|
||
for (const draft of space?.room_drafts || []) {
|
||
for (const [index, value] of (draft?.points || []).entries()) {
|
||
const point = pointOf(value);
|
||
if (point) yield { kind: 'room_draft', owner: `${spaceId}:${draft?.id ?? '?'}#${index}`, point };
|
||
}
|
||
}
|
||
for (const decor of space?.decor || []) {
|
||
const id = `${spaceId}:${decor?.id ?? '?'}`;
|
||
if (decor?.kind === 'line') {
|
||
const a = pointOf([decor?.x1, decor?.y1]);
|
||
const b = pointOf([decor?.x2, decor?.y2]);
|
||
if (a) yield { kind: 'decor', owner: `${id}.a`, point: a };
|
||
if (b) yield { kind: 'decor', owner: `${id}.b`, point: b };
|
||
} else if (['rect', 'ellipse', 'furniture'].includes(decor?.kind)) {
|
||
const origin = pointOf([decor?.x, decor?.y]);
|
||
const size = pointOf([decor?.w, decor?.h]);
|
||
if (origin) yield { kind: 'decor', owner: `${id}.origin`, point: origin };
|
||
if (size) yield { kind: 'decor', owner: `${id}.size`, point: size };
|
||
} else if (decor?.kind === 'text') {
|
||
const point = pointOf([decor?.x, decor?.y]);
|
||
if (point) yield { kind: 'decor', owner: id, point };
|
||
}
|
||
}
|
||
}
|
||
for (const [key, position] of Object.entries(layout || {})) {
|
||
const point = pointOf([position?.x, position?.y]);
|
||
if (point) yield { kind: 'layout', owner: key, point };
|
||
}
|
||
}
|
||
|
||
const pointOf = (value) => (Array.isArray(value) && isFiniteNumber(value[0])
|
||
&& isFiniteNumber(value[1]) ? [value[0], value[1]] : null);
|
||
|
||
/**
|
||
* Профиль модели: сколько координат точно на узле, сколько в шуме, сколько
|
||
* законно вне сетки. Ни одного нарушения не возвращается — по построению.
|
||
*/
|
||
export function latticeProfile({ config, layout = {} } = {}) {
|
||
const buckets = { exact: 0, noise: 0, offGrid: 0 };
|
||
const offGridValues = new Set();
|
||
const byKind = new Map();
|
||
let worstNoise = null;
|
||
let total = 0;
|
||
for (const { kind, owner, point } of modelCoordinates(config, layout)) {
|
||
for (const [axis, value] of [['x', point[0]], ['y', point[1]]]) {
|
||
const deviation = latticeDeviation(value);
|
||
const bucket = deviation === 0 ? 'exact' : deviation < NOISE_STEPS ? 'noise' : 'offGrid';
|
||
if (bucket === 'offGrid') offGridValues.add(Number(value).toFixed(12));
|
||
total++;
|
||
buckets[bucket]++;
|
||
const seen = byKind.get(kind) || { exact: 0, noise: 0, offGrid: 0 };
|
||
seen[bucket]++;
|
||
byKind.set(kind, seen);
|
||
if (bucket === 'noise' && (!worstNoise || deviation > worstNoise.steps)) {
|
||
worstNoise = { kind, owner, axis, value, steps: deviation };
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
total,
|
||
...buckets,
|
||
offGridUnique: offGridValues.size,
|
||
noiseSteps: NOISE_STEPS,
|
||
worstNoise,
|
||
byKind: Object.fromEntries([...byKind].map(([kind, counts]) => [kind, counts])),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Инвариант 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;
|
||
}
|
||
|
||
/**
|
||
* Геометрия, которая ничего не рисует, но выключает ручки ресайза (#296).
|
||
*
|
||
* Зачем отдельная проверка. Перегородка, лежащая ровно под стеной комнаты, и
|
||
* черновик из двух точек не видны на плане и не портят ни один снимок модели —
|
||
* ни ключи, ни роли, ни решётку, ни кладку. При этом `resolveSafeResize`
|
||
* законно отказывает по ним `duplicate-physical-wall`, и пользователь получает
|
||
* выключенную ручку с подсказкой про объект, который нельзя ни увидеть, ни
|
||
* выделить, ни удалить. Ни один прежний гейт этого класса не видит: все они
|
||
* измеряют снимок, а эта геометрия снимок не портит.
|
||
*
|
||
* Судится только доказуемое: перекрытие по длине больше шага решётки — то есть
|
||
* не касание углом, — и черновик, который не может стать комнатой ни при какой
|
||
* последующей правке.
|
||
*/
|
||
const collinearOverlapN = (a, b, c, d) => {
|
||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||
const length = Math.hypot(dx, dy);
|
||
if (!(length > GRID_STEP_N)) return 0;
|
||
const ux = dx / length, uy = dy / length;
|
||
const across = (p) => Math.abs((p[0] - a[0]) * uy - (p[1] - a[1]) * ux);
|
||
if (across(c) > EDGE_TOLERANCE || across(d) > EDGE_TOLERANCE) return 0;
|
||
const along = (p) => (p[0] - a[0]) * ux + (p[1] - a[1]) * uy;
|
||
const lo = Math.max(0, Math.min(along(c), along(d)));
|
||
const hi = Math.min(length, Math.max(along(c), along(d)));
|
||
return Math.max(0, hi - lo);
|
||
};
|
||
|
||
/** Точки черновика как есть: замыкание контура здесь не предполагается. */
|
||
const draftSegments = (draft) => {
|
||
const points = Array.isArray(draft?.points) ? draft.points.map(point).filter(Boolean) : [];
|
||
const segments = [];
|
||
for (let i = 0; i + 1 < points.length; i++) segments.push([points[i], points[i + 1]]);
|
||
return { points, segments };
|
||
};
|
||
|
||
export function checkHiddenObstacles(config) {
|
||
const violations = [];
|
||
for (const space of Array.isArray(config?.spaces) ? config.spaces : []) {
|
||
const spaceId = String(space?.id ?? '?');
|
||
const edges = [];
|
||
for (const room of space?.rooms || []) {
|
||
const poly = roomPolygon(room);
|
||
if (!poly) continue;
|
||
for (const [a, b] of edgesOf(poly)) edges.push({ a, b, room: String(room?.id ?? '?') });
|
||
}
|
||
const longestOverlap = (a, b) => {
|
||
let best = null;
|
||
for (const edge of edges) {
|
||
const overlap = collinearOverlapN(edge.a, edge.b, a, b);
|
||
if (overlap > GRID_STEP_N && (!best || overlap > best.overlap)) {
|
||
best = { overlap, room: edge.room };
|
||
}
|
||
}
|
||
return best;
|
||
};
|
||
for (const partition of space?.partitions || []) {
|
||
const a = point(partition?.a), b = point(partition?.b);
|
||
if (!a || !b) continue;
|
||
const hit = longestOverlap(a, b);
|
||
if (!hit) continue;
|
||
violations.push({
|
||
invariant: 'hidden_obstacles', kind: 'partition_over_room_wall',
|
||
owner: `${spaceId}:${partition?.id ?? '?'}`,
|
||
reference: `${(hit.overlap / GRID_STEP_N).toFixed(0)} шагов по стене ${hit.room}`,
|
||
detail: 'перегородка лежит на стене комнаты: на плане её не видно,'
|
||
+ ' а ресайз этой стены она выключает',
|
||
});
|
||
}
|
||
for (const draft of space?.room_drafts || []) {
|
||
const { points, segments } = draftSegments(draft);
|
||
if (points.length < 3) {
|
||
violations.push({
|
||
invariant: 'hidden_obstacles', kind: 'unusable_draft',
|
||
owner: `${spaceId}:${draft?.id ?? '?'}`,
|
||
reference: `${points.length} точки`,
|
||
detail: 'контур не может стать комнатой ни при какой правке,'
|
||
+ ' но препятствием для ресайза остаётся',
|
||
});
|
||
continue;
|
||
}
|
||
for (const [a, b] of segments) {
|
||
const hit = longestOverlap(a, b);
|
||
if (!hit) continue;
|
||
violations.push({
|
||
invariant: 'hidden_obstacles', kind: 'draft_over_room_wall',
|
||
owner: `${spaceId}:${draft?.id ?? '?'}`,
|
||
reference: `${(hit.overlap / GRID_STEP_N).toFixed(0)} шагов по стене ${hit.room}`,
|
||
detail: 'незакрытый контур лежит на стене комнаты и выключает её ресайз',
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
/** Разобрать runtime-ответы, сырой config и tracked single-space fixtures. */
|
||
export function readModel(text) {
|
||
const parsed = JSON.parse(text);
|
||
const source = parsed?.payload?.config ?? parsed?.result?.config ?? parsed?.config ?? parsed;
|
||
const config = !Array.isArray(source?.spaces) && parsed?.space
|
||
? { spaces: [parsed.space] }
|
||
: source;
|
||
const layout = parsed?.payload?.layout ?? parsed?.result?.layout ?? parsed?.layout ?? {};
|
||
return { config, layout };
|
||
}
|
||
|
||
/** Production structural pass with bounded, anonymised diagnostics. */
|
||
export function checkPhysicalGeometry(config) {
|
||
let result;
|
||
try { result = checkOptimizeGeometry(config); } catch {
|
||
return [{
|
||
invariant: 'physical_geometry', kind: 'geometry_exception', owner: 'config',
|
||
reference: 'prepare-exception', detail: 'production geometry check failed',
|
||
}];
|
||
}
|
||
return result.failures.map((failure, index) => ({
|
||
invariant: 'physical_geometry', kind: 'physical_geometry',
|
||
owner: `space[${index + 1}]`, reference: failure.reason,
|
||
detail: 'canonical wall geometry is not safe for a write',
|
||
}));
|
||
}
|
||
|
||
/** Наблюдения перечисляются по смыслу: «их 39» читателю ничего не говорит. */
|
||
function noteSummary(notes) {
|
||
const counts = new Map();
|
||
for (const note of notes) counts.set(note.kind, (counts.get(note.kind) || 0) + 1);
|
||
const titles = {
|
||
unknown_owner: 'позиции без записи маркера',
|
||
stale_layout_space: 'позиции живых владельцев на удалённых пространствах',
|
||
stale_wall_key: 'записей толщины используют exact endpoints вместо своего ключа',
|
||
};
|
||
return [...counts].map(([kind, n]) => `${n} — ${titles[kind] || kind}`).join('; ') + '.';
|
||
}
|
||
|
||
/** Профиль решётки на языке решения, а не на языке счётчиков (ADR #282). */
|
||
function latticeReport(profile) {
|
||
const share = (n) => (profile.total ? `${(n / profile.total * 100).toFixed(2)}%` : '—');
|
||
const lines = [
|
||
`Координат в модели: ${profile.total}.`,
|
||
'',
|
||
` точно на узле ${profile.exact} (${share(profile.exact)})`,
|
||
` шум у узла ${profile.noise} (${share(profile.noise)})`
|
||
+ ` — ближе ${profile.noiseSteps} шага, но не точно`,
|
||
` законно вне сетки ${profile.offGrid} (${share(profile.offGrid)})`,
|
||
'',
|
||
];
|
||
if (profile.worstNoise) {
|
||
const w = profile.worstNoise;
|
||
lines.push(`Худший шум: ${w.owner} по ${w.axis} = ${w.value}`
|
||
+ ` — ${w.steps.toExponential(2)} шага от узла.`, '');
|
||
}
|
||
lines.push('По видам объектов (точно / шум / вне сетки):');
|
||
for (const [kind, counts] of Object.entries(profile.byKind)) {
|
||
lines.push(` ${kind.padEnd(11)} ${counts.exact} / ${counts.noise} / ${counts.offGrid}`);
|
||
}
|
||
lines.push('', 'Шум — это класс дефектов #258/#279/#248: ключ стены попадает не в тот',
|
||
'бакет, Optimize не сходится. «Вне сетки» — законная геометрия нынешней модели.',
|
||
'Стадия 0 ADR #282 измеряет, а не судит: нарушений здесь не бывает.');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function report(violations, notes = []) {
|
||
if (!violations.length) {
|
||
const tail = notes.length
|
||
? `\nНаблюдений (не нарушения): ${notes.length}. ` + noteSummary(notes)
|
||
: '';
|
||
return 'Инварианты выполнены: ссылки разрешимы, записи толщины находятся.'
|
||
+ tail;
|
||
}
|
||
const lines = [`Нарушений: ${violations.length}.`, ''];
|
||
const byKind = new Map();
|
||
for (const violation of violations) {
|
||
const list = byKind.get(violation.kind) || [];
|
||
list.push(violation);
|
||
byKind.set(violation.kind, list);
|
||
}
|
||
const titles = {
|
||
marker_space: 'Маркеры ссылаются на несуществующие пространства',
|
||
marker_room: 'Маркеры ссылаются на несуществующие комнаты',
|
||
vacuum_room: 'Сегменты пылесоса ссылаются на несуществующие комнаты',
|
||
marker_control: 'Управление светом ссылается на несовместимый маркер',
|
||
marker_badge: 'Бейдж значения ссылается на несовместимый маркер',
|
||
room_open_to: 'Связи комнат ссылаются на несуществующие комнаты',
|
||
opening_host: 'Проёмы ссылаются на несуществующие стены',
|
||
room_wall_ids: 'Комнаты ссылаются на несуществующие сегменты стен',
|
||
layout_space: 'Позиции на несуществующих пространствах, владельцев тоже нет',
|
||
layout_owner: 'Позиции без владельца',
|
||
wall_carrier: 'Записи толщины вне рёбер и перегородок',
|
||
open_span_carrier: 'Виртуальные проёмы вне границ комнат',
|
||
physical_geometry: 'Небезопасная каноническая геометрия стен',
|
||
geometry_exception: 'Сбой проверки канонической геометрии стен',
|
||
lost: 'Потерянные записи толщины',
|
||
wall_key: 'Записи толщины, которые не найдутся по ключу',
|
||
mixed_role_record: 'Записи толщины, описывающие сразу общую и наружную стену',
|
||
partition_over_room_wall: 'Перегородки, лежащие на стенах комнат',
|
||
draft_over_room_wall: 'Незакрытые контуры, лежащие на стенах комнат',
|
||
unusable_draft: 'Черновики, которые не могут стать комнатой',
|
||
};
|
||
for (const [kind, list] of byKind) {
|
||
lines.push(`${titles[kind] || kind}: ${list.length}`);
|
||
for (const violation of list.slice(0, 12)) {
|
||
lines.push(` ${violation.owner} → ${violation.reference}: ${violation.detail}`);
|
||
}
|
||
if (list.length > 12) lines.push(` и ещё ${list.length - 12}`);
|
||
lines.push('');
|
||
}
|
||
return lines.join('\n').trimEnd();
|
||
}
|
||
|
||
function main(argv) {
|
||
const arg = (name) => {
|
||
const index = argv.indexOf(name);
|
||
return index >= 0 ? argv[index + 1] : undefined;
|
||
};
|
||
const configPath = arg('--config');
|
||
if (!configPath) {
|
||
console.error('использование: model-invariants.mjs --config <файл> [--layout <файл>]'
|
||
+ ' [--lattice|--near-axis] [--json]');
|
||
return 2;
|
||
}
|
||
const model = readModel(readFileSync(configPath, 'utf8'));
|
||
if (arg('--layout')) model.layout = readModel(readFileSync(arg('--layout'), 'utf8')).layout;
|
||
const notes = [];
|
||
if (argv.includes('--lattice')) {
|
||
const profile = latticeProfile(model);
|
||
if (argv.includes('--json')) console.log(JSON.stringify({ lattice: profile }, null, 2));
|
||
else console.log(latticeReport(profile));
|
||
return 0;
|
||
}
|
||
if (argv.includes('--near-axis')) {
|
||
const profile = nearAxisProfile(model.config);
|
||
if (argv.includes('--json')) console.log(JSON.stringify({ nearAxis: profile }, null, 2));
|
||
else {
|
||
console.log(`Почти осевых физических стен: ${profile.total}.`);
|
||
for (const space of profile.spaces.filter((item) => item.count)) {
|
||
console.log(` ${space.spaceId || '(без id)'}: ${space.count}`);
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
const violations = [
|
||
...checkReferences(model, { notes }),
|
||
...checkWallKeys(model.config, { notes }),
|
||
...checkMixedRoleRecords(model.config),
|
||
...checkHiddenObstacles(model.config),
|
||
...checkPhysicalGeometry(model.config),
|
||
];
|
||
if (argv.includes('--json')) console.log(JSON.stringify({ violations, notes }, null, 2));
|
||
else console.log(report(violations, notes));
|
||
// Код возврата — не приговор конфигурации пользователя, а сигнал для CI.
|
||
return violations.length ? 1 : 0;
|
||
}
|
||
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||
process.exit(main(process.argv.slice(2)));
|
||
}
|