/** * «Выровнять всё по сетке» / «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; 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, ): AlignResult { const spaces = JSON.parse(JSON.stringify(spacesIn || [])); const layout: Record = 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 = {}; 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_) ---------------- 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, }; }