mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
172 lines
7.0 KiB
Python
172 lines
7.0 KiB
Python
"""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 draft in space.get("room_drafts") or []:
|
|
draft["points"] = [_pt(p, dx, dy, kx, ky) for p in draft.get("points") or []]
|
|
|
|
for part in space.get("partitions") or []:
|
|
part["a"] = _pt(part.get("a"), dx, dy, kx, ky)
|
|
part["b"] = _pt(part.get("b"), dx, dy, kx, ky)
|
|
|
|
for column in space.get("wall_columns") or []:
|
|
column["center"] = _pt(column.get("center"), dx, dy, kx, ky)
|
|
|
|
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
|