fix: canonicalize near-grid coordinates exactly

Issue: #223
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-20 19:32:16 +00:00
committed by claude[bot]
parent 603bb3647f
commit d486c64576
18 changed files with 357 additions and 47 deletions
+68 -22
View File
@@ -65,18 +65,17 @@ const WALL_TOL = GRID_STEP_N * 6;
/** What one cell is worth when a space does not say — the card's own default. */
const DEFAULT_CELL_CM = 5;
/** Round a NORMALISED coordinate to the nearest grid node, idempotently.
* A value already on a node is returned UNCHANGED (bit for bit), so a second
* run of the alignment writes nothing at all. */
/** Round a NORMALISED coordinate to the exact nearest grid node. */
export function snapN(v: number): number {
if (!Number.isFinite(v)) return v;
const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;
return Math.abs(s - v) <= EPS ? v : s;
return Math.round(v / GRID_STEP_N) * GRID_STEP_N;
}
export interface AlignReport {
/** Elements whose coordinates the run would change. */
moved: number;
/** Near-node coordinate components rewritten without a visible displacement. */
coordsCanonicalized: number;
/** Elements examined (rooms, decor shapes, openings, markers, labels). */
total: number;
/** Largest displacement in NORMALISED units (1 = the plan's width). */
@@ -151,8 +150,21 @@ export function alignAllToGrid(
const spaces = JSON.parse(JSON.stringify(spacesIn || []));
const layout: Record<string, any> = JSON.parse(JSON.stringify(layoutIn || {}));
let moved = 0, total = 0, maxShift = 0, maxShiftCm = 0, maxSpace = '', rotated = 0;
let coordsCanonicalized = 0;
let removedDrafts = 0;
/** Count only a near-node value which was actually written to the candidate. */
const noteCanonicalCoordinate = (before: number, after: number): void => {
if (Number.isFinite(before) && Number.isFinite(after)
&& before !== after && Math.abs(after - before) <= EPS) {
coordsCanonicalized++;
}
};
const noteCanonicalPoint = (before: number[], after: number[]): void => {
noteCanonicalCoordinate(before[0], after[0]);
noteCanonicalCoordinate(before[1], after[1]);
};
// a marker or a room label names its space; the scale of THAT space is the
// one its centimetres are in
const cellById: Record<string, number> = {};
@@ -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,
};
}
+3 -2
View File
@@ -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}
<p class="alignmsg">${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),
})}</p>
${r.glowSpacesMigrated || r.glowRoomsMigrated
? html`<p class="alignmsg">${this._t('gs.optimize_glow_migration', {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "Оптимизировать",