mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: canonicalize near-grid coordinates exactly
Issue: #223 User-Visible: yes
This commit is contained in:
committed by
claude[bot]
parent
603bb3647f
commit
d486c64576
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
Vendored
+5
-5
File diff suppressed because one or more lines are too long
+11
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- «Оптимизировать планы» теперь устраняет микроскопический floating-point шум
|
||||
сохранённых координат сетки, даже если визуально ничего не сдвигается. В
|
||||
предпросмотре отдельно показаны обновлённые пространства и очищенные
|
||||
координаты, а повторный запуск становится точным no-op
|
||||
([#223](https://github.com/Matysh/houseplan-card/issues/223)).
|
||||
- Размещение отдельной сущности Home Assistant больше не оставляет рядом
|
||||
дублирующий автоматический маркер всего родительского устройства. В
|
||||
auto-marker теперь входят только оставшиеся активные видимые сущности, а при
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1270,14 +1270,19 @@ show_signal: true
|
||||
|---|---|
|
||||
| Миграции модели | Старые, однозначно преобразуемые поля переводятся в текущий формат; для `entity:switch.*` собственный switch в старом `controls` становится ролью «Всегда» (для `device:*` это делает сохранение настроек устройства, где доступен реестр HA) |
|
||||
| Масштаб сетки | Некорректный старый `cell_cm` приводится к допустимому диапазону 0,1–1000 см |
|
||||
| Комнаты | Вершины округляются к сетке |
|
||||
| Комнаты | Вершины записываются точными узлами сетки; микроскопический floating-point шум также устраняется без видимого сдвига |
|
||||
| Декор и мебель | Положение и размеры округляются к сетке |
|
||||
| Устройства и подписи комнат | Позиции округляются к сетке |
|
||||
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
|
||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||
|
||||
Перед записью диалог показывает количество затрагиваемых элементов, максимальный сдвиг в сантиметрах и пространство с этим сдвигом.
|
||||
Перед записью диалог показывает количество затрагиваемых элементов,
|
||||
максимальный сдвиг в сантиметрах и пространство с этим сдвигом. Отдельная
|
||||
строка различает пространства с обновлённым представлением стен/связей и
|
||||
координаты, в которых устранён только вычислительный шум. Поэтому возможен
|
||||
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
|
||||
координат. Повторный Optimize над результатом ничего не предлагает.
|
||||
|
||||
### Что оптимизация сохраняет
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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": "Оптимизировать",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user