mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
Seen on the live instance: in the editors the dot grid covered only part of the canvas. The grid is drawn over the space's view_box, and I transformed that box along with everything else — so it still described the old plan area, and the margins the square canvas had just added were outside it. Nothing to draw on, which is precisely the room the change exists to give. The viewport is now reset to the full square. It is also what 'fit to screen' fits, so the whole canvas is reachable.
134 lines
5.2 KiB
Python
134 lines
5.2 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 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 migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
|
|
"""Migrate every space, and the marker positions that belong to them."""
|
|
spaces = config.get("spaces") or []
|
|
factors = {}
|
|
for space in spaces:
|
|
if "aspect" in space:
|
|
factors[str(space.get("id"))] = transform_for(space.get("aspect") or 1)
|
|
if not factors:
|
|
return False
|
|
|
|
for space in spaces:
|
|
migrate_space(space)
|
|
|
|
for pos in (layout or {}).values():
|
|
if not isinstance(pos, dict):
|
|
continue
|
|
f = factors.get(str(pos.get("s")))
|
|
if not f:
|
|
continue
|
|
dx, dy, kx, ky = f
|
|
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
|
|
return True
|