mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
v1.59.0-rc.1: optimize plans and polish editor feedback
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Explicit, idempotent maintenance for a whole House Plan model.
|
||||
*
|
||||
* This is deliberately narrower than "delete anything suspicious". It only
|
||||
* performs transformations whose meaning is known and lossless, then aligns
|
||||
* plan objects to the lattice and rewrites derived wall/open-span storage into
|
||||
* its canonical form. Unknown fields, backdrop calibration, view boxes,
|
||||
* unattached layout entries and files are preserved.
|
||||
*/
|
||||
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { decorTextScale, liveTextReference, liveTextToken, roomPoly } from './logic';
|
||||
import {
|
||||
clipOpenSpansToShared, cutsToSpanEntries, entryToSeg,
|
||||
projectOnSeg, rekeyOpenSpansAfterMove, resolveOpenCuts, sanitizeOpenSpans,
|
||||
snapOpenPoint, spanToEntry, syncOpenToFromCuts,
|
||||
} from './open-spans';
|
||||
import { GRID_PITCH, GRID_STEP_N, NORM_W, spaceModels } from './space-geometry';
|
||||
import {
|
||||
degradeWalls, normalizeWallIntervals, rekeyWallsAfterMove,
|
||||
} from './wall-thickness';
|
||||
|
||||
/** Bump when a new lossless maintenance pass is added. */
|
||||
export const PLAN_MODEL_VERSION = 1;
|
||||
const DEFAULT_CELL_CM = 5;
|
||||
|
||||
export interface OptimizeReport extends AlignReport {
|
||||
modelFrom: number;
|
||||
modelTo: number;
|
||||
/** Legacy fields converted or removed. */
|
||||
migrated: number;
|
||||
/** Spaces whose wall/open-span representation was rewritten canonically. */
|
||||
canonicalized: number;
|
||||
/** Redundant equal-thickness wall entries removed by canonicalisation. */
|
||||
wallsMerged: number;
|
||||
/** Touching/overlapping virtual pieces merged on the same room pair. */
|
||||
spansMerged: number;
|
||||
}
|
||||
|
||||
export interface OptimizeResult {
|
||||
config: any;
|
||||
layout: Record<string, any>;
|
||||
report: OptimizeReport;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const own = (o: any, key: string): boolean => Object.prototype.hasOwnProperty.call(o, key);
|
||||
|
||||
const modelOf = (space: any): any => (
|
||||
spaceModels({ spaces: [space], markers: [], settings: {} } as any)[0]
|
||||
);
|
||||
|
||||
/** Parallel per-room edges; unlike deduped roomEdges(), indices remain stable
|
||||
* while vertices are rounded, which lets exact wall fragments follow a move. */
|
||||
const edgePairs = (rooms: any[]): [number[], number[]][] => {
|
||||
const out: [number[], number[]][] = [];
|
||||
for (const room of rooms || []) {
|
||||
const poly = roomPoly(room);
|
||||
if (!poly?.length) continue;
|
||||
for (let i = 0; i < poly.length; i++) {
|
||||
out.push([
|
||||
[poly[i][0], poly[i][1]],
|
||||
[poly[(i + 1) % poly.length][0], poly[(i + 1) % poly.length][1]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Convert read-compatible legacy fields where the conversion is exact. */
|
||||
const migrateLosslessly = (config: any): number => {
|
||||
let n = 0;
|
||||
for (const marker of config.markers || []) {
|
||||
if (marker.display === 'ripple') { marker.display = 'icon_ripple'; n++; }
|
||||
const vacuum = marker.vacuum;
|
||||
if (vacuum && own(vacuum, 'trail')) {
|
||||
if (!['never', 'cleaning', 'always'].includes(vacuum.trail_mode)) {
|
||||
vacuum.trail_mode = vacuum.trail === false ? 'never' : 'cleaning';
|
||||
}
|
||||
delete vacuum.trail;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
for (const space of config.spaces || []) {
|
||||
if (own(space, 'segments')) { delete space.segments; n++; }
|
||||
for (const shape of space.decor || []) {
|
||||
if (shape?.kind !== 'text') continue;
|
||||
|
||||
// Old s/m/l text sizes have an exact numerical equivalent.
|
||||
if (shape.scale === undefined && shape.size !== undefined) {
|
||||
shape.scale = decorTextScale(shape);
|
||||
delete shape.size;
|
||||
n++;
|
||||
}
|
||||
|
||||
let text = String(shape.text ?? '');
|
||||
const hasInline = [...text.matchAll(/\{([^{}\r\n]+)\}/g)]
|
||||
.some((m) => !!liveTextReference(m[1]));
|
||||
const hasLegacy = own(shape, 'entity') || own(shape, 'attr') || own(shape, 'unit');
|
||||
if (!hasLegacy) continue;
|
||||
|
||||
// Inline references are already authoritative, so stale side fields can
|
||||
// be discarded. Otherwise migrate only the subset representable without
|
||||
// losing an explicit unit or a non-canonical attribute.
|
||||
const explicitUnit = String(shape.unit ?? '').trim();
|
||||
// The legacy selector stored the entity state as attr="state". In the
|
||||
// inline grammar state is the bare entity token; `:state` would instead
|
||||
// ask Home Assistant for an attribute named "state" and change meaning.
|
||||
const legacyAttr = String(shape.attr ?? '').trim().toLowerCase() === 'state'
|
||||
? null
|
||||
: shape.attr;
|
||||
const token = !hasInline && !explicitUnit
|
||||
? liveTextToken(shape.entity, legacyAttr)
|
||||
: '';
|
||||
if (hasInline || token) {
|
||||
if (token) {
|
||||
const slot = text.indexOf('{}');
|
||||
text = slot >= 0
|
||||
? text.slice(0, slot) + token + text.slice(slot + 2)
|
||||
: `${text}${text ? ' ' : ''}${token}`;
|
||||
shape.text = text;
|
||||
}
|
||||
delete shape.entity;
|
||||
delete shape.attr;
|
||||
delete shape.unit;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Produce the exact config/layout pair that the confirmation dialog previews.
|
||||
* Calling it again on its own output is a no-op.
|
||||
*/
|
||||
export function optimizePlans(configIn: any, layoutIn: Record<string, any>): OptimizeResult {
|
||||
const config = clone(configIn || { spaces: [], markers: [], settings: {} });
|
||||
const original = JSON.stringify(configIn || {});
|
||||
const originalLayout = JSON.stringify(layoutIn || {});
|
||||
const modelFrom = Number.isInteger(Number(config.model_version))
|
||||
? Number(config.model_version)
|
||||
: 0;
|
||||
let migrated = migrateLosslessly(config);
|
||||
|
||||
// Materialise legacy open_to before geometry moves. Once explicit spans
|
||||
// exist, they are the source of truth and open_to becomes a derived index.
|
||||
for (const space of config.spaces || []) {
|
||||
const model = modelOf(space);
|
||||
if (!model) continue;
|
||||
const existing = sanitizeOpenSpans(space.open_spans);
|
||||
if (!existing.length) {
|
||||
const cuts = resolveOpenCuts(
|
||||
model.rooms, null, NORM_W, GRID_PITCH * 0.02, true,
|
||||
);
|
||||
if (cuts.length) {
|
||||
space.open_spans = cutsToSpanEntries(cuts, NORM_W);
|
||||
migrated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const beforeSpaces = clone(config.spaces || []);
|
||||
const aligned = alignAllToGrid(config.spaces || [], layoutIn || {});
|
||||
config.spaces = aligned.spaces;
|
||||
const alignReport: AlignReport = { ...aligned.report };
|
||||
|
||||
let wallsMerged = 0;
|
||||
let spansMerged = 0;
|
||||
let canonicalized = 0;
|
||||
for (let i = 0; i < config.spaces.length; i++) {
|
||||
const before = beforeSpaces[i];
|
||||
const space = config.spaces[i];
|
||||
const canonicalBefore = JSON.stringify({
|
||||
spans: before.open_spans || [],
|
||||
links: (before.rooms || []).map((r: any) => [r.id, r.open_to || []]),
|
||||
walls: before.walls || [],
|
||||
});
|
||||
const oldModel = modelOf(before);
|
||||
const nextModel = modelOf(space);
|
||||
if (!oldModel || !nextModel) continue;
|
||||
|
||||
const oldEdges = edgePairs(oldModel.rooms);
|
||||
const nextEdges = edgePairs(nextModel.rooms);
|
||||
const eps = GRID_PITCH * 0.02;
|
||||
|
||||
const sourceSpans = sanitizeOpenSpans(before.open_spans);
|
||||
let spans = rekeyOpenSpansAfterMove(sourceSpans, oldEdges, nextEdges, NORM_W);
|
||||
const spanParts = spans.length;
|
||||
spans = clipOpenSpansToShared(spans, nextModel.rooms, NORM_W, eps);
|
||||
// Virtual endpoints are wall-bound: snap them by distance along the room
|
||||
// edge, just like the drawing tool, instead of rounding X/Y independently
|
||||
// (which would pull a point off a diagonal wall).
|
||||
spans = spans.map((entry) => {
|
||||
const sg = entryToSeg(entry, NORM_W);
|
||||
const a = [sg[0], sg[1]], b = [sg[2], sg[3]];
|
||||
const mid = [(sg[0] + sg[2]) / 2, (sg[1] + sg[3]) / 2];
|
||||
const dx = sg[2] - sg[0], dy = sg[3] - sg[1];
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const edge = nextEdges
|
||||
.map(([ea, eb]) => [ea[0], ea[1], eb[0], eb[1]])
|
||||
.filter((candidate) => {
|
||||
const ex = candidate[2] - candidate[0], ey = candidate[3] - candidate[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
return Math.abs(dx * ey - dy * ex) / (len * elen) <= 1e-6
|
||||
&& projectOnSeg(mid, candidate).d <= eps * 4
|
||||
&& projectOnSeg(a, candidate).d <= eps * 4
|
||||
&& projectOnSeg(b, candidate).d <= eps * 4;
|
||||
})
|
||||
.sort((x, y) => Math.hypot(y[2] - y[0], y[3] - y[1])
|
||||
- Math.hypot(x[2] - x[0], x[3] - x[1]))[0];
|
||||
alignReport.total++;
|
||||
if (!edge) return entry;
|
||||
const joints = [[edge[0], edge[1]], [edge[2], edge[3]]];
|
||||
const na = snapOpenPoint(a, edge, joints, GRID_PITCH, eps * 2);
|
||||
const nb = snapOpenPoint(b, edge, joints, GRID_PITCH, eps * 2);
|
||||
if (Math.hypot(nb[0] - na[0], nb[1] - na[1]) < GRID_PITCH * 0.5) return entry;
|
||||
const shift = Math.max(Math.hypot(na[0] - a[0], na[1] - a[1]),
|
||||
Math.hypot(nb[0] - b[0], nb[1] - b[1]));
|
||||
if (shift > GRID_PITCH * 1e-6) {
|
||||
alignReport.moved++;
|
||||
const shiftN = shift / NORM_W;
|
||||
if (shiftN > alignReport.maxShift) alignReport.maxShift = shiftN;
|
||||
const shiftCm = (shift / GRID_PITCH)
|
||||
* (Number(space.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM);
|
||||
if (shiftCm > alignReport.maxShiftCm) {
|
||||
alignReport.maxShiftCm = shiftCm;
|
||||
alignReport.maxSpace = String(space.id || '');
|
||||
}
|
||||
}
|
||||
return spanToEntry(na, nb, NORM_W);
|
||||
});
|
||||
spans = clipOpenSpansToShared(spans, nextModel.rooms, NORM_W, eps);
|
||||
spansMerged += Math.max(0, spanParts - spans.length);
|
||||
const cuts = spans.map((entry) => entryToSeg(entry, NORM_W));
|
||||
if (spans.length) space.open_spans = spans;
|
||||
else delete space.open_spans;
|
||||
syncOpenToFromCuts(space.rooms || [], nextModel.rooms, cuts, eps);
|
||||
|
||||
const wallParts = Array.isArray(before.walls) ? before.walls.length : 0;
|
||||
let walls = rekeyWallsAfterMove(
|
||||
before.walls, oldEdges, nextEdges, GRID_STEP_N, NORM_W,
|
||||
);
|
||||
walls = normalizeWallIntervals(
|
||||
nextModel.rooms, walls, cuts, GRID_STEP_N,
|
||||
Number(space.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM,
|
||||
GRID_PITCH, NORM_W,
|
||||
);
|
||||
walls = degradeWalls(
|
||||
walls, space.rooms || [], GRID_STEP_N, 1,
|
||||
cuts.map((c) => [c[0] / NORM_W, c[1] / NORM_W, c[2] / NORM_W, c[3] / NORM_W]),
|
||||
);
|
||||
wallsMerged += Math.max(0, wallParts - walls.length);
|
||||
if (walls.length) space.walls = walls;
|
||||
else delete space.walls;
|
||||
const canonicalAfter = JSON.stringify({
|
||||
spans: space.open_spans || [],
|
||||
links: (space.rooms || []).map((r: any) => [r.id, r.open_to || []]),
|
||||
walls: space.walls || [],
|
||||
});
|
||||
if (canonicalAfter !== canonicalBefore) canonicalized++;
|
||||
}
|
||||
|
||||
if (modelFrom !== PLAN_MODEL_VERSION) {
|
||||
config.model_version = PLAN_MODEL_VERSION;
|
||||
migrated++;
|
||||
}
|
||||
|
||||
const changed = JSON.stringify(config) !== original
|
||||
|| JSON.stringify(aligned.layout) !== originalLayout;
|
||||
return {
|
||||
config,
|
||||
layout: aligned.layout,
|
||||
report: {
|
||||
...alignReport,
|
||||
modelFrom,
|
||||
modelTo: PLAN_MODEL_VERSION,
|
||||
migrated,
|
||||
canonicalized,
|
||||
wallsMerged,
|
||||
spansMerged,
|
||||
},
|
||||
changed,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user