Files
houseplan-card/docs/BACKDROP.md
T
MatyshandCursor a7d956a072
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 9s
Validate / frontend (push) Successful in 2m58s
Validate / backend (push) Failing after 9m24s
Validate / smoke (push) Failing after 22m47s
v1.59.0-beta.4: wall thickness + white editor sheet with backdrop
Plan-editor wall thickness (docs/WALL-THICKNESS.md) and keep the white drawing sheet under the grid in editors even when a backdrop image is loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 12:27:09 +03:00

13 KiB
Raw Blame History

The backdrop picture — the spec (source of truth)

Owner-approved 2026-08-04. Companion to docs/CANVAS.md (the plane the picture lives on) and docs/RESIZE.md (the other transform frame).

Until v1.58.0 an uploaded plan image was nailed to the canvas: centred, at its own proportions, filling the square. If it did not line up with the rooms you had drawn — or with the rooms you were about to draw — the only recourse was to edit the file. And because it filled the canvas, it also decided where the opaque paper was, which quietly made a picture plan and a drawn plan two different kinds of thing.

This closes both. The picture is now one of the objects on the plane: it can be moved and scaled like anything else, it counts as content for the frame, and it no longer makes paper of its own.


§1 Model — three optional numbers

A space gains three optional fields, next to plan_url / plan_aspect:

Field Meaning Units Range
plan_x offset of the image's top-left corner from its default place normalised (as rooms, decor, openings) ±CANVAS_LIMIT (±5000)
plan_y the same, vertically normalised ±CANVAS_LIMIT
plan_scale one uniform multiplier for both sides, anchored at the (already offset) top-left corner multiplier 0.01 … 100

There is no rotation and no independent width/height: the picture keeps its own aspect ratio for ever, which is what makes "scale" a single number.

The absence of all three is exactly the pre-v1.58.0 behaviour. No migration runs, nothing is written into anybody's store, and a plan that has never been through the backdrop editor renders bit-identically. Junk (a string, NaN, a zero or negative scale) is treated as absence, not as an error — the same reading rule the rest of the geometry uses.

One function owns the arithmetic, planRect() in src/space-geometry.ts:

base   = fitInSquare(plan_aspect, 1000)      // the centred default
rect.x = base.x + plan_x * 1000
rect.y = base.y + plan_y * 1000
rect.w = base.w * plan_scale
rect.h = base.h * plan_scale

spaceModels() calls it, so every consumer — the full card, the static houseplan-space-card, the kiosk, the content frame — reads the same rectangle. The card's model cache key (_cfgFingerprint) carries the three fields, so a drag invalidates the memo.

validation.py mirrors the ranges (PLAN_SCALE_MIN / PLAN_SCALE_MAX alongside CANVAS_LIMIT). Detaching the image (space dialog → «Нарисую комнаты вручную») deletes all three: there is nothing left for them to describe, and a stale transform must not silently apply to the next upload.


§2 The transform frame

Where. The backdrop editor (mode: 'decor', «Редактор подложки»). The frame is on screen as soon as that editor opens (tools select and «Картинка-подложка»); the drawing tools own the drag, so the frame steps aside for them. Nowhere else: View, the Plan editor, the Device editor and the kiosk never touch the picture. A space with no image never grows a frame, and its toolbar is unchanged.

What. A dashed outline on the picture's rectangle plus four corner handles. Two radii, one gesture: the HIT circle is 2 % of the visible view — finger-sized at any zoom, the same rule the vacuum fit frame uses — and is invisible; the bead you actually see (.bdknob) is a quarter of it (owner, 2026-08-05: «уменьшить в 4 раза… они постоянно гигантские»). The clickable area is unchanged; only the ink shrank, so the frame stops covering the picture it frames. The same split now governs the text block (docs/LIVE-TEXT.md §3), the room-resize corners and the vacuum fit. The outline never takes a pointer; the handles do, with setPointerCapture and nwse-resize / nesw-resize cursors. Under the picture's own tool the stage cursor is grab, and grabbing while a gesture is live.

Move — press anywhere inside the picture and drag, under the «Картинка-подложка» tool. Why a tool and not just the select tool: the picture's body is most of the screen, and claiming it would take away the one-finger pan of the whole plane, which the owner asked for on 2026-08-04 («таскать план при любом масштабе») and smoke_pan_any_zoom guards. The corner handles are precise targets and need no such protection — they are live whenever the frame is. The gesture is also only started INSIDE the rectangle, so pressing beside the picture pans even under its own tool.

…and that tool is ARMED when the editor opens (owner, 2026-08-05: «не получается двигать картинку-подложку в режиме редактора подложки»). The frame is drawn on open, which promises a draggable picture, while the promise was only kept once the user found a tool they had no reason to look for. So _setMode('decor') selects backdrop instead of select whenever the space HAS a picture — the editor is named after it. Nothing else changes: select is one click away and still leaves the body to the pan, a space with no picture still opens on select, and Esc still returns to select. This is a discoverability fix, not a new gesture.

Nothing may rescale the stage mid-gesture. Two things wanted to, and both moved the picture away from the finger before they were stopped:

  • the picture is a content item, so dragging it GROWS the editor's content frame, which rescales the view — _frameOf() therefore returns the memoized frame untouched while _bdDrag is live, and catches up on release;
  • «Вернуть картинку» appears the moment the picture has moved, and the toolbar sits above the stage — so it is withheld until the gesture ends.

Scale — pull a corner. Uniform, about the opposite corner, which stays exactly where it was (the mechanic borrowed from the vacuum calibration panel). k = max(|dx| / base.w, |dy| / base.h), so the gesture follows whichever axis you pulled harder, and both sides then follow k.

Nothing else moves. Rooms, openings, devices, decor and room labels are untouched by both gestures — this is a picture-only transform, and the smoke asserts the room geometry is byte-identical afterwards.

Undo is the «Вернуть картинку» button in the backdrop toolbar: it appears only once the picture HAS been moved, and it deletes the three fields (back to centred, own size).


§3 THE PAPER IS THE ROOMS — and only the rooms

This is a behaviour CHANGE, deliberately.

The opaque "paper" exists so the scene background — bg_color, or the daynight sky (docs/SUN.md) — never bleeds through the plan itself. Until v1.58.0 it had two forms: the ROOM CONTOURS for a drawn plan, and the image rectangle for a picture plan.

The second form is gone. The paper is now paperRoomShapes(space.rooms) in every case (or paperRoomShapesWithWalls when a space carries wall thickness — docs/WALL-THICKNESS.md §4 — so the sheet grows under shared slabs and the scene never shows through a hatched body):

  • one opaque shape per room, in exactly the room's own geometry — an L-shaped house or a pair of detached buildings never grows a bounding rectangle;
  • an empty space has no paper at all, image or no image;
  • its colour is unchanged (styles.ts .hp-paper): white on a hand-drawn plan (.stage.noplan), the theme card background where an image is attached in View; every editor (plan / devices / decor) forces white under the grid even with a backdrop, so the drawing sheet stays the same sheet as a hand-drawn plan. daynight dims it via the .zoomwrap brightness filter only — its alpha stays 1.

Layer order, top to bottom, in both renderers:

devices / labels
sun wedges and rays
walls, openings, room fills, decor
THE PICTURE                     ← above the paper, below the geometry
the paper (room contours)
the scene background (bg_color / the daynight sky)

The consequence, and the owner knows it: a picture with transparency over a space with no rooms drawn shows the scene background through itself. That is the honest reading of "the paper belongs to the rooms" — draw the rooms and the paper appears under them.


§4 The picture is content

contentItems() has always counted the image rectangle as one content item alongside the rooms; it now counts the transformed one. So the frame, «Вписать всё», the zoom floor and the pan slack (docs/CANVAS.md §4–§5) all follow the picture wherever it is dragged, and a picture scaled down to a quarter tightens the frame around it instead of framing empty plane.

iconUnit() deliberately does not count it: an icon is a percentage of the drawn plan (docs/CANVAS.md §6), and scaling the backdrop must not resize every marker on it.


§5 Snap

Both gestures obey the canvas snap contract (docs/CANVAS.md §9):

Gesture What is snapped
move the resulting top-left corner — the anchor, not the delta, so one drag is enough to put a legacy off-grid picture onto the lattice
scale the dragged corner along the picture's longer side; the scale is then read back off it

The fixed corner of a scale keeps its (on-grid) place and the long side lands on a node. The short side follows from the aspect ratio and generally does not — that is what "uniform, no stretch" costs, and it is the right trade: a picture whose proportions drift is a broken picture.

Shift suspends the snap for the duration of the gesture, exactly as everywhere else (§9.4). The ±CANVAS_LIMIT clamp rides along regardless — _snap() and clampCanvasN are the only bound either gesture has.


§6 Live measurements

While the picture is dragged or scaled, one badge rides its centre with the picture's real size, width × height, through the space's cell_cm and the HA unit system (metres or feet). It is the same _fmtLen (segmentCm → formatLength) and the same .measurelabel element the wall ruler, the opening shoulders and the room resize use — the card states a length exactly one way.


§6b One tool at a time — a drawing tool owns the canvas

The backdrop editor has seven tools, and they are exclusive: only select talks to an existing shape, only erase deletes one, only «Картинка-подложка» grabs the picture's body, and the four drawing tools (line, rect, ellipse, text) do nothing but create.

The rule exists because of a concrete failure (owner, 2026-08-04): «нельзя поставить начало линии на конец другой — при клике на конец выделяется первая линия, а не начинает рисоваться новая». The shape's own pointerdown handler ran first, stopped the event and did nothing under a drawing tool, so the press never reached the stage and no draft was born — exactly on the one spot where a new line most wants to start, since §5's snap anchors include every line end.

So, under a drawing tool (and under the picture tool):

  • .dshape is pointer-events: none — an existing figure is not a target at all, and the cursor is the stage's;
  • _decorShapeDown returns before stopPropagation for any tool that is not select/erase, so a synthetic press cannot swallow the gesture either;
  • _decorPointerDown only looks for a shape under the pointer for select/erase, and otherwise starts the draft at the pressed point;
  • the transform frame's corner handles stay off (§2) — they were the first thing taught not to swallow the first point of a line.

Selecting, moving and the text dialog's double-click therefore live in the select tool only. demo/smoke_decor.mjs asserts both halves: a press on an existing line starts a new line from that end under the line tool, and still selects and grabs it under select.


§7 What is deliberately NOT done

  • No rotation. A skewed scan is a scan to re-take, not a plan to skew the whole editor's maths for.
  • No non-uniform scale. See above: the picture keeps its ratio.
  • No numeric fields in the space dialog. The frame is the interface; the numbers exist for the config, not for typing.
  • No per-room clipping of the picture. It is one rectangle.
  • No transform for the sun's paper/wedges. Those follow the rooms, and now so does the paper — one rule, no second geometry.

§8 Where it lives

Concern File
the rectangle (planRect, PLAN_SCALE_MIN/MAX), content items src/space-geometry.ts
the frame, the gestures, the live badge, the reset src/houseplan-card.ts (_bd*, _renderBackdropFrame)
the frame's look, the paper, the cursors src/styles.ts (.bdframe, .hp-paper)
the static card's copy of the paper + picture order src/space-render.ts
the stored ranges custom_components/houseplan/validation.py
unit coverage test/backdrop.test.mjs
DOM coverage demo/smoke_backdrop.mjs, demo/smoke_bg_color.mjs §11
screenshots demo/shot_backdrop.mjs