mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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>
271 lines
13 KiB
Markdown
271 lines
13 KiB
Markdown
# 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` |
|