fix: enforce exact lattice coordinates

Issue: #291
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 17:13:21 +03:00
parent c8a3878568
commit 4ccf4a3ccc
31 changed files with 1213 additions and 316 deletions
+252 -28
View File
@@ -2,14 +2,37 @@
* Stable, allow-listed persisted geometry.
*
* Mirrored by custom_components/houseplan/coordinate_canonicalization.py.
* Keep the precision, scalar formula and field allow-list in lockstep.
* Keep the precision, lattice formula and field allow-list in lockstep.
*/
export const COORDINATE_DECIMALS = 9;
export const COORDINATE_FACTOR = 10 ** COORDINATE_DECIMALS;
export const LATTICE_GRID_N = 240;
export const LATTICE_NOISE_STEPS = 1e-4;
type JsonRecord = Record<string, any>;
export interface LatticeSpaceReport {
spaceId: string;
space: string;
canonicalized: number;
far: number;
maxShift: number;
maxShiftCm: number;
}
export interface LatticeCanonicalizationReport {
canonicalized: number;
far: number;
maxShift: number;
maxShiftCm: number;
spaces: LatticeSpaceReport[];
}
interface MutableLatticeReport extends LatticeCanonicalizationReport {
bySpace: Map<string, LatticeSpaceReport>;
}
function cloneJson<T>(value: T): T {
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
if (value !== null && typeof value === 'object') {
@@ -20,6 +43,7 @@ function cloneJson<T>(value: T): T {
return value;
}
/** Existing scalar contract for transforms, angles, lengths and ratios. */
export function canonicalizeNumber(value: unknown): unknown {
if (typeof value !== 'number' || !Number.isFinite(value)) return value;
const sign = value < 0 || Object.is(value, -0) ? -1 : 1;
@@ -28,6 +52,27 @@ export function canonicalizeNumber(value: unknown): unknown {
return result === 0 ? 0 : result;
}
/** Collapse only an unobservable tail around a 1/240 node. */
export function canonicalizeLatticeCoordinate(value: unknown): unknown {
if (typeof value !== 'number' || !Number.isFinite(value)) return value;
const scaled = value * LATTICE_GRID_N;
const nearest = Math.round(scaled);
const deviation = Math.abs(scaled - nearest);
if (deviation < LATTICE_NOISE_STEPS) {
const result = nearest / LATTICE_GRID_N;
return result === 0 ? 0 : result;
}
return canonicalizeNumber(value);
}
/** Three significant digits without turning a non-zero sub-millimetre shift into zero. */
export function formatLatticeShiftCm(value: number): string {
if (!Number.isFinite(value) || value === 0) return '0';
const absolute = Math.abs(value);
if (absolute < 0.001) return value.toExponential(2);
return String(Number(value.toPrecision(3)));
}
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as JsonRecord : null;
@@ -39,7 +84,7 @@ function records(value: unknown): JsonRecord[] {
: [];
}
function fields(item: JsonRecord, names: readonly string[]): void {
function scalarFields(item: JsonRecord, names: readonly string[]): void {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(item, name)) {
item[name] = canonicalizeNumber(item[name]);
@@ -47,89 +92,268 @@ function fields(item: JsonRecord, names: readonly string[]): void {
}
}
function point(value: unknown): void {
if (!Array.isArray(value)) return;
for (let index = 0; index < Math.min(2, value.length); index++) {
value[index] = canonicalizeNumber(value[index]);
function latticeFields(item: JsonRecord, names: readonly string[]): void {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(item, name)) {
item[name] = canonicalizeLatticeCoordinate(item[name]);
}
}
}
function points(value: unknown): void {
function latticePoint(value: unknown): void {
if (!Array.isArray(value)) return;
for (const item of value) point(item);
for (let index = 0; index < Math.min(2, value.length); index++) {
value[index] = canonicalizeLatticeCoordinate(value[index]);
}
}
function latticePoints(value: unknown): void {
if (!Array.isArray(value)) return;
for (const item of value) latticePoint(item);
}
/** Visit exactly the persisted coordinate allow-list, without cloning. */
function visitLatticeCoordinates(
config: unknown,
layout: unknown,
visit: (value: number, space: JsonRecord | null) => void,
): void {
const root = record(config);
if (root) {
for (const space of records(root.spaces)) {
const fieldValues = (item: JsonRecord, names: readonly string[]): void => {
for (const name of names) {
const value = item[name];
if (typeof value === 'number' && Number.isFinite(value)) visit(value, space);
}
};
const pointValues = (value: unknown): void => {
if (!Array.isArray(value)) return;
for (let index = 0; index < Math.min(2, value.length); index++) {
const coordinate = value[index];
if (typeof coordinate === 'number' && Number.isFinite(coordinate)) {
visit(coordinate, space);
}
}
};
const pointsValues = (value: unknown): void => {
if (Array.isArray(value)) for (const point of value) pointValues(point);
};
for (const room of records(space.rooms)) {
fieldValues(room, ['x', 'y', 'w', 'h']);
pointsValues(room.poly);
}
for (const wall of records(space.walls)) {
pointValues(wall.a);
pointValues(wall.b);
}
for (const opening of records(space.openings)) fieldValues(opening, ['x', 'y']);
for (const decor of records(space.decor)) {
if (decor.kind === 'line') fieldValues(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
fieldValues(decor, ['x', 'y', 'w', 'h']);
} else if (decor.kind === 'text') fieldValues(decor, ['x', 'y']);
}
for (const draft of records(space.room_drafts)) pointsValues(draft.points);
for (const partition of records(space.partitions)) {
pointValues(partition.a);
pointValues(partition.b);
}
for (const column of records(space.wall_columns)) pointValues(column.center);
for (const span of records(space.open_spans)) {
pointValues(span.a);
pointValues(span.b);
}
}
}
const layoutRoot = record(layout);
if (!layoutRoot) return;
const spacesById = new Map<string, JsonRecord>();
if (root) {
for (const space of records(root.spaces)) {
if (space.id != null) spacesById.set(String(space.id), space);
}
}
for (const value of Object.values(layoutRoot)) {
const item = record(value);
if (!item) continue;
const owner = item.s != null ? spacesById.get(String(item.s)) || null : null;
for (const name of ['x', 'y']) {
const coordinate = item[name];
if (typeof coordinate === 'number' && Number.isFinite(coordinate)) visit(coordinate, owner);
}
}
}
function emptyReport(): MutableLatticeReport {
return {
canonicalized: 0,
far: 0,
maxShift: 0,
maxShiftCm: 0,
spaces: [],
bySpace: new Map(),
};
}
function cellCm(space: JsonRecord | null): number {
const value = Number(space?.cell_cm);
return value > 0 ? value : 5;
}
/** Measure the exact work the boundary will do, without cloning or writing. */
export function latticeCanonicalizationReport(
config: unknown,
layout: unknown = {},
): LatticeCanonicalizationReport {
const report = emptyReport();
const spaces = records(record(config)?.spaces);
let worstCellCm = 5;
for (const space of spaces) worstCellCm = Math.max(worstCellCm, cellCm(space));
visitLatticeCoordinates(config, layout, (value, space) => {
const scaled = value * LATTICE_GRID_N;
const deviation = Math.abs(scaled - Math.round(scaled));
const canonical = canonicalizeLatticeCoordinate(value);
const isNoise = deviation > 0 && deviation < LATTICE_NOISE_STEPS;
const isFar = deviation >= LATTICE_NOISE_STEPS;
if (!isNoise && !isFar) return;
const shift = isNoise && typeof canonical === 'number' ? Math.abs(canonical - value) : 0;
if (isNoise) report.canonicalized++;
else report.far++;
report.maxShift = Math.max(report.maxShift, shift);
report.maxShiftCm = Math.max(
report.maxShiftCm,
shift * LATTICE_GRID_N * (space ? cellCm(space) : worstCellCm),
);
if (!space?.id || !isNoise) return;
const id = String(space.id);
let item = report.bySpace.get(id);
if (!item) {
item = {
spaceId: id,
space: String(space.title || id),
canonicalized: 0,
far: 0,
maxShift: 0,
maxShiftCm: 0,
};
report.bySpace.set(id, item);
}
item.canonicalized++;
item.maxShift = Math.max(item.maxShift, shift);
item.maxShiftCm = Math.max(item.maxShiftCm, shift * LATTICE_GRID_N * cellCm(space));
});
// Far values are useful only next to a space that the barrier actually
// touched. Count them in a second allocation-free pass into those rows.
if (report.bySpace.size) {
visitLatticeCoordinates(config, layout, (value, space) => {
if (!space?.id) return;
const item = report.bySpace.get(String(space.id));
if (!item) return;
const scaled = value * LATTICE_GRID_N;
if (Math.abs(scaled - Math.round(scaled)) >= LATTICE_NOISE_STEPS) item.far++;
});
}
report.spaces = [...report.bySpace.values()];
const { bySpace: _bySpace, ...publicReport } = report;
return publicReport;
}
export function canonicalizePosition<T>(position: T): T {
const result = cloneJson(position);
return canonicalizePositionInPlace(result);
}
export function canonicalizePositionInPlace<T>(position: T): T {
const result = position;
const item = record(result);
if (item) fields(item, ['x', 'y']);
if (item) latticeFields(item, ['x', 'y']);
return result;
}
export function canonicalizeLayoutGeometry<T>(layout: T): T {
const result = cloneJson(layout);
return canonicalizeLayoutGeometryInPlace(result);
}
export function canonicalizeLayoutGeometryInPlace<T>(layout: T): T {
const result = layout;
const root = record(result);
if (!root) return result;
for (const value of Object.values(root)) {
const item = record(value);
if (item) fields(item, ['x', 'y']);
if (item) latticeFields(item, ['x', 'y']);
}
return result;
}
export function canonicalizeConfigGeometry<T>(config: T): T {
const result = cloneJson(config);
return canonicalizeConfigGeometryInPlace(result);
}
export function canonicalizeConfigGeometryInPlace<T>(config: T): T {
const result = config;
const root = record(result);
if (!root) return result;
for (const space of records(root.spaces)) {
fields(space, [
scalarFields(space, [
'plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle',
]);
for (const room of records(space.rooms)) {
fields(room, ['x', 'y', 'w', 'h']);
points(room.poly);
latticeFields(room, ['x', 'y', 'w', 'h']);
latticePoints(room.poly);
}
for (const wall of records(space.walls)) {
point(wall.a);
point(wall.b);
latticePoint(wall.a);
latticePoint(wall.b);
}
for (const opening of records(space.openings)) {
fields(opening, ['x', 'y', 'angle', 'length']);
latticeFields(opening, ['x', 'y']);
scalarFields(opening, ['angle', 'length']);
const host = record(opening.host);
if (host) fields(host, ['t']);
if (host) scalarFields(host, ['t']);
}
for (const decor of records(space.decor)) {
if (decor.kind === 'line') fields(decor, ['x1', 'y1', 'x2', 'y2']);
if (decor.kind === 'line') latticeFields(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
fields(decor, ['x', 'y', 'w', 'h', 'angle']);
latticeFields(decor, ['x', 'y', 'w', 'h']);
scalarFields(decor, ['angle']);
} else if (decor.kind === 'text') {
fields(decor, ['x', 'y', 'scale', 'angle']);
latticeFields(decor, ['x', 'y']);
scalarFields(decor, ['scale', 'angle']);
}
}
for (const draft of records(space.room_drafts)) points(draft.points);
for (const draft of records(space.room_drafts)) latticePoints(draft.points);
for (const partition of records(space.partitions)) {
point(partition.a);
point(partition.b);
latticePoint(partition.a);
latticePoint(partition.b);
}
for (const column of records(space.wall_columns)) {
point(column.center);
if (column.shape === 'square') fields(column, ['angle']);
latticePoint(column.center);
if (column.shape === 'square') scalarFields(column, ['angle']);
}
for (const span of records(space.open_spans)) {
point(span.a);
point(span.b);
latticePoint(span.a);
latticePoint(span.b);
}
}
for (const marker of records(root.markers)) fields(marker, ['angle']);
for (const marker of records(root.markers)) scalarFields(marker, ['angle']);
return result;
}
+23 -2
View File
@@ -208,6 +208,7 @@ import {
canonicalizeConfigGeometry,
canonicalizeLayoutGeometry,
canonicalizePosition,
formatLatticeShiftCm,
} from './coordinate-canonicalization';
import { enqueueSerializedWrite } from './serialized-write-queue';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
@@ -14205,7 +14206,13 @@ class HouseplanCard extends LitElement {
this._layout = { ...this._layout, [id]: newPos };
}
await this._saveConfigNow();
if (newPos) this._noteLayoutRev(await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos }));
if (newPos) {
const pos = canonicalizePosition(newPos);
this._layout = { ...this._layout, [id]: pos };
this._noteLayoutRev(await this.hass.callWS({
type: 'houseplan/layout/update', device_id: id, pos,
}));
}
const obsoleteIds = new Set(replacedRemovedIds);
if (oldId && oldId !== id) obsoleteIds.add(oldId);
obsoleteIds.delete(id);
@@ -15333,7 +15340,8 @@ 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.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged
+ d.report.coordsCanonicalized + d.report.latticeCoordinatesCanonicalized
+ d.report.wallsMerged + d.report.spansMerged
+ d.report.partitionsMerged + d.report.partitionsReconciled
+ d.report.openingsRehosted + d.report.wallsStraightened),
r: String(d.report.spaceRefsRemapped + d.report.roomRefsRemapped
@@ -16410,6 +16418,19 @@ class HouseplanCard extends LitElement {
${r.moved ? html`<p class="alignmsg">${this._t('gs.align_count', {
n: String(r.moved), total: String(r.total), cm: String(d.cm),
})}</p>` : nothing}
${r.latticeCoordinatesCanonicalized ? html`
<p class="alignmsg">${this._t('gs.optimize_lattice_summary', {
n: String(r.latticeCoordinatesCanonicalized),
cm: formatLatticeShiftCm(r.latticeMaxShiftCm),
})}</p>
${r.latticeSpaces.map((space) => html`<p class="alignmsg">${this._t(
'gs.optimize_lattice_space', {
space: space.space,
n: String(space.canonicalized),
far: String(space.far),
},
)}</p>`)}
` : nothing}
${d.where
? html`<p class="alignmsg">${this._t('gs.align_where', { s: d.where })}</p>`
: nothing}
+2
View File
@@ -815,6 +815,8 @@
"gs.align_preflight_space": "Space {n}",
"gs.align_preflight_more": ", and {n} more",
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.",
"gs.optimize_lattice_summary": "Noisy coordinate values canonicalized: {n}; maximum movement: {cm} cm.",
"gs.optimize_lattice_space": "{space}: coordinate values canonicalized: {n}; off-grid values left unchanged: {far}.",
"gs.optimize_coincident_partitions": "Coincident partitions converted to room walls: {n}.",
"gs.optimize_openings_rehosted": "Openings reattached to room walls: {n}.",
"gs.optimize_walls_straightened": "Walls straightened: {n}; maximum movement: {cm} cm.",
+2
View File
@@ -815,6 +815,8 @@
"gs.align_preflight_space": "Пространство {n}",
"gs.align_preflight_more": " и ещё {n}",
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
"gs.optimize_lattice_summary": "Канонизировано шумовых значений координат: {n}; максимальный сдвиг: {cm} см.",
"gs.optimize_lattice_space": "{space}: канонизировано значений координат: {n}; оставлено значений вне сетки: {far}.",
"gs.optimize_coincident_partitions": "Совпадающие перегородки преобразованы в стены: {n}.",
"gs.optimize_openings_rehosted": "Проёмы перепривязаны к стенам комнат: {n}.",
"gs.optimize_walls_straightened": "Выпрямлено стен: {n}; максимальное перемещение: {cm} см.",
+25 -3
View File
@@ -10,7 +10,8 @@
import { alignAllToGrid, type AlignReport } from './align-grid';
import {
canonicalizeConfigGeometry, canonicalizeLayoutGeometry,
canonicalizeConfigGeometryInPlace, canonicalizeLayoutGeometryInPlace,
latticeCanonicalizationReport, type LatticeSpaceReport,
} from './coordinate-canonicalization';
import {
DECOR_TEXT_BASE, decorTextScale, liveTextReference, liveTextToken, roomPoly,
@@ -73,6 +74,16 @@ export interface OptimizeReport extends AlignReport, SpaceReferenceReport {
maxStraightenShiftCm: number;
/** Space owning the largest accepted straightening movement. */
maxStraightenSpace: string;
/** Near-node coordinate components rewritten to the exact 1/240 double. */
latticeCoordinatesCanonicalized: number;
/** Authored off-grid components observed and deliberately left for Align. */
latticeCoordinatesFar: number;
/** Largest storage-only coordinate shift in normalized units. */
latticeMaxShift: number;
/** Largest storage-only coordinate shift through its owning space scale. */
latticeMaxShiftCm: number;
/** Only user-named spaces with at least one rewritten coordinate. */
latticeSpaces: LatticeSpaceReport[];
}
export interface OptimizeResult {
@@ -413,6 +424,12 @@ export function optimizePlans(
?? reconcileCoincidentPartitions;
const references = repairSpaceReferences(configIn, layoutIn, context);
const config = references.config;
const lattice = latticeCanonicalizationReport(config, references.layout);
// repairSpaceReferences already owns the immutable candidate clone. Apply
// the boundary in-place here so Optimize does not allocate a second full
// config/layout clone merely to remove sub-pixel storage tails (#291).
canonicalizeConfigGeometryInPlace(config);
canonicalizeLayoutGeometryInPlace(references.layout);
const original = JSON.stringify(configIn || {});
const originalLayout = JSON.stringify(layoutIn || {});
const modelFrom = Number.isInteger(Number(config.model_version))
@@ -629,8 +646,8 @@ export function optimizePlans(
// canonical form look dirty again after the update event reloads it (#248).
// Canonicalise the complete pair before both the diff and the return so the
// preview, durable intent, live stores and next preview all see one target.
const persistedConfig = canonicalizeConfigGeometry(config);
const persistedLayout = canonicalizeLayoutGeometry(finalAligned.layout);
const persistedConfig = canonicalizeConfigGeometryInPlace(config);
const persistedLayout = canonicalizeLayoutGeometryInPlace(finalAligned.layout);
// A version marker is bookkeeping, not maintenance by itself. Persist it
// only alongside a real config/layout transformation; otherwise an already
@@ -691,6 +708,11 @@ export function optimizePlans(
wallsStraightenSkipped,
maxStraightenShiftCm: changed ? maxStraightenShiftCm : 0,
maxStraightenSpace: changed ? maxStraightenSpace : '',
latticeCoordinatesCanonicalized: changed ? lattice.canonicalized : 0,
latticeCoordinatesFar: changed ? lattice.far : 0,
latticeMaxShift: changed ? lattice.maxShift : 0,
latticeMaxShiftCm: changed ? lattice.maxShiftCm : 0,
latticeSpaces: changed ? lattice.spaces : [],
...persistedReferences,
},
changed,