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

271 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |