fix: canonicalize persisted geometry

Issue: #224
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 14:47:38 +03:00
parent 8442538b6b
commit 4a798e3e13
25 changed files with 1489 additions and 196 deletions
+135
View File
@@ -0,0 +1,135 @@
/**
* Stable, allow-listed persisted geometry.
*
* Mirrored by custom_components/houseplan/coordinate_canonicalization.py.
* Keep the precision, scalar formula and field allow-list in lockstep.
*/
export const COORDINATE_DECIMALS = 9;
export const COORDINATE_FACTOR = 10 ** COORDINATE_DECIMALS;
type JsonRecord = Record<string, any>;
function cloneJson<T>(value: T): T {
if (Array.isArray(value)) return value.map((item) => cloneJson(item)) as T;
if (value !== null && typeof value === 'object') {
const out: JsonRecord = {};
for (const [key, item] of Object.entries(value as JsonRecord)) out[key] = cloneJson(item);
return out as T;
}
return value;
}
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;
const result = sign
* (Math.floor(Math.abs(value) * COORDINATE_FACTOR + 0.5) / COORDINATE_FACTOR);
return result === 0 ? 0 : result;
}
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as JsonRecord : null;
}
function records(value: unknown): JsonRecord[] {
return Array.isArray(value)
? value.filter((item): item is JsonRecord => record(item) !== null)
: [];
}
function fields(item: JsonRecord, names: readonly string[]): void {
for (const name of names) {
if (Object.prototype.hasOwnProperty.call(item, name)) {
item[name] = canonicalizeNumber(item[name]);
}
}
}
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 points(value: unknown): void {
if (!Array.isArray(value)) return;
for (const item of value) point(item);
}
export function canonicalizePosition<T>(position: T): T {
const result = cloneJson(position);
const item = record(result);
if (item) fields(item, ['x', 'y']);
return result;
}
export function canonicalizeLayoutGeometry<T>(layout: T): T {
const result = cloneJson(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']);
}
return result;
}
export function canonicalizeConfigGeometry<T>(config: T): T {
const result = cloneJson(config);
const root = record(result);
if (!root) return result;
for (const space of records(root.spaces)) {
fields(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);
}
for (const wall of records(space.walls)) {
point(wall.a);
point(wall.b);
}
for (const opening of records(space.openings)) {
fields(opening, ['x', 'y', 'angle', 'length']);
const host = record(opening.host);
if (host) fields(host, ['t']);
}
for (const decor of records(space.decor)) {
if (decor.kind === 'line') fields(decor, ['x1', 'y1', 'x2', 'y2']);
else if (decor.kind === 'rect' || decor.kind === 'ellipse' || decor.kind === 'furniture') {
fields(decor, ['x', 'y', 'w', 'h', 'angle']);
} else if (decor.kind === 'text') {
fields(decor, ['x', 'y', 'scale', 'angle']);
}
}
for (const draft of records(space.room_drafts)) points(draft.points);
for (const partition of records(space.partitions)) {
point(partition.a);
point(partition.b);
}
for (const column of records(space.wall_columns)) {
point(column.center);
if (column.shape === 'square') fields(column, ['angle']);
}
for (const span of records(space.open_spans)) {
point(span.a);
point(span.b);
}
}
for (const marker of records(root.markers)) fields(marker, ['angle']);
return result;
}
+14 -2
View File
@@ -187,6 +187,11 @@ import {
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import {
canonicalizeConfigGeometry,
canonicalizeLayoutGeometry,
canonicalizePosition,
} from './coordinate-canonicalization';
import { hasTranslation, langOf, t, type I18nKey } from './i18n';
import { CommandStack } from './command-stack';
import { resolvedSvgScreenBlend, svgScreenBlendSupported } from './glow-blend';
@@ -4416,8 +4421,9 @@ class HouseplanCard extends LitElement {
const ids = [...this._dirtyPos];
this._dirtyPos.clear();
for (const id of ids) {
const pos = this._layout[id];
const pos = canonicalizePosition(this._layout[id]);
if (!pos) continue;
this._layout = { ...this._layout, [id]: pos };
// in flight until the server answers: a layout reload triggered in the
// meantime must keep this position, not the one the server still has
this._sentPos.set(id, pos);
@@ -4429,6 +4435,7 @@ class HouseplanCard extends LitElement {
}
this._cacheSnapshot();
} else {
this._layout = canonicalizeLayoutGeometry(this._layout);
localStorage.setItem(LS_KEY, JSON.stringify(this._layout));
}
}, 600);
@@ -6945,8 +6952,13 @@ class HouseplanCard extends LitElement {
.then(async () => {
if (!this._serverCfg) return;
this._dropLegacySegments();
const candidate = canonicalizeConfigGeometry(this._serverCfg);
const candidateFingerprint = contentFingerprint(candidate);
if (candidateFingerprint !== contentFingerprint(this._serverCfg)) this._cfgEpoch++;
this._serverCfg = candidate;
this._cfgContentFingerprint = candidateFingerprint;
const r = await this.hass.callWS({
type: 'houseplan/config/set', config: this._serverCfg, expected_rev: this._cfgRev,
type: 'houseplan/config/set', config: candidate, expected_rev: this._cfgRev,
});
this._cfgRev = r?.rev ?? this._cfgRev + 1;
});