mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
364 lines
16 KiB
TypeScript
364 lines
16 KiB
TypeScript
/**
|
|
* «Выровнять всё по сетке» / «Align everything to the grid» — docs/CANVAS.md §9.
|
|
*
|
|
* WHY THIS IS AN ACTION AND NOT A MIGRATION
|
|
* -----------------------------------------
|
|
* Every gesture in the editor now lands on the grid, but plans drawn before
|
|
* that contract existed (and imported plans) may hold coordinates between
|
|
* the nodes. The repair stays explicit and previewable because:
|
|
*
|
|
* 1. It moves the user's data without asking. A house plan is a drawing; the
|
|
* card has no mandate to redraw it on a version bump.
|
|
* 2. A silent migration is unattributable. When a room looks 3 cm wrong the
|
|
* owner cannot tell whether the card did it or they did.
|
|
*
|
|
* Intentional off-grid placement is no longer a supported product state
|
|
* (UX-05). The button is the repair path for legacy/imported data: it first
|
|
* says how many elements it will move and by how much at most, and only then
|
|
* writes once, in a single config+layout operation.
|
|
*
|
|
* WALL-BOUND VS GRID-BOUND (the contract this file implements)
|
|
* -----------------------------------------------------------
|
|
* Room vertices, decor geometry, device markers and room labels are GRID-BOUND:
|
|
* they are rounded to the nearest node. Openings are WALL-BOUND: a door that
|
|
* sits on a grid node but half a metre off its wall is broken geometry, so an
|
|
* opening is re-projected onto the nearest wall and its offset ALONG that wall
|
|
* is snapped to the same step. On an axis-aligned wall with grid-aligned
|
|
* corners the two rules agree exactly.
|
|
*
|
|
* THE REPORT IS A PROMISE, SO IT IS AN UPPER BOUND (AUD-158B1-01)
|
|
* --------------------------------------------------------------
|
|
* The confirmation is the only safety gate in front of an action with no undo.
|
|
* A number that is merely typical of what happens is worse than no number at
|
|
* all, so the maximum is measured on the geometry that is actually written
|
|
* back. Two things used to make it a sample instead of a bound:
|
|
*
|
|
* * a rect was measured by its origin and its far corner only. The far corner
|
|
* is then pushed out again by the minimum-size correction, and the other two
|
|
* corners — the ones that carry the X error of one side together with the Y
|
|
* error of the other — were never looked at, understating an ordinary box by
|
|
* up to √2 and a box thinner than one step by much more.
|
|
* * the single NORMALISED maximum was turned into centimetres by the caller,
|
|
* through the `cell_cm` of the FIRST space. Two floors drawn at 5 cm and at
|
|
* 100 cm per cell made that promise twenty times too small.
|
|
*
|
|
* So displacement is accumulated in CENTIMETRES, every space through its own
|
|
* `cell_cm`, and the report says which space the maximum belongs to.
|
|
*
|
|
* AN OPENING ALSO CARRIES AN ANGLE (AUD-158B1-02)
|
|
* ----------------------------------------------
|
|
* The batch rewrites `angle` as well as `x`/`y`. A window whose centre is
|
|
* already on its wall but whose stored angle is wrong is therefore a real
|
|
* correction — yet it used to come back inside `changed:false`, so the dialog
|
|
* said there was nothing to do and the fix could never be applied. The angle is
|
|
* part of the diff now, and displacement is measured on the opening's ENDS, so
|
|
* turning it is not free in the report either.
|
|
*/
|
|
|
|
import { GRID_N, GRID_STEP_N } from './space-geometry';
|
|
import { snapToWall } from './logic';
|
|
|
|
/** Anything closer than this to a node already counts as being on it. */
|
|
const EPS = GRID_STEP_N * 1e-6;
|
|
/** How far an opening may be from a wall and still be re-projected onto it. */
|
|
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 exact nearest grid node. */
|
|
export function snapN(v: number): number {
|
|
if (!Number.isFinite(v)) return v;
|
|
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). */
|
|
maxShift: number;
|
|
/** The same maximum in CENTIMETRES — every space through its own `cell_cm`,
|
|
* because a normalised number means nothing until it meets a scale. This is
|
|
* the number the confirmation promises, and it is an upper bound. */
|
|
maxShiftCm: number;
|
|
/** Which space holds that maximum (`''` when nothing moves). */
|
|
maxSpace: string;
|
|
/** Openings whose stored `angle` is corrected — possibly without moving. */
|
|
rotated: number;
|
|
}
|
|
|
|
export interface AlignResult {
|
|
spaces: any[];
|
|
layout: Record<string, any>;
|
|
report: AlignReport;
|
|
changed: boolean;
|
|
}
|
|
|
|
const dist = (ax: number, ay: number, bx: number, by: number) => Math.hypot(bx - ax, by - ay);
|
|
|
|
/** The displacement of an axis-aligned box: the largest of its FOUR corners,
|
|
* measured against the box that is really written back (minimum-size
|
|
* correction included). The worst corner combines the largest X error of
|
|
* either side with the largest Y error of either side — which is why the two
|
|
* that were never measured are exactly the two that can be the worst. */
|
|
const boxShift = (
|
|
x: number, y: number, w: number, h: number,
|
|
nx: number, ny: number, nw: number, nh: number,
|
|
): number => Math.hypot(
|
|
Math.max(Math.abs(nx - x), Math.abs((nx + nw) - (x + w))),
|
|
Math.max(Math.abs(ny - y), Math.abs((ny + nh) - (y + h))),
|
|
);
|
|
|
|
/** The displacement of an opening, measured on its two ENDS so that turning it
|
|
* in place is not free. An opening is a symmetric segment on its wall: 180°
|
|
* apart is the same segment, so the pairing of ends that gives the smaller
|
|
* answer is the true one — a flipped angle is a rewrite, not a move. */
|
|
const openingShift = (
|
|
x: number, y: number, a: number, len: number,
|
|
nx: number, ny: number, na: number,
|
|
): number => {
|
|
const h = Math.max(Number(len) || 0, 0) / 2;
|
|
const R = Math.PI / 180;
|
|
const ux = Math.cos(a * R) * h, uy = Math.sin(a * R) * h;
|
|
const vx = Math.cos(na * R) * h, vy = Math.sin(na * R) * h;
|
|
const same = Math.max(dist(x + ux, y + uy, nx + vx, ny + vy),
|
|
dist(x - ux, y - uy, nx - vx, ny - vy));
|
|
const flip = Math.max(dist(x + ux, y + uy, nx - vx, ny - vy),
|
|
dist(x - ux, y - uy, nx + vx, ny + vy));
|
|
return Math.min(same, flip);
|
|
};
|
|
|
|
const cellCmOf = (sp: any): number => {
|
|
const v = Number(sp?.cell_cm);
|
|
return v > 0 ? v : DEFAULT_CELL_CM;
|
|
};
|
|
|
|
/**
|
|
* The whole batch, as a pure function: give it the spaces and the layout, get
|
|
* back new ones plus the report the confirmation dialog shows. Nothing here
|
|
* touches the network, so the dialog can call it twice — once to preview, once
|
|
* to write — and be certain the numbers it promised are the numbers it did.
|
|
*/
|
|
export function alignAllToGrid(
|
|
spacesIn: any[], layoutIn: Record<string, any>,
|
|
): AlignResult {
|
|
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;
|
|
|
|
/** 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> = {};
|
|
let cellWorst = DEFAULT_CELL_CM;
|
|
for (const sp of spaces) {
|
|
const c = cellCmOf(sp);
|
|
if (sp?.id != null) cellById[String(sp.id)] = c;
|
|
if (c > cellWorst) cellWorst = c;
|
|
}
|
|
|
|
/** Record one element. `d` is normalised, `cellCm` turns it into the
|
|
* centimetres of its own space. `forced` is for a change that is real
|
|
* without being a displacement — an opening's angle. */
|
|
const note = (d: number, cellCm: number, spaceId: string, forced = false): void => {
|
|
if (!(d > EPS) && !forced) return;
|
|
moved++;
|
|
if (d > maxShift) maxShift = d;
|
|
const cm = d * GRID_N * cellCm;
|
|
if (cm > maxShiftCm) { maxShiftCm = cm; maxSpace = spaceId; }
|
|
};
|
|
|
|
for (const sp of spaces) {
|
|
const cell = cellCmOf(sp);
|
|
const sid = sp?.id != null ? String(sp.id) : '';
|
|
|
|
// ---- rooms: every vertex to the nearest node ----------------------
|
|
for (const r of sp.rooms || []) {
|
|
total++;
|
|
let d = 0;
|
|
if (r.poly?.length) {
|
|
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) {
|
|
// a rect keeps its far corner on the grid too, hence w/h are snapped
|
|
// as corners and not as sizes (a snapped size on an off-grid origin
|
|
// would leave the other side between the nodes).
|
|
const x0 = r.x, y0 = r.y, w0 = r.w || 0, h0 = r.h || 0;
|
|
const x2 = snapN(x0 + w0), y2 = snapN(y0 + h0);
|
|
const nx = snapN(x0), ny = snapN(y0);
|
|
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);
|
|
}
|
|
|
|
// ---- independent partitions and columns --------------------------
|
|
for (const p of sp.partitions || []) {
|
|
total++;
|
|
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'
|
|
&& opening.host.id === p.id)
|
|
.every((opening: any) => {
|
|
const length = Number(opening.length);
|
|
const t = Number(opening.host.t);
|
|
const along = t * snappedLength;
|
|
return Number.isFinite(length) && length > 0
|
|
&& Number.isFinite(t) && t >= 0 && t <= 1
|
|
&& along - length / 2 >= -EPS
|
|
&& along + length / 2 <= snappedLength + EPS;
|
|
});
|
|
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 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);
|
|
}
|
|
|
|
// ---- decor ---------------------------------------------------------
|
|
for (const sh of sp.decor || []) {
|
|
// #383 deliberately made these transforms continuous. Their saved
|
|
// provenance no longer tells placement from resize, so Optimize must
|
|
// preserve the whole authored transform instead of moving a corner or
|
|
// changing the size behind the user's back (#477).
|
|
if (sh.kind === 'furniture' || sh.kind === 'image') continue;
|
|
total++;
|
|
let d = 0;
|
|
if (sh.kind === 'line') {
|
|
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 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(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(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(x0, y0, nx, ny);
|
|
}
|
|
sh.x = nx; sh.y = ny;
|
|
noteCanonicalCoordinate(x0, nx);
|
|
noteCanonicalCoordinate(y0, ny);
|
|
}
|
|
note(d, cell, sid);
|
|
}
|
|
|
|
// ---- openings: wall-bound, snapped ALONG the (already aligned) wall --
|
|
// The very same helper the live editor uses, so the button cannot disagree
|
|
// with the drag. A stray opening with no wall within WALL_TOL is left
|
|
// exactly where it is rather than teleported across the plan.
|
|
for (const o of sp.openings || []) {
|
|
total++;
|
|
if (o.host?.kind === 'partition') {
|
|
const partition = (sp.partitions || []).find((item: any) => item.id === o.host.id);
|
|
const t = Number(o.host.t);
|
|
if (!partition || !Number.isFinite(t) || t < 0 || t > 1) continue;
|
|
const dx = partition.b[0] - partition.a[0];
|
|
const dy = partition.b[1] - partition.a[1];
|
|
if (Math.hypot(dx, dy) <= EPS) continue;
|
|
const nx = partition.a[0] + dx * t;
|
|
const ny = partition.a[1] + dy * t;
|
|
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
|
if (angle >= 90) angle -= 180;
|
|
else if (angle < -90) angle += 180;
|
|
const raw = Number(o.angle);
|
|
const turned = !(Number.isFinite(raw) && raw === angle);
|
|
const d = openingShift(o.x, o.y, Number.isFinite(raw) ? raw : angle,
|
|
Number(o.length) || 0, nx, ny, angle);
|
|
o.x = nx; o.y = ny; o.angle = angle;
|
|
if (turned) rotated++;
|
|
note(d, cell, sid, turned);
|
|
continue;
|
|
}
|
|
const q = snapToWall([o.x, o.y], sp.rooms || [], WALL_TOL,
|
|
{ step: GRID_STEP_N, length: Number(o.length) || 0 });
|
|
if (!q) continue;
|
|
// the angle is rewritten too, so it belongs in the diff: an opening
|
|
// already on its wall with a wrong angle is a correction that must be
|
|
// offerable, not a silent no-op (AUD-158B1-02)
|
|
const raw = Number(o.angle);
|
|
const turned = !(Number.isFinite(raw) && raw === q.angle);
|
|
const d = openingShift(o.x, o.y, Number.isFinite(raw) ? raw : q.angle,
|
|
Number(o.length) || 0, q.x, q.y, q.angle);
|
|
o.x = q.x; o.y = q.y; o.angle = q.angle;
|
|
if (turned) rotated++;
|
|
note(d, cell, sid, turned);
|
|
}
|
|
}
|
|
|
|
// ---- layout: device markers AND room labels (rl_<id>) ----------------
|
|
for (const [k, v] of Object.entries(layout)) {
|
|
if (!v || typeof v !== 'object') continue;
|
|
const p: any = v;
|
|
if (typeof p.x !== 'number' || typeof p.y !== 'number') continue;
|
|
total++;
|
|
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 : '';
|
|
note(d, cellById[sid] ?? cellWorst, sid);
|
|
}
|
|
|
|
return {
|
|
spaces, layout,
|
|
report: {
|
|
moved, coordsCanonicalized, total, maxShift, maxShiftCm, maxSpace,
|
|
rotated,
|
|
},
|
|
changed: moved > 0 || coordsCanonicalized > 0,
|
|
};
|
|
}
|