mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
A space carried an aspect ratio, and coordinates were normalised against it: x by the width, y by the height. Every geometric question therefore depended on a per-space number, and picking a canvas orientation was a decision the user had no reason to make. The render space is now NORM_W x NORM_W and a plan image is fitted into it by its OWN ratio, centred — wide plans get margins above and below, tall ones at the sides. Migration (geometry_migration.py, pure and unit-tested) runs once at setup under the write lock. Nothing about a drawing changes: the old box is padded out to a square and every coordinate re-expressed against it — rooms as rects and polygons, openings and their lengths, decor, view_box, and the marker positions in the separate layout store. In render units it is a uniform scale plus an offset, so angles and proportions are exact. cell_cm is scaled for tall plans, because the grid pitch is a fraction of the width: without it a wall would measure less than it does. is now dropped by the schema rather than accepted — a stale tab sending it would be sending coordinates from the old normalisation too, and honouring the field would not make them right. The demo fixture was migrated with the same transform, so the smokes exercise the new geometry rather than a square-native fake; six of them needed their render-space helpers updated and one its click coordinates. Not released — dev only, per the owner's instruction.
134 lines
5.0 KiB
Python
134 lines
5.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 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
|
|
|
|
vb = space.get("view_box")
|
|
if isinstance(vb, list) and len(vb) == 4:
|
|
space["view_box"] = [
|
|
dx + float(vb[0]) * kx, dy + float(vb[1]) * ky,
|
|
float(vb[2]) * kx, float(vb[3]) * ky,
|
|
]
|
|
|
|
# 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
|