= {};
@@ -186,6 +198,7 @@ export function alignAllToGrid(
r.poly = r.poly.map((p: number[]) => {
const q = [snapN(p[0]), snapN(p[1])];
d = Math.max(d, dist(p[0], p[1], q[0], q[1]));
+ noteCanonicalPoint(p, q);
return q;
});
} else if (r.x != null && r.y != null) {
@@ -198,6 +211,10 @@ export function alignAllToGrid(
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
d = boxShift(x0, y0, w0, h0, nx, ny, nw, nh);
r.x = nx; r.y = ny; r.w = nw; r.h = nh;
+ noteCanonicalCoordinate(x0, nx);
+ noteCanonicalCoordinate(y0, ny);
+ noteCanonicalCoordinate(x0 + w0, nx + nw);
+ noteCanonicalCoordinate(y0 + h0, ny + nh);
}
note(d, cell, sid);
}
@@ -206,17 +223,22 @@ export function alignAllToGrid(
for (const draft of sp.room_drafts || []) {
total++;
let d = 0;
- const snapped = (draft.points || []).map((p: number[]) => {
+ const sourcePoints: number[][] = draft.points || [];
+ const snapped = sourcePoints.map((p: number[]) => {
const q = [snapN(p[0]), snapN(p[1])];
d = Math.max(d, dist(p[0], p[1], q[0], q[1]));
return q;
});
const points: number[][] = snapped.length ? [snapped[0]] : [];
+ const written: Array<[number[], number[]]> = snapped.length
+ ? [[sourcePoints[0], snapped[0]]]
+ : [];
const segments: any[] = [];
for (let i = 0; i + 1 < snapped.length; i++) {
const next = snapped[i + 1], last = points[points.length - 1];
if (last && dist(last[0], last[1], next[0], next[1]) <= EPS) continue;
points.push(next);
+ written.push([sourcePoints[i + 1], next]);
segments.push({
...(draft.segments?.[i] || {}),
cm: Number(draft.segments?.[i]?.cm) || 15,
@@ -224,6 +246,9 @@ export function alignAllToGrid(
}
draft.points = points;
draft.segments = segments;
+ if (points.length >= 2) {
+ for (const [before, after] of written) noteCanonicalPoint(before, after);
+ }
note(d, cell, sid);
}
if (Array.isArray(sp.room_drafts)) {
@@ -236,10 +261,11 @@ export function alignAllToGrid(
// ---- independent partitions and columns --------------------------
for (const p of sp.partitions || []) {
total++;
- const a = [snapN(p.a[0]), snapN(p.a[1])];
- const b = [snapN(p.b[0]), snapN(p.b[1])];
- let d = Math.max(dist(p.a[0], p.a[1], a[0], a[1]),
- dist(p.b[0], p.b[1], b[0], b[1]));
+ const beforeA = [p.a[0], p.a[1]], beforeB = [p.b[0], p.b[1]];
+ const a = [snapN(beforeA[0]), snapN(beforeA[1])];
+ const b = [snapN(beforeB[0]), snapN(beforeB[1])];
+ let d = Math.max(dist(beforeA[0], beforeA[1], a[0], a[1]),
+ dist(beforeB[0], beforeB[1], b[0], b[1]));
const snappedLength = dist(a[0], a[1], b[0], b[1]);
const hostedFit = (sp.openings || [])
.filter((opening: any) => opening.host?.kind === 'partition'
@@ -253,15 +279,20 @@ export function alignAllToGrid(
&& along - length / 2 >= -EPS
&& along + length / 2 <= snappedLength + EPS;
});
- if (snappedLength > EPS && hostedFit) { p.a = a; p.b = b; }
- else d = 0;
+ if (snappedLength > EPS && hostedFit) {
+ p.a = a; p.b = b;
+ noteCanonicalPoint(beforeA, a);
+ noteCanonicalPoint(beforeB, b);
+ } else d = 0;
note(d, cell, sid);
}
for (const c of sp.wall_columns || []) {
total++;
- const center = [snapN(c.center[0]), snapN(c.center[1])];
- const d = dist(c.center[0], c.center[1], center[0], center[1]);
+ const before = [c.center[0], c.center[1]];
+ const center = [snapN(before[0]), snapN(before[1])];
+ const d = dist(before[0], before[1], center[0], center[1]);
c.center = center;
+ noteCanonicalPoint(before, center);
note(d, cell, sid);
}
@@ -270,20 +301,30 @@ export function alignAllToGrid(
total++;
let d = 0;
if (sh.kind === 'line') {
- const a = [snapN(sh.x1), snapN(sh.y1)], b = [snapN(sh.x2), snapN(sh.y2)];
- d = Math.max(dist(sh.x1, sh.y1, a[0], a[1]), dist(sh.x2, sh.y2, b[0], b[1]));
+ const beforeA = [sh.x1, sh.y1], beforeB = [sh.x2, sh.y2];
+ const a = [snapN(beforeA[0]), snapN(beforeA[1])];
+ const b = [snapN(beforeB[0]), snapN(beforeB[1])];
+ d = Math.max(dist(beforeA[0], beforeA[1], a[0], a[1]),
+ dist(beforeB[0], beforeB[1], b[0], b[1]));
sh.x1 = a[0]; sh.y1 = a[1]; sh.x2 = b[0]; sh.y2 = b[1];
+ noteCanonicalPoint(beforeA, a);
+ noteCanonicalPoint(beforeB, b);
} else {
- const nx = snapN(sh.x), ny = snapN(sh.y);
+ const x0 = sh.x, y0 = sh.y, w0 = sh.w, h0 = sh.h;
+ const nx = snapN(x0), ny = snapN(y0);
if (sh.w != null && sh.h != null) {
- const x2 = snapN(sh.x + sh.w), y2 = snapN(sh.y + sh.h);
+ const x2 = snapN(x0 + w0), y2 = snapN(y0 + h0);
const nw = Math.max(GRID_STEP_N, x2 - nx), nh = Math.max(GRID_STEP_N, y2 - ny);
- d = boxShift(sh.x, sh.y, sh.w, sh.h, nx, ny, nw, nh);
+ d = boxShift(x0, y0, w0, h0, nx, ny, nw, nh);
sh.w = nw; sh.h = nh;
+ noteCanonicalCoordinate(x0 + w0, nx + nw);
+ noteCanonicalCoordinate(y0 + h0, ny + nh);
} else {
- d = dist(sh.x, sh.y, nx, ny);
+ d = dist(x0, y0, nx, ny);
}
sh.x = nx; sh.y = ny;
+ noteCanonicalCoordinate(x0, nx);
+ noteCanonicalCoordinate(y0, ny);
}
note(d, cell, sid);
}
@@ -340,6 +381,8 @@ export function alignAllToGrid(
const nx = snapN(p.x), ny = snapN(p.y);
const d = dist(p.x, p.y, nx, ny);
layout[k] = { ...p, x: nx, y: ny };
+ noteCanonicalCoordinate(p.x, nx);
+ noteCanonicalCoordinate(p.y, ny);
// an entry whose space is gone still moves, and the promise must not
// shrink because of it: the largest scale on the plan is the safe one
const sid = typeof p.s === 'string' ? p.s : '';
@@ -348,7 +391,10 @@ export function alignAllToGrid(
return {
spaces, layout,
- report: { moved, total, maxShift, maxShiftCm, maxSpace, rotated, removedDrafts },
- changed: moved > 0,
+ report: {
+ moved, coordsCanonicalized, total, maxShift, maxShiftCm, maxSpace,
+ rotated, removedDrafts,
+ },
+ changed: moved > 0 || coordsCanonicalized > 0,
};
}
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 4aa6d92d..8df5a6f0 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -14288,7 +14288,7 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('gs.align_done', {
n: String(d.report.moved),
m: String(d.report.migrated + d.report.canonicalized
- + d.report.wallsMerged + d.report.spansMerged),
+ + d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged),
}));
} catch (e: any) {
if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false };
@@ -15291,7 +15291,8 @@ class HouseplanCard extends LitElement {
: nothing}
${this._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
- w: String(r.wallsMerged), s: String(r.spansMerged),
+ p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
+ s: String(r.spansMerged),
})}
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`${this._t('gs.optimize_glow_migration', {
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 43717383..37c8a94b 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -782,7 +782,7 @@
"gs.align_where": "The largest shift is in “{s}”.",
"gs.align_turned": "Openings whose angle is corrected: {n}.",
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {n}.",
- "gs.optimize_changes": "Model migrations: {m}; plans canonicalized: {c}; merged real-wall fragments: {w}; virtual fragments: {s}.",
+ "gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}.",
"gs.optimize_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.",
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
"gs.align_run": "Optimize",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index 6ecc1fa2..91d050eb 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -782,7 +782,7 @@
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
- "gs.optimize_changes": "Миграций модели: {m}; канонизировано планов: {c}; объединено отрезков реальных стен: {w}; виртуальных: {s}.",
+ "gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}.",
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
"gs.align_run": "Оптимизировать",
diff --git a/test/align-grid.test.mjs b/test/align-grid.test.mjs
index c4a5dd1d..72ef7c4c 100644
--- a/test/align-grid.test.mjs
+++ b/test/align-grid.test.mjs
@@ -38,14 +38,61 @@ const detunedLayout = () => ({
junk: { s: 'f1' }, // no coordinates: skipped
});
-test('snapN is idempotent and leaves a node bit-identical', () => {
+test('snapN returns the exact nearest node and is idempotent', () => {
const node = 12 / GRID_N;
- assert.equal(snapN(node), node); // untouched, not "re-rounded"
+ assert.equal(snapN(node), node);
+ const canonical = 112 * S;
+ const noisy = 0.46666666666666673;
+ assert.notEqual(noisy, canonical);
+ assert.equal(snapN(noisy), canonical, 'a one-ULP tail must not survive Optimize');
const off = node + S / 3;
assert.ok(Math.abs(snapN(off) - node) < 1e-12);
assert.equal(snapN(snapN(off)), snapN(off));
});
+test('near-node report counts only coordinate values actually written to the candidate', () => {
+ const canonical = 112 * S;
+ const noisy = 0.46666666666666673;
+ const spaces = [{
+ id: 'f1', rooms: [
+ { id: 'poly', poly: [[noisy, 0.2], [0.6, 0.2], [0.6, 0.4], [0.2, 0.4]] },
+ { id: 'rect', x: 0, y: 0.5, w: noisy, h: 0.2 },
+ ],
+ partitions: [
+ { id: 'accepted', a: [noisy, 0.2], b: [0.6, 0.2], cm: 15 },
+ { id: 'rejected', a: [noisy, 0.4], b: [0.6, 0.4], cm: 15 },
+ ],
+ openings: [{
+ id: 'outside-host', type: 'door', x: 0.5, y: 0.4, angle: 0, length: 0.05,
+ host: { kind: 'partition', id: 'rejected', t: 2 },
+ }],
+ wall_columns: [{ id: 'column', shape: 'circle', center: [noisy, 0.5], cm: 20 }],
+ decor: [
+ { id: 'line', kind: 'line', x1: noisy, y1: 0.7, x2: 0.6, y2: 0.7 },
+ { id: 'box', kind: 'rect', x: 0, y: 0.8, w: noisy, h: 0.1 },
+ { id: 'text', kind: 'text', x: noisy, y: 0.9, text: 'x' },
+ ],
+ }];
+ const result = alignAllToGrid(spaces, { marker: { s: 'f1', x: noisy, y: 0.5 } });
+
+ assert.equal(result.report.moved, 0, 'ULP cleanup is not a visible move');
+ assert.equal(result.report.maxShift, 0);
+ assert.equal(result.report.maxShiftCm, 0);
+ assert.equal(result.report.coordsCanonicalized, 8);
+ assert.equal(result.changed, true);
+ assert.equal(result.spaces[0].partitions[0].a[0], canonical);
+ assert.equal(result.spaces[0].partitions[1].a[0], noisy,
+ 'hostedFit=false keeps the rejected endpoints and must not count them');
+ assert.equal(result.spaces[0].wall_columns[0].center[0], canonical);
+ assert.equal(result.layout.marker.x, canonical);
+});
+
+test('ordinary off-grid movement is not duplicated in the near-node counter', () => {
+ const result = alignAllToGrid([], { marker: { x: 0.2 + S / 3, y: 0.2 } });
+ assert.equal(result.report.moved, 1);
+ assert.equal(result.report.coordsCanonicalized, 0);
+});
+
test('alignAllToGrid puts every grid-bound element on a node', () => {
const { spaces, layout, report } = alignAllToGrid(detuned().spaces, detunedLayout());
const sp = spaces[0];
@@ -111,6 +158,7 @@ test('idempotent: a second run moves nothing and changes nothing', () => {
const first = alignAllToGrid(detuned().spaces, detunedLayout());
const second = alignAllToGrid(first.spaces, first.layout);
assert.equal(second.report.moved, 0);
+ assert.equal(second.report.coordsCanonicalized, 0);
assert.equal(second.changed, false);
assert.deepEqual(second.spaces, first.spaces);
assert.deepEqual(second.layout, first.layout);
@@ -122,6 +170,7 @@ test('an already-aligned plan reports nothing to do', () => {
};
const r = alignAllToGrid(clean.spaces, { d1: { s: 'f1', x: 0.25, y: 0.5 } });
assert.equal(r.report.moved, 0);
+ assert.equal(r.report.coordsCanonicalized, 0);
assert.equal(r.changed, false);
assert.ok(r.report.total >= 2); // it still LOOKED at everything
});
diff --git a/test/i18n.test.mjs b/test/i18n.test.mjs
index 153ad7e0..7998a4ee 100644
--- a/test/i18n.test.mjs
+++ b/test/i18n.test.mjs
@@ -27,6 +27,19 @@ test('i18n: placeholders match between languages', () => {
}
});
+test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
+ assert.equal(
+ en['gs.optimize_changes'],
+ 'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}.',
+ );
+ assert.equal(
+ ru['gs.optimize_changes'],
+ 'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}.',
+ );
+ assert.match(cardSource, /p: String\(r\.coordsCanonicalized\)/);
+ assert.match(cardSource, /d\.report\.coordsCanonicalized \+ d\.report\.wallsMerged/);
+});
+
test('i18n: every literal help call has body and full aria keys in both languages', () => {
const allCalls = cardSource.match(/this\._help\(/g) || [];
const helpKeys = [...cardSource.matchAll(/this\._help\('([^']+\.help)'\)/g)].map((match) => match[1]);
diff --git a/test/plan-optimizer.test.mjs b/test/plan-optimizer.test.mjs
index 02f8e6a0..91682291 100644
--- a/test/plan-optimizer.test.mjs
+++ b/test/plan-optimizer.test.mjs
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import {
collapseIsolatedWallThicknessIslands, optimizePlans, PLAN_MODEL_VERSION,
} from '../test-build/plan-optimizer.js';
+import { unionBodies } from '../test-build/physical-geometry.js';
import { GRID_PITCH, GRID_STEP_N as S, NORM_W } from '../test-build/space-geometry.js';
import { wallKey } from '../test-build/wall-thickness.js';
@@ -17,6 +18,20 @@ const room = (id, x0, x1, openTo) => ({
const exactWall = (a, b, cm) => ({ key: wallKey(a, b, S), a, b, cm });
+// Privacy-minimised six-room topology from #218/#223. The relevant stored ULP
+// tails stay literal so this fixture proves that Optimize repairs the source,
+// not merely that render-time boolean normalisation remains resilient.
+const noisySixRoomFloor = [
+ [[0.46666666666666673, 0.7083333333333334], [0.6125, 0.9],
+ [0.4666666666666667, 1], [0.46666666666666673, 0.9]],
+ [[0.1625, 0.3], [0.3458333333333333, 0],
+ [0.46666666666666673, 1], [0.3458333333333333, 1]],
+ [[0.7, 0], [0.8, 0], [0.8, 0.7083333333333334], [0.7, 0.7083333333333334]],
+ [[0.7, 0.7083333333333335], [0.8, 0.7083333333333335], [0.8, 1], [0.7, 1]],
+ [[0.85, 0], [0.9, 0], [0.9, 0.4], [0.85, 0.4]],
+ [[0.85, 0.5], [0.9, 0.5], [0.9, 1], [0.85, 1]],
+];
+
const microIntervalFixture = (length = S / 3, middleCm = 15, rightCm = 22) => {
const x0 = 0.2, split = 0.5, x1 = 0.8, y = 0.2;
return {
@@ -57,6 +72,41 @@ test('Optimize collapses one isolated thickness micro-interval and is idempotent
assert.deepEqual(second.config, first.config);
});
+test('Optimize canonicalizes the six-room ULP source without claiming a visible move', () => {
+ const config = {
+ model_version: PLAN_MODEL_VERSION,
+ spaces: [{
+ id: 'noisy', title: 'Noisy', view_box: [0, 0, 1, 1], cell_cm: 5,
+ rooms: noisySixRoomFloor.map((poly, index) => ({ id: `room-${index}`, poly })),
+ }],
+ markers: [], settings: {}, future: { kept: true },
+ };
+ const before = structuredClone(config);
+ const first = optimizePlans(config, {});
+
+ assert.deepEqual(config, before, 'preview never mutates the noisy source');
+ assert.equal(first.changed, true);
+ 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.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, Math.round(x / S) * S);
+ assert.equal(y, Math.round(y / S) * S);
+ }
+ }
+ assert.ok(unionBodies(first.config.spaces[0].rooms.map((item) => item.poly)),
+ 'downstream boolean geometry accepts the exact candidate');
+
+ const second = optimizePlans(first.config, first.layout);
+ assert.equal(second.changed, false);
+ assert.equal(second.report.coordsCanonicalized, 0);
+ assert.deepEqual(second.config, first.config);
+ assert.deepEqual(second.layout, first.layout);
+});
+
test('micro-interval cleanup has a strict half-step boundary at both coordinate scales', () => {
for (const length of [S / 3, S / 2, S / 2 + S / 100]) {
const fixture = microIntervalFixture(length);