"""One-time migration to a square canvas — pure, so it can be tested alone. Until v1.48.0 a space had an `aspect`, and coordinates were normalised against it: x by the width, y by the HEIGHT. Making every canvas square without touching the numbers would stretch every plan vertically. Nothing about the drawing changes here. The canvas is padded to a square — top and bottom for a wide plan, left and right for a tall one — and the coordinates are re-expressed against that larger box. In render units it is a uniform scale plus an offset, so angles, room proportions and relative positions survive exactly. `cell_cm` follows, because the grid is tied to the width: for a tall plan the width grew, so the same wall would otherwise measure less. """ from __future__ import annotations import logging from typing import Any _LOGGER = logging.getLogger(__name__) def transform_for(aspect: float) -> tuple[float, float, float, float]: """(dx, dy, kx, ky) that map old normalised coordinates onto the square. x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that axis's factor; both are the same uniform scale in RENDER units, which is why angles are preserved. """ a = float(aspect) if not a or a <= 0: a = 1.0 k = min(1.0, a) # how much the old box shrinks inside the square kx = k # x was normalised by the width ky = k / a # y was normalised by the height (= width / aspect) return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any: if isinstance(p, (list, tuple)) and len(p) >= 2: return [dx + float(p[0]) * kx, dy + float(p[1]) * ky] return p def migrate_space(space: dict[str, Any]) -> bool: """Rewrite one space in place. Returns True when anything was changed.""" if "aspect" not in space: return False try: aspect = float(space.get("aspect") or 1) except (TypeError, ValueError): aspect = 1.0 dx, dy, kx, ky = transform_for(aspect) space.pop("aspect", None) for room in space.get("rooms") or []: if room.get("x") is not None: room["x"] = dx + float(room["x"]) * kx if room.get("y") is not None: room["y"] = dy + float(room["y"]) * ky if room.get("w") is not None: room["w"] = float(room["w"]) * kx if room.get("h") is not None: room["h"] = float(room["h"]) * ky if room.get("poly"): room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]] for op in space.get("openings") or []: op["x"] = dx + float(op.get("x", 0)) * kx op["y"] = dy + float(op.get("y", 0)) * ky # a length is measured along the wall, and the render scale is uniform if op.get("length") is not None: op["length"] = float(op["length"]) * kx for shape in space.get("decor") or []: for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)): if shape.get(a) is not None: shape[a] = dx + float(shape[a]) * fx if shape.get(b) is not None: shape[b] = dy + float(shape[b]) * fy if shape.get("w") is not None: shape["w"] = float(shape["w"]) * kx if shape.get("h") is not None: shape["h"] = float(shape["h"]) * ky # The viewport becomes the whole square rather than the transformed old # rectangle. It is what the grid is drawn over and what "fit to screen" # fits, so keeping the old box would leave the new margins outside the # canvas — no dots, nothing to draw on — which is exactly the room this # change was meant to give. space["view_box"] = [0.0, 0.0, 1.0, 1.0] # The grid pitch is a fraction of the WIDTH. A tall plan just got a wider # canvas, so a wall now covers fewer cells; without this every measurement # in the plan would silently shrink. if kx != 1: try: cell = float(space.get("cell_cm") or 5) except (TypeError, ValueError): cell = 5.0 space["cell_cm"] = round(cell / kx, 4) # The image keeps its own proportions and is centred; the space no longer # has any of its own. if space.get("plan_url") and not space.get("plan_aspect"): space["plan_aspect"] = round(aspect, 6) return True def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]: """{space_id: old aspect} for every space still carrying one. This is the migration INTENT. The two stores are written independently and either write can fail, so the intent has to survive on its own: it is saved into the layout store BEFORE anything changes (HP-1490-01), and cleared by the same write that stores the migrated layout. A crash between the writes leaves the intent behind, and the next start finishes the missing half — each half is idempotent because its trigger (`aspect` in the config, the saved intent for the layout) travels with that half's own write. """ out: dict[str, float] = {} for space in (config or {}).get("spaces") or []: if "aspect" not in space: continue try: out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0 except (TypeError, ValueError): out[str(space.get("id"))] = 1.0 return out def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool: """The config half: migrate every space still carrying an `aspect`. `layout` is accepted for backward compatibility and migrated with the factors found in the config — callers that can crash between store writes should use `pending_from_config()` + `migrate_layout()` instead, so the layout half does not depend on state the config half just deleted. """ factors = pending_from_config(config) if not factors: return False for space in config.get("spaces") or []: migrate_space(space) if layout: migrate_layout(layout, factors) return True def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool: """The layout half: marker and label positions of the spaces in `pending`.""" changed = False for pos in (layout or {}).values(): if not isinstance(pos, dict) or str(pos.get("s")) not in pending: continue dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))]) if pos.get("x") is not None: pos["x"] = dx + float(pos["x"]) * kx if pos.get("y") is not None: pos["y"] = dy + float(pos["y"]) * ky changed = True return changed