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
File diff suppressed because one or more lines are too long
@@ -0,0 +1,103 @@
// #223: explicit Optimize removes stored ULP noise without claiming a visible
// move. Exercise the production bundle's Preview → Cancel → Apply → Undo flow.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 920, height: 840 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const S = 1 / 240;
const noisyFloor = [
[[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 original = {
model_version: 6,
spaces: [{
id: 'noisy', title: 'Noisy floor', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: noisyFloor.map((poly, index) => ({ id: `room-${index}`, poly })),
future: { kept: true },
}],
markers: [], settings: {},
};
const clone = (value) => JSON.parse(JSON.stringify(value));
const isCanonical = (value) => value === Math.round(value / S) * S;
let serverConfig = clone(original), serverLayout = {}, backup = null;
let lastToast = '';
const sent = [];
const baseCall = card.hass.callWS.bind(card.hass);
card.hass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/plan/optimize') {
sent.push(message.type);
backup = { config: clone(serverConfig), layout: clone(serverLayout) };
serverConfig = clone(message.config); serverLayout = clone(message.layout);
return { ok: true, config_rev: 2, layout_rev: 2, can_undo: true };
}
if (message.type === 'houseplan/plan/optimize_undo') {
sent.push(message.type);
serverConfig = clone(backup.config); serverLayout = clone(backup.layout);
return { ok: true, config_rev: 3, layout_rev: 3, can_undo: false };
}
if (message.type === 'houseplan/config/get')
return { config: clone(serverConfig), rev: 3, can_write: true };
if (message.type === 'houseplan/layout/get')
return { layout: clone(serverLayout), rev: 3 };
return baseCall(message);
},
};
const baseToast = card._showToast.bind(card);
card._showToast = (message) => { lastToast = message; baseToast(message); };
card._config = { ...card._config, language: 'en' };
card._serverCfg = clone(original); card._layout = {};
card._modelCache = null; card._frame = null; card._space = 'noisy';
card.requestUpdate(); await card.updateComplete;
card._openAlignDialog(); await card.updateComplete;
const preview = card._alignDialog;
const previewText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.previewOffersInvisibleCleanup = !!preview?.changed
&& preview.report.moved === 0
&& preview.report.coordsCanonicalized > 0
&& preview.report.maxShift === 0
&& preview.report.maxShiftCm === 0;
result.previewNamesBothReportUnits = previewText.includes('spaces updated:')
&& previewText.includes('noisy coordinate values removed:');
result.previewOffersApply = !!card.renderRoot.querySelector('hp-dialog .btn.on');
result.previewDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
card._alignDialog = null; await card.updateComplete;
result.cancelDoesNotWrite = sent.length === 0
&& JSON.stringify(card._serverCfg) === JSON.stringify(original);
card._openAlignDialog(); await card.updateComplete;
await card._runAlignToGrid(); await card.updateComplete;
result.applyUsesOneAtomicWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
result.applyStoresOnlyCanonicalRoomCoordinates = card._serverCfg.spaces[0].rooms
.every((room) => room.poly.every(([x, y]) => isCanonical(x) && isCanonical(y)));
result.applyPreservesUnknownFields = card._serverCfg.spaces[0].future?.kept === true;
result.toastExplainsZeroMoveCleanup = lastToast.includes('0 elements moved')
&& !lastToast.includes('0 records maintained');
card._openAlignDialog(); await card.updateComplete;
result.secondRunIsExactNoOp = card._alignDialog?.changed === false
&& card._alignDialog.report.coordsCanonicalized === 0
&& !card.renderRoot.querySelector('hp-dialog .btn.on');
card._alignDialog = null; await card.updateComplete;
await card._undoPlanOptimization(); await card.updateComplete;
result.undoUsesServerSnapshot = sent.filter((type) => type === 'houseplan/plan/optimize_undo').length === 1;
result.undoRestoresExactNoisyValues = JSON.stringify(card._serverCfg) === JSON.stringify(original);
result.undoIsOneDeep = card._canOptimizeUndo === false;
return result;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+5 -5
View File
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -419,6 +419,10 @@ nodes. New editor operations cannot create more. General settings contain
a **Plan maintenance** group whose action previews and then repairs old
data through all current passes: model upgrades, mandatory grid
alignment, exact open-span canonicalisation and wall-interval compaction.
Unlike live snapping, the explicit maintenance pass also replaces a stored
coordinate which is only one or several ULPs away from its node with the exact
computed node. That has no visible displacement but removes topology noise at
its persisted source.
Why an action rather than a silent migration:
@@ -468,7 +472,7 @@ idempotence case in `test/plan-optimizer.test.mjs`:
* a stray opening with no wall within 6 steps is left exactly where it
is rather than teleported;
* **idempotent**: a second run reports `moved: 0`, `changed: false`, and
returns objects deep-equal to the first run's;
`coordsCanonicalized: 0`, and returns objects deep-equal to the first run's;
* the report is an **upper bound**, not a sample (AUD-158B1-01).
### The report is a promise
@@ -492,6 +496,12 @@ The confirmation is the decision gate in front of a geometry rewrite, so
says which space the maximum is in; openings corrected in angle alone
are counted on a line of their own.
`coordsCanonicalized` counts individual near-node coordinate values actually
written to the candidate. It excludes ordinary shifts above `EPS`, rejected
partition snaps and wall/open-span maintenance. Those values do not increase
`moved` or `maxShift*`; the dialog instead labels them as removed coordinate
noise and keeps the existing updated-space counter separate.
One undo is available until the next config or layout edit. It restores
the stored snapshot; re-running optimization itself is never treated as
undo because a grid projection is not invertible.
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- “Optimize plans” now removes microscopic floating-point noise from stored
grid coordinates even when nothing visibly moves. Its preview separately
reports updated spaces and cleaned coordinate values, and a second run is an
exact no-op ([#223](https://github.com/Matysh/houseplan-card/issues/223)).
- Placing an individual Home Assistant entity no longer leaves a duplicate
automatic marker of its complete parent device. The automatic marker now
contains only the remaining active visible entities and disappears when
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- «Оптимизировать планы» теперь устраняет микроскопический floating-point шум
сохранённых координат сетки, даже если визуально ничего не сдвигается. В
предпросмотре отдельно показаны обновлённые пространства и очищенные
координаты, а повторный запуск становится точным no-op
([#223](https://github.com/Matysh/houseplan-card/issues/223)).
- Размещение отдельной сущности Home Assistant больше не оставляет рядом
дублирующий автоматический маркер всего родительского устройства. В
auto-marker теперь входят только оставшиеся активные видимые сущности, а при
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.66.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion of the published v1.66.0-beta.1 product line |
| Current local cycle | v1.66.0 promotes the exact product behaviour already published in beta.1: capsule-shaped Text marker outlines, resilient Glow floor clipping and the unified red/green lock plus theme-aware orange-core palette. The stable commit contains only version fields, generated snapshots and release metadata; stable publication is gated by the full local suite and green exact-SHA Validate and Full Performance workflows. |
| Current local cycle | Post-v1.66.0 Unreleased development includes entity/parent marker deduplication (#226) and exact coordinate maintenance (#223): explicit Optimize rewrites persisted near-grid ULP noise without claiming a visible move, reports cleaned coordinates separately and remains exactly idempotent. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+10
View File
@@ -2042,6 +2042,16 @@ require hands on real hardware — they remain for the human pass.
snap to nodes in ONE write, openings stay on their walls, and pressing the
button a second time reports nothing to do. Cancel does nothing at all
[auto: smoke_grid_snap + unit test/align-grid.test.mjs]
- [ ] **Optimize removes stored ULP coordinate noise (#223)**: a six-room
fixture whose shared grid vertices differ by `5.5e-17` offers Apply with
`moved: 0` and a positive cleaned-coordinate count. Preview distinguishes
updated spaces from removed coordinate noise; Cancel writes nothing;
Apply stores exact grid nodes in one transaction; the next run is a no-op
and server Undo restores the original noisy bits. A rejected hosted
partition contributes nothing to the counter
[unit: align-grid + plan-optimizer + i18n; auto:
smoke_optimize_coordinate_canonicalization; mutation:
`snapn-returns-input-near-node`].
- [ ] Optimizer migration safety: legacy decor width/text size is clamped to
the backend schema, `fill: true` receives explicit fill style, invalid
legacy `plan_scale` is preserved for repair, an already canonical plan is
+7 -2
View File
@@ -1270,14 +1270,19 @@ show_signal: true
|---|---|
| Миграции модели | Старые, однозначно преобразуемые поля переводятся в текущий формат; для `entity:switch.*` собственный switch в старом `controls` становится ролью «Всегда» (для `device:*` это делает сохранение настроек устройства, где доступен реестр HA) |
| Масштаб сетки | Некорректный старый `cell_cm` приводится к допустимому диапазону 0,1–1000 см |
| Комнаты | Вершины округляются к сетке |
| Комнаты | Вершины записываются точными узлами сетки; микроскопический floating-point шум также устраняется без видимого сдвига |
| Декор и мебель | Положение и размеры округляются к сетке |
| Устройства и подписи комнат | Позиции округляются к сетке |
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
Перед записью диалог показывает количество затрагиваемых элементов, максимальный сдвиг в сантиметрах и пространство с этим сдвигом.
Перед записью диалог показывает количество затрагиваемых элементов,
максимальный сдвиг в сантиметрах и пространство с этим сдвигом. Отдельная
строка различает пространства с обновлённым представлением стен/связей и
координаты, в которых устранён только вычислительный шум. Поэтому возможен
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
координат. Повторный Optimize над результатом ничего не предлагает.
### Что оптимизация сохраняет
+14
View File
@@ -40,6 +40,20 @@ import { fileURLToPath } from 'node:url';
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
{
id: 'snapn-returns-input-near-node',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="snapN returns the exact nearest node" '
+ 'test/align-grid.test.mjs',
because: 'explicit Optimize must replace a stored ULP tail with the exact nearest grid node '
+ 'instead of preserving the noisy input merely because its displacement is visually tiny',
patches: [{
file: 'src/align-grid.ts',
find: ' return Math.round(v / GRID_STEP_N) * GRID_STEP_N;',
replace: ' const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;\n'
+ ' return Math.abs(s - v) <= EPS ? v : s;',
}],
},
{
id: 'union-quantization-removed',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+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": "Оптимизировать",
+51 -2
View File
@@ -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
});
+13
View File
@@ -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]);
+50
View File
@@ -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);