Files
houseplan-card/docs/BACKDROP.md
T
Codex 7c31725ac9 feat: warn about huge backdrops and offer a safe reduced copy (#39)
A picked raster is now classified from its HEADER BYTES ONLY before anything
heavy happens: src/backdrop-probe.ts parses PNG IHDR (+colour type/tRNS for
alpha), JPEG SOF and WebP VP8/VP8L/VP8X at fixed offsets, never using a file
field as an allocation size; hostile or truncated headers collapse to
'unknown', which warns without numbers instead of passing silently. The
thresholds live in that module as the single calibration point
(WARN_DECODED_BYTES 128 MiB ≈ 32 MP, HARD_DIMENSION 16384 — the browser
canvas cap, DOWNSCALE_TARGET_PX 4096), derived from the desktop-Chromium
matrix now committed as demo/benchmark_backdrop_decode.mjs with a
conservative tablet margin documented in the spec.

The shared pick flow (src/backdrop-pick.ts) feeds BOTH lazy runtimes — the
editor space dialog and the onboarding first-space dialog — so the guard
cannot drift between them, and nothing of it enters the eager View graph.
Warn shows the real numbers and three actions; the reduced copy decodes
EXIF-aware, keeps aspect and alpha (PNG stays PNG, opaque becomes JPEG
q0.9) and flows through the ordinary planFile → upload path. Hard has two
phases with one outcome: beyond 16384 px only Cancel; a failed or timed-out
(10 s) reduce closes with a toast, clean staging and NO silent fallback to
the original the user just declined. SVG never reaches the probe. The safe
path swaps the manual byte-loop base64 for FileReader — half the JS-heap
peak on every upload, byte-identical output (parity asserted in the smoke).

Proofs: header-table units incl. a fuzz set of hostile headers and ±1
threshold bounds; smoke_backdrop_guard on the real bundle — zero decode
calls before the choice, byte parity of keep-original, a real 6200 px
reduce to 4096 for both alpha and opaque branches, cancel-only hard
dialog, both phase-2 failures (reject and hang under the test-only timeout
override), re-pick after refusal, SVG bypass; four registry mutants
(probe-always-safe, alpha-dropped, hard-demoted, phase-2 silent fallback).
Spec anchor corrected alongside: the server plan limit is 8 MB
(MAX_PLAN_BYTES), attachments are the 50 MB path — an 8 MB JPEG is easily
80-160 MP decoded, so the client-side guard stays the primary defence.

Issue: #39
User-Visible: yes
2026-08-29 10:13:09 +03:00

94 lines
3.8 KiB
Markdown

# Plan image backdrop
Status: current in v1.60.0. The complete Background editor contract is in
`DECOR-EDITOR.md`.
## Placement model
The source image is first fitted proportionally into the square plan canvas by
`fitInSquare(plan_aspect, NORM_W)`. The optional transform is then applied:
| Field | Meaning | Default |
|---|---|---:|
| `plan_x`, `plan_y` | top-left offset from the fitted rectangle, normalised by `NORM_W` | `0` |
| `plan_scale_x`, `plan_scale_y` | independent width/height multipliers | `1` |
| `plan_angle` | rotation around the transformed rectangle centre | `0°` |
| `plan_scale` | legacy uniform fallback for both axes | `1` |
`planRect()` is the single reader used by the full card, static card and content
bounds. New writes remove `plan_scale`; **Оптимизировать планы** converts it
losslessly to both axis fields.
## Editor behaviour
The Background editor opens on **Select**, never on the image tool. The image
is interactive only under **Plan backdrop**:
- body drag moves it;
- four corner handles preserve ratio by default;
- `Shift` allows independent axes;
- the upper handle rotates in 5° steps, or freely with `Shift`;
- double click opens numeric width, height and angle;
- `Esc` restores the transform at pointer-down;
- release creates one named command in the shared 50-step history;
- Reset removes all six transform fields and is itself undoable.
In the Background editor the image is at opacity `0.5` under every other tool
and cannot receive pointer events from the controller. Under Plan backdrop it
is opaque. View and the other editors always show it at opacity `1`.
Move and resulting top-left coordinates are grid-bound. Width and height are
quantised when resized or entered numerically. There is no positional modifier
that bypasses the grid.
## Rendering and content
The image is purely decorative and never changes rooms, walls, openings,
devices, Glow or sun geometry. It is still a content item for fit/pan bounds.
For a rotated image all four rotated corners contribute to those bounds.
Layer order, top to bottom:
```text
devices and room labels
opening symbols / positive and zero-thickness walls / late room-hover outline
sun rays
live Glow pools
decor
room hover fill / Glow-base rooms and tunnels / data room fills and tunnels
plan image
room-shaped paper
scene background
```
The paper remains room-shaped; an image without rooms does not create opaque
paper behind itself.
## Large images (#39)
Before any decode the picked raster is classified from its header bytes only
(`src/backdrop-probe.ts`): PNG IHDR (+colour type/tRNS for alpha), JPEG SOF,
WebP VP8/VP8L/VP8X. Thresholds live in that module as the single calibration
point: a decoded size above `WARN_DECODED_BYTES` (128 MiB ≈ 32 MP) opens a
warning dialog with the real numbers and offers an aspect-preserving reduced
copy (longest side `DOWNSCALE_TARGET_PX` = 4096; PNG with alpha stays PNG,
opaque images become JPEG q0.9, EXIF orientation honoured); a side beyond
`HARD_DIMENSION` (16384, the browser canvas cap) offers only Cancel. A failed
or timed-out decode of the reduced copy shows a toast and leaves staging clean
— the original the user declined is never uploaded silently. Unreadable
headers behave like a warning without numbers. SVG is never rasterised and
skips the probe entirely. Calibration matrix and rationale:
`docs/specs/039-large-backdrops.md`, rerun via
`demo/benchmark_backdrop_decode.mjs`.
## Ownership
| Concern | File |
|---|---|
| fitted/transformed rectangle and rotated bounds | `src/space-geometry.ts` |
| gestures, frame, numeric dialog, history/reset | `src/houseplan-card.ts` |
| full-card SVG image | `src/houseplan-card.ts` |
| static-card SVG image | `src/space-render.ts` |
| schema ranges | `custom_components/houseplan/validation.py` |
| legacy conversion | `src/plan-optimizer.ts` |