fix: enforce exact lattice coordinates

Issue: #291
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 17:13:21 +03:00
parent c8a3878568
commit 4ccf4a3ccc
31 changed files with 1213 additions and 316 deletions
+2 -2
View File
@@ -36,7 +36,7 @@ test('issue 276 reconciles the anonymized 5 cm offset fixture without moving its
assert.equal(space.openings.length, 1);
assert.deepEqual(space.openings[0], {
id: 'hosted-door', type: 'door',
x: 0.504166667, y: 0.5, angle: -90, length: 0.2,
x: 121 / 240, y: 0.5, angle: -90, length: 0.2,
contact: 'binary_sensor.test_door', lock: 'lock.test_door',
invert: true, flip_h: true, future_field: { keep: true },
});
@@ -100,7 +100,7 @@ test('issue 276 rehosts three non-overlapping door/window/gate openings atomical
['hosted-door', 'hosted-window', 'hosted-gate']);
assert.deepEqual(openings.map((opening) => opening.type), ['door', 'window', 'gate']);
assert.ok(openings.every((opening) => opening.host === undefined));
assert.ok(openings.every((opening) => opening.x === 0.504166667));
assert.ok(openings.every((opening) => opening.x === 121 / 240));
assert.ok(openings[0].y < openings[1].y && openings[1].y < openings[2].y);
assert.equal(openings[0].contact, 'binary_sensor.door');
assert.equal(openings[1].cover, 'cover.window');
+59 -2
View File
@@ -4,10 +4,16 @@ import test from 'node:test';
import {
COORDINATE_DECIMALS,
LATTICE_GRID_N,
LATTICE_NOISE_STEPS,
canonicalizeConfigGeometry,
canonicalizeConfigGeometryInPlace,
canonicalizeLatticeCoordinate,
canonicalizeLayoutGeometry,
canonicalizeNumber,
canonicalizePosition,
formatLatticeShiftCm,
latticeCanonicalizationReport,
} from '../test-build/coordinate-canonicalization.js';
const fixture = JSON.parse(readFileSync(
@@ -15,7 +21,7 @@ const fixture = JSON.parse(readFileSync(
'utf8',
));
test('frontend and backend share the nine-decimal fixture contract (#224)', () => {
test('frontend and backend share the scalar+lattice fixture contract (#291)', () => {
assert.equal(COORDINATE_DECIMALS, fixture.decimals);
const configBefore = structuredClone(fixture.configInput);
const layoutBefore = structuredClone(fixture.layoutInput);
@@ -41,13 +47,64 @@ test('scalar canonicalization is symmetric and never snaps off-grid geometry (#2
assert.equal(canonicalizeNumber(Number.POSITIVE_INFINITY), Number.POSITIVE_INFINITY);
});
test('all 4801 lattice nodes and their nine-decimal forms share exact bits (#291)', () => {
assert.equal(LATTICE_GRID_N, 240);
assert.equal(LATTICE_NOISE_STEPS, 1e-4);
for (let k = -2400; k <= 2400; k++) {
const node = k / LATTICE_GRID_N;
const nineDecimal = Number(node.toFixed(COORDINATE_DECIMALS));
assert.equal(canonicalizeLatticeCoordinate(node), node, `node ${k}`);
assert.equal(canonicalizeLatticeCoordinate(nineDecimal), node, `stored node ${k}`);
assert.equal(canonicalizeLatticeCoordinate(
canonicalizeLatticeCoordinate(nineDecimal),
), node, `idempotent node ${k}`);
}
assert.equal(Object.is(canonicalizeLatticeCoordinate(-0), -0), false);
assert.equal(canonicalizeLatticeCoordinate(0.06), 0.06);
assert.equal(canonicalizeLatticeCoordinate(0.2875), 0.2875);
assert.equal(canonicalizeLatticeCoordinate((1 + 0.999e-4) / 240), 1 / 240);
assert.equal(canonicalizeLatticeCoordinate((1 + 1.001e-4) / 240), 0.004167084);
});
test('lattice report separates noise, far values and physical per-space maxima (#291)', () => {
const node = 83 / 240;
const noise = Number(node.toFixed(9));
const report = latticeCanonicalizationReport({ spaces: [
{ id: 'quiet', title: 'Quiet', cell_cm: 1, rooms: [{ poly: [[0, 0], [0.06, 0]] }] },
{ id: 'touched', title: 'Touched', cell_cm: 10, rooms: [{ poly: [[noise, node], [0.06, 0]] }] },
] }, { marker: { s: 'touched', x: noise, y: 0 }, orphan: { x: noise, y: 0 } });
assert.equal(report.canonicalized, 3);
assert.equal(report.far, 2);
assert.equal(report.spaces.length, 1, 'unchanged spaces stay out of the breakdown');
assert.deepEqual(report.spaces[0], {
spaceId: 'touched', space: 'Touched', canonicalized: 2, far: 1,
maxShift: Math.abs(node - noise),
maxShiftCm: Math.abs(node - noise) * 240 * 10,
});
assert.equal(report.maxShift, Math.abs(node - noise));
assert.equal(report.maxShiftCm, Math.abs(node - noise) * 240 * 10);
});
test('lattice movement format keeps three significant digits without fake 0.1 cm (#291)', () => {
assert.equal(formatLatticeShiftCm(0), '0');
assert.equal(formatLatticeShiftCm(0.000033), '3.30e-5');
assert.equal(formatLatticeShiftCm(0.0012345), '0.00123');
assert.equal(formatLatticeShiftCm(1.2), '1.2');
});
test('Optimize can apply the same barrier in-place without a second clone (#291)', () => {
const input = structuredClone(fixture.configInput);
assert.equal(canonicalizeConfigGeometryInPlace(input), input);
assert.deepEqual(input, fixture.configExpected);
});
test('one position changes only x/y and preserves future metadata (#224)', () => {
const input = {
s: 'floor', x: 0.1000000006, y: -0.0000000004,
k: 1.0000000004, nested: { numeric: 0.1234567896 },
};
assert.deepEqual(canonicalizePosition(input), {
s: 'floor', x: 0.100000001, y: 0,
s: 'floor', x: 0.1, y: 0,
k: 1.0000000004, nested: { numeric: 0.1234567896 },
});
});
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { checkCoordinateWriteBarriers } from '../scripts/coordinate-write-barrier-guard.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
test('all frontend and backend coordinate writers converge on one boundary (#291)', () => {
assert.deepEqual(checkCoordinateWriteBarriers(repoRoot), []);
});
+8 -8
View File
@@ -12,9 +12,9 @@
"name": "Left",
"area": null,
"poly": [
[0, 0], [0.5, 0], [0.5, 0.004166667],
[0.504166667, 0.004166667], [0.504166667, 0.995833333],
[0.5, 0.995833333], [0.5, 1], [0, 1]
[0, 0], [0.5, 0], [0.5, 0.004166666666666667],
[0.5041666666666667, 0.004166666666666667], [0.5041666666666667, 0.9958333333333333],
[0.5, 0.9958333333333333], [0.5, 1], [0, 1]
]
},
{
@@ -22,8 +22,8 @@
"name": "Right",
"area": null,
"poly": [
[0.504166667, 0.004166667], [1, 0.004166667],
[1, 0.995833333], [0.504166667, 0.995833333]
[0.5041666666666667, 0.004166666666666667], [1, 0.004166666666666667],
[1, 0.9958333333333333], [0.5041666666666667, 0.9958333333333333]
]
}
],
@@ -31,15 +31,15 @@
{
"key": "0.504167,0.500000@1.5706",
"cm": 20,
"a": [0.504166667, 0.004166667],
"b": [0.504166667, 0.995833333]
"a": [0.5041666666666667, 0.004166666666666667],
"b": [0.5041666666666667, 0.9958333333333333]
}
],
"openings": [
{
"id": "hosted-door",
"type": "door",
"x": 0.504166667,
"x": 0.5041666666666667,
"y": 0.5,
"angle": -90,
"length": 0.2,
+19 -19
View File
@@ -241,9 +241,9 @@
"name": "Rect",
"area": null,
"x": 0.4,
"y": 0.400000001,
"y": 0.4,
"w": 0.2,
"h": 0.200000001
"h": 0.2
}
],
"walls": [
@@ -251,7 +251,7 @@
"key": "wall",
"cm": 15.0000000004,
"a": [0, 0.3],
"b": [0.300000001, 0.300000001]
"b": [0.3, 0.3]
}
],
"openings": [
@@ -274,9 +274,9 @@
"id": "line",
"kind": "line",
"x1": 0.1,
"y1": 0.100000001,
"y1": 0.1,
"x2": 0.2,
"y2": 0.200000001,
"y2": 0.2,
"width_cm": 2.0000000004,
"opacity": 0.7000000004
},
@@ -284,9 +284,9 @@
"id": "rect-decor",
"kind": "rect",
"x": 0.1,
"y": 0.200000001,
"y": 0.2,
"w": 0.3,
"h": 0.400000001,
"h": 0.4,
"angle": 12.12345679,
"fill_opacity": 0.6000000004
},
@@ -294,8 +294,8 @@
"id": "ellipse",
"kind": "ellipse",
"x": -0.1,
"y": -0.200000001,
"w": 0.300000001,
"y": -0.2,
"w": 0.3,
"h": 0.4,
"angle": -12.12345679
},
@@ -303,7 +303,7 @@
"id": "text",
"kind": "text",
"x": 0.7,
"y": 0.800000001,
"y": 0.8,
"text": "Text",
"scale": 1.234567891,
"angle": 2.12345679,
@@ -314,9 +314,9 @@
"kind": "furniture",
"symbol": "chair",
"x": 0.6,
"y": 0.600000001,
"y": 0.6,
"w": 0.1,
"h": 0.100000001,
"h": 0.1,
"angle": 90.12345679
}
],
@@ -324,9 +324,9 @@
{
"id": "draft",
"points": [
[0.1, 0.900000001],
[0.200000001, 0.9],
[0.3, 0.900000001]
[0.1, 0.9],
[0.2, 0.9],
[0.3, 0.9]
],
"segments": [
{"cm": 10.0000000004},
@@ -346,15 +346,15 @@
{
"id": "column",
"shape": "square",
"center": [0.75, 0.750000001],
"center": [0.75, 0.75],
"cm": 30.0000000004,
"angle": 45.12345679
}
],
"open_spans": [
{
"a": [0.3, 0.300000001],
"b": [0.600000001, 0.3]
"a": [0.3, 0.3],
"b": [0.6, 0.3]
}
],
"future": {
@@ -428,7 +428,7 @@
"rl:poly": {
"s": "floor",
"x": 0,
"y": 0.750000001,
"y": 0.75,
"k": 1.5000000004
}
}
+6 -6
View File
@@ -70,10 +70,10 @@
"id": "fine-room",
"name": "Fine room",
"poly": [
[0.004166667, 0],
[0.008333333, 0],
[0.008333333, 0.008333333],
[0.004166667, 0.008333333]
[0.004166666666666667, 0],
[0.008333333333333333, 0],
[0.008333333333333333, 0.008333333333333333],
[0.004166666666666667, 0.008333333333333333]
]
}
]
@@ -103,8 +103,8 @@
"layout": {
"sensor.fine": {
"s": "fine",
"x": 0.004166667,
"y": 0.008333333
"x": 0.004166666666666667,
"y": 0.008333333333333333
},
"sensor.regular": {
"s": "regular",
+23 -1
View File
@@ -62,7 +62,29 @@ test('Optimize distinguishes updated spaces from cleaned coordinate noise', () =
assert.match(cardSource, /i: String\(r\.partitionsMerged\)/);
assert.match(cardSource, /gs\.optimize_coincident_partitions/);
assert.match(cardSource, /gs\.optimize_openings_rehosted/);
assert.match(cardSource, /d\.report\.coordsCanonicalized \+ d\.report\.wallsMerged/);
assert.match(cardSource,
/d\.report\.coordsCanonicalized \+ d\.report\.latticeCoordinatesCanonicalized/);
});
test('issue 291 Optimize reports lattice cleanup separately in both languages', () => {
assert.equal(
en['gs.optimize_lattice_summary'],
'Noisy coordinate values canonicalized: {n}; maximum movement: {cm} cm.',
);
assert.equal(
ru['gs.optimize_lattice_summary'],
'Канонизировано шумовых значений координат: {n}; максимальный сдвиг: {cm} см.',
);
assert.equal(
en['gs.optimize_lattice_space'],
'{space}: coordinate values canonicalized: {n}; off-grid values left unchanged: {far}.',
);
assert.equal(
ru['gs.optimize_lattice_space'],
'{space}: канонизировано значений координат: {n}; оставлено значений вне сетки: {far}.',
);
assert.match(cardSource, /formatLatticeShiftCm\(r\.latticeMaxShiftCm\)/);
assert.match(cardSource, /r\.latticeSpaces\.map/);
});
test('issue 252 Optimize keeps internal ids out of the main orphan report', () => {
+43
View File
@@ -10,6 +10,9 @@ import {
} 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';
import {
canonicalizeConfigGeometry, latticeCanonicalizationReport,
} from '../test-build/coordinate-canonicalization.js';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -355,6 +358,28 @@ test('профиль видит все виды объектов, а не тол
assert.equal(profile.total, 18);
});
test('профиль решётки покрывает полный persisted allow-list (#291)', () => {
const off = 0.0605;
const profile = latticeProfile({
config: { spaces: [{
id: 'sp1',
rooms: [{ id: 'legacy', x: off, y: off, w: off, h: off }],
openings: [{ id: 'door', x: off, y: off }],
room_drafts: [{ id: 'draft', points: [[off, off]] }],
decor: [
{ id: 'line', kind: 'line', x1: off, y1: off, x2: off, y2: off },
{ id: 'rect', kind: 'rect', x: off, y: off, w: off, h: off },
{ id: 'text', kind: 'text', x: off, y: off },
],
}] },
layout: {},
});
assert.deepEqual(Object.keys(profile.byKind).sort(),
['decor', 'opening', 'room', 'room_draft']);
assert.equal(profile.total, 18);
assert.equal(profile.offGrid, 18);
});
test('модели проекта не несут шума решётки (#282)', async () => {
// Свойство, которое стоит знать про себя: тестовые данные проекта чисты —
// весь их офф-грид авторский. Значит воспроизвести на них #258/#279 нельзя
@@ -403,6 +428,24 @@ test('реальные планы сохраняют координатный ш
}
});
test('барьер очищает клоны реальных планов, не переписывая source fixtures (#291)', () => {
for (const plan of REAL_PLANS) {
const path = resolve(repoRoot, 'test/fixtures', plan.file);
const rawBefore = readFileSync(path, 'utf8');
const { space } = JSON.parse(rawBefore);
const source = { spaces: [space], markers: [], settings: {} };
const measured = latticeCanonicalizationReport(source, {});
assert.ok(measured.canonicalized >= plan.minNoise, `${plan.file}: cleanup must be measurable`);
const candidate = canonicalizeConfigGeometry(source);
const profile = latticeProfile({ config: candidate, layout: {} });
assert.equal(profile.noise, 0, `${plan.file}: boundary must remove all near-node tails`);
assert.equal(profile.offGrid, measured.far, `${plan.file}: authored off-grid population survives`);
assert.equal(checkWallKeys(candidate, { notes: [] }).length, 0);
assert.equal(checkMixedRoleRecords(candidate).length, 0);
assert.equal(readFileSync(path, 'utf8'), rawBefore, 'the raw regression fixture is immutable');
}
});
test('реальные планы: сплошные рёбра без записи толщины закреплены числом (#286)', async () => {
// Ребро, объявленное сплошным, но с нулевой толщиной, рисуется без кладки.
// На первом этаже таких два, длиной по 2 шага, и соседние тела их накрывают —
+19 -11
View File
@@ -6,7 +6,7 @@ import {
collapseIsolatedWallThicknessIslands, optimizePlans, PLAN_MODEL_VERSION,
} from '../test-build/plan-optimizer.js';
import {
canonicalizeConfigGeometry, canonicalizeLayoutGeometry, canonicalizeNumber,
canonicalizeConfigGeometry, canonicalizeLatticeCoordinate, canonicalizeLayoutGeometry,
} from '../test-build/coordinate-canonicalization.js';
import { pointInPhysicalGeometry, unionBodies } from '../test-build/physical-geometry.js';
import { GRID_PITCH, GRID_STEP_N as S, NORM_W } from '../test-build/space-geometry.js';
@@ -48,7 +48,10 @@ const assertNoPersistedChanges = (result) => {
'positionsRemapped', 'markersDetached', 'orphanRoomLabelsRemoved',
'orphanDevicePositionsRemoved', 'orphanGroupPositionsRemoved',
'liveMissingPositionsRemoved',
'latticeCoordinatesCanonicalized', 'latticeCoordinatesFar',
'latticeMaxShift', 'latticeMaxShiftCm',
]) assert.equal(result.report[field], 0, `${field} must describe the persisted delta`);
assert.deepEqual(result.report.latticeSpaces, []);
assert.equal(result.report.maxShift, 0);
assert.equal(result.report.maxShiftCm, 0);
assert.equal(result.report.maxSpace, '');
@@ -237,8 +240,9 @@ test('issue 273 Optimize collapses the beta.5 island beside one T-node', () => {
'T coordinate and perpendicular incident room stay byte-equivalent');
assert.equal(first.config.spaces[0].walls.length, 1);
assert.equal(first.config.spaces[0].walls[0].cm, 22);
assert.deepEqual(first.config.spaces[0].walls[0].a, [0.8, 0.345833333]);
assert.deepEqual(first.config.spaces[0].walls[0].b, [0.95, 0.345833333]);
const exactNode = 83 / 240;
assert.deepEqual(first.config.spaces[0].walls[0].a, [0.8, exactNode]);
assert.deepEqual(first.config.spaces[0].walls[0].b, [0.95, exactNode]);
const afterIntervals = wallIntervals(
first.config.spaces[0].rooms, first.config.spaces[0].walls, [], S, 5, S,
@@ -250,7 +254,7 @@ test('issue 273 Optimize collapses the beta.5 island beside one T-node', () => {
assert.ok(geometry, 'optimized profile must render a masonry body');
for (const x of [fixture.split - S, (fixture.split + fixture.microEnd) / 2,
fixture.microEnd + S]) {
assert.equal(pointInPhysicalGeometry([x, 0.345833333 - 0.008], geometry.geom), true,
assert.equal(pointInPhysicalGeometry([x, exactNode - 0.008], geometry.geom), true,
`22 cm outer face must stay continuous at x=${x}`);
}
@@ -276,12 +280,14 @@ test('Optimize canonicalizes the six-room ULP source without claiming a visible
assert.equal(first.report.moved, 0);
assert.equal(first.report.maxShift, 0);
assert.equal(first.report.maxShiftCm, 0);
assert.ok(first.report.coordsCanonicalized > 0);
assert.ok(first.report.latticeCoordinatesCanonicalized > 0);
assert.equal(first.report.coordsCanonicalized, 0,
'storage-only tails are not presented as visible grid alignment');
assert.deepEqual(first.config.future, { kept: true });
for (const item of first.config.spaces[0].rooms) {
for (const [x, y] of item.poly) {
assert.equal(x, canonicalizeNumber(Math.round(x / S) * S));
assert.equal(y, canonicalizeNumber(Math.round(y / S) * S));
assert.equal(x, canonicalizeLatticeCoordinate(Math.round(x / S) * S));
assert.equal(y, canonicalizeLatticeCoordinate(Math.round(y / S) * S));
}
}
assert.ok(unionBodies(first.config.spaces[0].rooms.map((item) => item.poly)),
@@ -290,11 +296,12 @@ test('Optimize canonicalizes the six-room ULP source without claiming a visible
const second = optimizePlans(first.config, first.layout);
assert.equal(second.changed, false);
assert.equal(second.report.coordsCanonicalized, 0);
assert.equal(second.report.latticeCoordinatesCanonicalized, 0);
assert.deepEqual(second.config, first.config);
assert.deepEqual(second.layout, first.layout);
});
test('issue 248 Optimize stays a no-op across the nine-decimal storage round-trip', () => {
test('issue 248 Optimize stays a no-op across the lattice storage round-trip', () => {
const inputBefore = structuredClone(storageRoundtripFixture.input);
const first = optimizePlans(
storageRoundtripFixture.input.config,
@@ -303,7 +310,7 @@ test('issue 248 Optimize stays a no-op across the nine-decimal storage round-tri
assert.deepEqual(storageRoundtripFixture.input, inputBefore, 'preview must keep fixture input');
assert.equal(first.changed, true);
assert.ok(first.report.coordsCanonicalized > 0);
assert.ok(first.report.latticeCoordinatesCanonicalized > 0);
assert.deepEqual(first.config, storageRoundtripFixture.expected.config);
assert.deepEqual(first.layout, storageRoundtripFixture.expected.layout);
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config);
@@ -339,8 +346,9 @@ test('issue 258 Optimize canonicalizes an affected wall key across storage round
const repaired = first.config.spaces[0].walls.find((wall) => wall.cm === 29);
assert.ok(repaired);
assert.equal(repaired.cm, 29);
assert.deepEqual(repaired.a, wallKeyRoundtripFixture.space.walls[0].a);
assert.deepEqual(repaired.b, wallKeyRoundtripFixture.space.walls[0].b);
const canonicalSource = canonicalizeConfigGeometry({ spaces: [wallKeyRoundtripFixture.space] });
assert.deepEqual(repaired.a, canonicalSource.spaces[0].walls[0].a);
assert.deepEqual(repaired.b, canonicalSource.spaces[0].walls[0].b);
assert.equal(repaired.key, wallKeyRoundtripFixture.canonical_key);
assert.equal(repaired.key, wallKey(repaired.a, repaired.b, S));
assert.deepEqual(canonicalizeConfigGeometry(first.config), first.config);