mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
Owner's batch (committed to dev earlier today, released here):
- devices count as content for the default zoom;
- the editor no longer shifts the plan — the stage measures its own top
instead of assuming 118px of header;
- zoom goes out to 0.4x, centred.
From the review:
- HP-1490-01: the square-canvas migration wrote two stores in sequence, and
the first write deleted the aspects the second needed — a crash between
them stranded the layout in the old coordinates with nothing able to
finish it. The intent {space: old aspect} is durable now: saved to the
layout store before anything moves, cleared by the same write that stores
the migrated layout, each half idempotent behind its own trigger. The
update event fires only after both halves are on disk. Proven at the exact
crash boundary by a harness test that fails the layout write once.
- HP-1490-02: check_quota and the file write were two executor jobs with
nothing between them, so N parallel uploads all measured the store before
any of them wrote. One job under a dedicated upload_lock now — narrower
than write_lock on purpose, a directory scan must not stall config saves.
A failed write reserves nothing.
- HP-1490-03: the content frame fed pan, zoom, clamp AND pointer maths, so
the editors were boxed into yesterday's drawing. Edit modes measure from
the full square; mode switches refit rather than carry a view clamped
against the wrong base.
- HP-1490-04: Save could outrun the proportions read and ship the previous
file's ratio. Picking a plan clears it immediately; Save awaits the
bounded read and stores 'unknown' over a lie.
- §5: package-lock version synced, duplicated comment removed.
New: smoke_audit_1490.mjs, migration crash-recovery pure + harness tests,
parallel-quota harness test. Inventory: 138 unit / 49 pure / 40 harness / 64
smokes.
162 lines
6.6 KiB
Python
162 lines
6.6 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 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
|