feat: expand furniture library

Issue: #159
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 18:52:03 +03:00
parent cc05df0b33
commit 7909ab6eed
120 changed files with 3318 additions and 1591 deletions
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- The Background furniture library now has 56 top-view symbols and a compact
two-level picker: choose a front-view category, then the exact plan variant.
Existing furniture keeps its saved geometry while 18 drawings receive the
new original artwork ([#159](https://github.com/Matysh/houseplan-card/issues/159)).
- If the optional language pack (currently German) cannot be downloaded, the
card now says so with a toast instead of silently switching to English; the
locale runtime the card ships is now the same code the i18n test suite
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- В библиотеке мебели редактора подложки теперь 56 видов сверху и компактный
двухуровневый выбор: сначала категория с видом спереди, затем точный вариант
для плана. Уже размещённая мебель сохраняет геометрию, а 18 рисунков получают
новый авторский дизайн ([#159](https://github.com/Matysh/houseplan-card/issues/159)).
- Если необязательный языковой пакет (сейчас — немецкий) не загрузился,
карточка говорит об этом тостом, а не молча переключается на английский;
рантайм локалей в продакшне теперь тот же код, что доказывает тестовый
+84 -254
View File
@@ -1,263 +1,93 @@
# The furniture library — top-view symbols in the decor layer
# Furniture library
Status: **implemented (v1.59.0-beta.3).** Code: `src/furniture.ts` (the
table, the geometry, the wall magnet, the resize — all pure and unit-tested),
`src/houseplan-card.ts` (the `furniture` tool, `_renderFurnPalette`,
`_furnPlace`, `_furnMoveUpdate`, the shared `_dt*` frame),
`custom_components/houseplan/validation.py` (`DECOR_SCHEMA`, furniture
branch). Tests: `test/furniture.test.mjs`,
`tests_backend/test_validation.py::test_decor_furniture`; smoke:
`demo/smoke_furniture.mjs`.
Status: **implemented and expanded by issue #159**. The Background editor
stores furniture as ordinary decor:
The shape: `{kind:'furniture', symbol, x, y, w, h, color, opacity, width_cm, angle?}` —
a **new** decor kind, so no existing plan carries one, nothing is migrated,
and every plan written before this validates and renders byte-for-byte as
before.
## 1. Why a plan needs furniture and not icons
A house plan with rooms, doors and devices still does not look like a plan of
a *home*. What tells a stranger which room is the bathroom is not the label
«Bathroom» — it is a bath, a toilet and a basin drawn where they stand. The
competing card solves this by making the user bring their own SVG; we already
draw the walls, so the pieces are ours to draw too.
**The symbols are ours, drawn in code.** This is the one design decision worth
arguing, so: every general-purpose icon set — Material Design Icons included,
and `@mdi/js` is already in this bundle — draws a **pictogram**, a sofa seen
from the front inside a 24 × 24 square. A plan is drawn **from above** and
**to scale**: a sofa is a 2.2 × 0.9 m rectangle with a back along one long
side. Stretching a 24 × 24 front view into that rectangle produces a drawing
that was never meant to be stretched; on the plan it reads as an icon lying on
the floor. It also fights the renderer: a non-uniform `scale()` distorts the
stroke, and the usual cure (`vector-effect: non-scaling-stroke`) freezes the
stroke in screen pixels, so a furniture outline would stop scaling with zoom
while every other decor shape kept scaling.
So `src/furniture.ts` holds ~30 symbols as primitives in a **unit box**, and
`furniturePathD(symbol, w, h)` generates one `d` string at the piece's real
size. The stroke is then an ordinary `stroke-width` in render units, exactly
like a decor rectangle. The whole library costs a few kilobytes of source and
needs no asset pipeline, no build step and no second set of preview images —
the palette draws its thumbnails with the same function the plan uses.
Two conventions every symbol obeys, and they are the whole contract:
- the box is `0..1 × 0..1`, `x` right, `y` **down** (SVG);
- **`y = 0` is the BACK** — the side that goes against a wall. A sofa's back,
a bed's headboard, a wardrobe's rear panel, a worktop's edge and a toilet's
cistern are all "the back", and that is what makes §5 mean something;
- nothing is filled. A plan is a drawing.
## 2. The licence, and what we did not use
The project is MIT and the symbols ship inside the bundle and through HACS, so
only a licence permitting commercial use and modification **without attribution
in the UI** is usable: public domain / CC0 / MIT / Apache-2.0 / BSD / ISC /
SIL OFL. What was checked, from the repositories and licence pages rather than
from hearsay:
| Set | Licence | Attribution in the UI? | Top view? |
|---|---|---|---|
| Material Design Icons (`@mdi/js`, Pictogrammers) | Apache-2.0 (`Pictogrammers Free License`: icons Apache-2.0, code MIT) | not required ("appreciated in your about screen") | **no** — 24×24 pictograms |
| Tabler / Lucide / Phosphor / Iconoir | MIT / ISC / MIT / MIT | no | **no** — pictograms |
| Font Awesome Free | CC BY 4.0 | **yes** | no |
| Game-icons.net | CC BY 3.0 | **yes** | no |
| Flaticon / Freepik / Vecteezy / Adobe Stock / Noun Project | proprietary or CC BY | yes / unusable | mixed |
| [`1337GameDev/FloorPlanSVGSymbols`](https://github.com/1337GameDev/FloorPlanSVGSymbols) | **MIT** | no | **yes** |
So a ready-made, correctly licensed **top-view** set does exist — exactly one,
a 70-file MIT repository by Richard Duerr. It was read and **not used**, and
the reason is not the licence:
- coverage is partial and shaped for a different job (electrical receptacles,
smoke detectors, five window types; no wardrobe, no desk, no single bed, and
a sofa that is assembled from `CouchLeft` + `CouchMiddle` + `CouchRight`);
- the files are raw Inkscape output — `sodipodi` namespaces, unused gradient
`defs`, absolute `translate(-1366,-1443)` transforms, hard-coded
`stroke:#000000` inline styles, arbitrary `mm` viewBoxes. Every one would
need stripping, re-origining, re-normalising and re-colouring to
`currentColor` before it could be used, which is the same work as drawing it
— without the freedom to pick the real-world proportions.
The set is recorded here because its licence is genuinely compatible: if we
ever want a symbol we do not have (an electrical panel, a fan), taking it from
there is legally free — MIT, one copyright line in `LICENSE`, no UI credit.
**Nothing from it is in the bundle today.** Our symbols are original, and the
licence question is therefore closed rather than managed.
`@mdi/js` stays where it is — the *toolbar button* for the library is
`mdi:sofa-outline`, an icon in a toolbar, which is what pictograms are for.
## 3. The palette
The decor bar gains a seventh tool, **Мебель / Furniture**, next to
line/rect/oval/text. Choosing it opens a panel between the bar and the plan —
not a modal: where a sofa goes is a question about the plan, and the plan must
stay visible while it is answered.
- symbols are **grouped**, and the groups are the point: `мебель` (12),
`техника` (8), `сантехника` (6), `прочее` (4 — stairs, fireplace, plant,
rug). No search box: thirty items in four labelled rows are faster to scan
than to type at.
- every tile draws the real symbol through `furniturePathD`, fitted into
40 × 40 **keeping its real proportions** — so a sofa reads as a sofa and a
toilet does not become a square.
- picking a symbol **arms** it and fills the two size fields with its default.
- **Width** and **Depth** are in metres or feet, by the HA unit system
(`hass.config.unit_system.length`), and are editable **before** the click.
The config stores centimetres-through-`cell_cm` either way: a unit system is
how a user reads a plan, never what the plan is (docs/STYLING-HOOKS.md §6).
### The symbols and their default real sizes
Widths are measured **along the back edge**, depths away from it. Where the
owner's list gave a pair without an axis, the pair is kept and the axes follow
from the back edge — a toilet is 0.4 m wide and 0.7 m deep, not the other way
round.
| Group | Symbol | W × D, m | | Symbol | W × D, m |
|---|---|---|---|---|---|
| мебель | Диван `sofa` | 2.2 × 0.9 | | Кресло `armchair` | 0.9 × 0.85 |
| | Журнальный столик `coffee_table` | 1.1 × 0.6 | | Обеденный стол `table_dining` | 1.4 × 0.8 |
| | Круглый стол `table_round` | 1.2 × 1.2 | | Стул `chair` | 0.45 × 0.45 |
| | Письменный стол `desk` | 1.2 × 0.6 | | Двуспальная кровать `bed_double` | 1.6 × 2.0 |
| | Односпальная кровать `bed_single` | 0.9 × 2.0 | | Тумбочка `nightstand` | 0.45 × 0.4 |
| | Шкаф `wardrobe` | 1.0 × 0.6 | | Стеллаж `bookshelf` | 0.8 × 0.3 |
| техника | Холодильник `fridge` | 0.6 × 0.65 | | Плита `stove` | 0.6 × 0.6 |
| | Посудомоечная машина `dishwasher` | 0.6 × 0.6 | | Стиральная машина `washer` | 0.6 × 0.6 |
| | Сушильная машина `dryer` | 0.6 × 0.6 | | Телевизор `tv` | 1.2 × 0.3 |
| | Кондиционер `ac` | 0.9 × 0.25 | | Бойлер `water_heater` | 0.45 × 0.45 |
| сантехника | Унитаз `toilet` | 0.4 × 0.7 | | Ванна `bathtub` | 1.7 × 0.75 |
| | Душ `shower` | 0.9 × 0.9 | | Раковина `sink` | 0.6 × 0.45 |
| | Кухонная мойка `kitchen_sink` | 0.8 × 0.6 | | Биде `bidet` | 0.4 × 0.55 |
| прочее | Лестница `stairs` | 1.0 × 2.8 | | Камин `fireplace` | 1.2 × 0.4 |
| | Растение `plant` | 0.4 × 0.4 | | Ковёр `rug` | 2.0 × 1.4 |
Sizes are **defaults, not limits**: the fields overrule them before the click
and the corner handles overrule them after.
## 4. Placing: the tool is a stamp
Press on the plan → the piece appears **centred on the press**, at its real
size, selected, and the editor **switches back to `select`**. One pick, one
piece: the owner asked for «сразу выделен», and a tool that keeps stamping
until you disarm it is a tool that stamps a sofa the next time you meant to
pan. The palette is disarmed with it, and leaving the tool disarms it too.
Real size is `cell_cm`, the one scale this card has:
`w_norm = (cm / cell_cm) × GRID_PITCH / 1000`. A 2.2 m sofa on a plan drawn at
5 cm per cell is 44 cells; on a plan drawn at 10 cm per cell it is 22, and it
covers the same 2.2 m of wall in both.
Under this tool existing shapes are **inert**, like under every drawing tool
(the rule from 2026-08-04): the press must reach the stage even when it lands
on a sofa that is already there. Without an armed symbol the press does
nothing at all and the plane still pans — a click must not stamp whatever was
chosen last week. `Esc` disarms the symbol first, then clears the selection,
then leaves the tool, then leaves the editor.
## 5. The wall magnet
While a piece is **placed** or **dragged**, the nearest wall within six grid
cells (≈ 30 cm on a default plan) claims it: the piece's **back edge lands on
the wall** and the piece is **turned to the wall's direction**, with its body
on the side the finger is holding it (so a sofa dragged along the inside of a
wall never flips through it). The offset **along** the wall is still quantised
to the grid, so a row of kitchen units lines up.
Walls are the **derived room edges** (`roomEdges`) — the same walls an opening
snaps to. There is no wall entity to be bound to and no room id is stored: a
piece keeps absolute coordinates and survives every later edit of the rooms.
The magnet and grid quantisation are mandatory (docs/CANVAS.md §9.4). Out
of the magnet's reach the drag is the ordinary grid snap on the shape's own
anchor, and the angle the piece already had is kept: dragging a turned
wardrobe into the middle of a room must not straighten it.
## 6. Selecting: one frame, two shapes
A selected piece wears **the text block's frame** — dashed box, four corner
handles, one rotate handle on a stem, with the hit radius at 1.8 % of the
visible view and the visible bead at a quarter of that (docs/LIVE-TEXT.md §3,
the owner's «уменьшить в 4 раза»). Reusing it is deliberate: it is the same
question ("how big, which way round?") asked about two different things, and a
third set of chrome would only be a third set of bugs. What differs is
confined to two places:
- **the pivot.** A label has an anchor and must never walk away from it; a
piece of furniture has a box, and turning a sofa about its top-left corner
is not what anyone means by turning a sofa. So: the anchor for text, the
**centre** for furniture (`_dtPivot`).
- **what a corner means.** For a label it is one physical font size. For
furniture it is width and depth about the opposite corner. The common
controller preserves the current ratio by default; hold `Shift` for
independent axes. Each extent and the stored top-left coordinate remains
grid-bound.
**Rotation** is the handle above the box, in **5° steps**, Shift past the step
— the same step a device icon and a text block turn in. Turning back to zero
removes the field, so a straight piece stores no angle at all.
Double click opens the common properties dialog. It exposes the symbol itself,
physical width/depth, angle, contour colour/opacity and physical line width;
changing the symbol keeps the current box and transform.
**Live measurements.** While a corner is dragged, two badges show the piece's
real width and depth in the HA unit system, centred on the edges they measure.
They are `.measurelabel`, fed by the same `_fmtLen` (`segmentCm` over
`cell_cm`) the wall ruler, the room resize and the backdrop badge use. There is
one way this card ever states a length.
The frame needs **no measuring pass**: a piece's box is its config, so the
frame appears in the same render as the selection, not one after it (a text
label still needs `getBBox`, because only the browser knows how wide a word
came out).
## 7. Styling hooks
Per docs/STYLING-HOOKS.md §3, every piece carries
`data-hp="decor"`, `data-id="<its id>"`, `data-kind="furniture"` and
`data-symbol="<sofa|toilet|…>"`. So card-mod can colour all the plumbing, or
one particular bed, without us shipping a CSS field:
```css
/* every bathroom fixture in blue */
.decorlayer [data-kind="furniture"][data-symbol="toilet"],
.decorlayer [data-kind="furniture"][data-symbol="bathtub"] { stroke: #4fc3f7; }
```text
{ kind: "furniture", symbol, x, y, w, h, color, opacity, width_cm, angle? }
```
The colour, opacity and physical line width are the decor style's, chosen in
the bar like any other shape's, and are stored per piece.
The schema and saved coordinates did not change. Existing furniture therefore
keeps its position, size, rotation and styling; only 18 built-in drawings gain
the new artwork after an update.
## 8. Backend
## Library and source
`DECOR_SCHEMA` gains a fourth branch: `symbol` required, `^[a-z0-9_]+$`,
≤ 32 characters; `x`/`y` the usual ±`CANVAS_LIMIT`; `w`/`h` strictly positive
and capped by the same limit (a size, not a coordinate); `angle` optional,
−360…360. `color`/`opacity`/`width_cm` come from `_DECOR_COMMON`; legacy
render-unit `width` remains accepted until the object is edited or optimised.
The public library contains **56** top-view symbols:
The symbol is **not** validated against the card's list. The backend must
accept a plan written by a NEWER card, and a card that has learnt a new symbol
must not wait for the integration to be updated before the user can save. An
id this build has never heard of simply renders as nothing — `furnitureSymbol`
returns `null`, `furniturePathD` returns `''`, and the shape is skipped.
- 44 designer symbols from `assets/furniture/houseplan-0.3.0/svg/plan`;
- 12 retained built-ins: refrigerator, dishwasher, washer, dryer, air
conditioner, water heater, shower, sink, stairs, fireplace, plant and rug.
## 9. What this is not
The designer pack also contains 33 front-view category illustrations in
`svg/menu`. Four of them (`computer`, `oven`, `hood`, `exercise`) currently
have no top-view symbol and are intentionally hidden instead of opening an
empty category.
- **Not a device.** A piece of furniture has no entity, no tap action, no
state and no part in room aggregation. A sofa that switches the light is a
device marker standing on a sofa.
- **Not a 3D or an interior planner.** No layers, no z-order beyond
configuration order, no collision detection, no "does the door open into the
bed" check. A plan is a drawing, not a CAD model.
- **Not a user-extensible library.** No symbol upload, no custom paths in the
config: an arbitrary path from a config is an SVG injection surface, and the
card already refuses to be a place where users paste markup.
- **Not a wall-hugging constraint.** The magnet is a *placement aid*, not a
binding: once placed, a piece keeps absolute coordinates and does not follow
a wall that is later moved. Binding furniture to walls is what would make a
room resize (docs/RESIZE.md) start dragging sofas around, and that is a
bigger promise than a decor layer should make.
- **Not a stamp that keeps stamping.** See §4.
`pack.json` is the source of truth for stable ids, categories, names,
default centimetre dimensions and SVG paths. Run:
```powershell
npm run furniture:generate
npm run furniture:check
```
The generator validates a deliberately small, inert SVG subset and produces:
- `src/furniture-plan-art.generated.ts` for the initial View graph;
- `src/furniture-menu-art.generated.ts` for the lazy editor graph only.
Generated files are never edited manually. The 77 drawings were created by
Sergey Matyushin (`Matysh`) and granted to the project under its MIT License in
[issue #159](https://github.com/Matysh/houseplan-card/issues/159#issuecomment-5454085168).
No separate attribution is required in the interface.
## Palette interaction
Furniture is a two-level non-modal palette in the editor context tray:
1. The first level shows front-view **categories**, grouped as Furniture,
Appliances, Plumbing and Other.
2. Every category opens a second level, including categories that have only
one variant.
3. The second level shows the real top-view drawings. Back returns to the
category list and clears any armed symbol.
4. Picking a variant arms one placement and reveals editable Width and Depth
in the Home Assistant length unit.
5. Clicking the plan places the object and returns to Select. Shift keeps the
established free-placement behaviour.
The properties dialog remains a flat native select, grouped by category. Its
optgroup label includes both the parent group and category because HTML selects
cannot nest optgroups.
## Rendering contract
Each object is still one `<path>` and one erase hit path. Designer artwork
keeps its native SVG `viewBox`; the renderer applies the user's stored width
and depth with a non-uniform transform and uses
`vector-effect="non-scaling-stroke"`. Consequently:
- resizing changes the physical object box without distorting line weight;
- the user's decor colour, opacity and physical line width remain authoritative;
- `data-hp="decor"`, `data-kind="furniture"`, `data-id` and `data-symbol`
remain stable for card-mod;
- an id unknown to an older card remains valid data and simply renders
nothing instead of breaking the plan.
The top edge of every drawing is BACK (`y = 0`). Placement and dragging use
the established wall magnet, absolute saved coordinates and the shared
selection/resize/rotate frame. Furniture is decor: it has no entity, state,
room aggregation, collision model or automatic binding to later wall edits.
## Compatibility and performance
No backend schema, configuration version or migration was added. The 18
replacement ids preserve their identity; the remaining old ids remain
available. Default dimensions change only for a newly picked replacement—an
already saved object's `w` and `h` are never rewritten.
Plan artwork is available in View and kiosk. Front-view menu art is imported
only after the editor runtime is requested. Touch View/kiosk support is
blocking; editor ergonomics on touch remain best effort under
`docs/TOUCH-SUPPORT.md`.
+1
View File
@@ -30,6 +30,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| HACS | **In the default catalog since 2026-08-25** (hacs/default#9004 merged). Install = plain HACS search. Post-merge checklist: run the manual zip workflow on the next stable tag after merging to main; forum/4pda announcement |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru/de (src/i18n/*.json), everything user-visible localized incl. kiosk popover; German is loaded lazily through the registry introduced by #62 |
| Furniture | #159 replaces the flat ~30-item picker with a two-level category/variant palette and 56 top-view symbols. The reviewed 77-SVG MIT source pack is vendored under `assets/furniture/houseplan-0.3.0`; plan art stays in the initial View graph, front-view category art stays in the lazy editor graph, and existing saved geometry is unchanged. |
| Tests | Four layers: Node unit (`npm test`: frontend pure modules + tooling policy), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
| Input support | Owner's rule since 2026-08-08: View and kiosk are fully supported and release-blocking on touch. All three editors are desktop-first; touch editing is best effort and may be awkward, reduced or absent when parity is expensive. `docs/TOUCH-SUPPORT.md` defines the non-negotiable safety floor and documentation/test rules |
| Vacuums | Live puck, server-side trails and fit calibration are shipped. The local v1.61 Stage 1 contract in docs/VACUUM.md adds explicit Dreame/XCME/Valetudo coverage, registry-less source selection, capability diagnostics, path-gap preservation and source-health warnings; #205's issue branch resumes one ended same-map run through an inclusive 30-minute station/pause grace. Roomba remains Stage 2 |
+6 -1
View File
@@ -688,13 +688,18 @@ The authoritative technical interaction contract is
| Rectangle | Drag diagonal; Shift makes a square | Stroke plus independent fill, size and angle |
| Oval | Drag bounds; Shift makes a circle | Stroke plus independent fill, radii and angle |
| Text | Click to open dialog | Multiline text, HA tokens, colour, physical size and angle |
| Furniture | Pick symbol, then click | Symbol, size, colour, outline and wall magnet |
| Furniture | Pick a front-view category, pick a top-view variant, then click | Symbol, size, colour, outline and wall magnet |
| Erase | Click an item | Confirmed deletion, undoable |
Creation and transform snap to the grid plus nearby room/background anchors.
The plan image is interactive only with Backdrop selected. Undo/Redo shares the
50-command editor history.
The Furniture palette always uses two levels: categories first, then the
available plan variants. **All categories** returns to the first level and
disarms the current symbol. Existing placed furniture keeps its saved size and
position when the built-in artwork is updated.
![Selected line in the Background editor](images/07-background-editor.png)
Live text accepts `{sensor.entity}` and
+6 -1
View File
@@ -1237,7 +1237,7 @@ Power=`off`/`unavailable` подавляет даже устаревший ак
| Прямоугольник | Drag по диагонали; `Shift` фиксирует квадрат | Контур и независимая заливка с прозрачностью; показываются ширина × высота и площадь |
| Овал | Drag по габаритам; `Shift` фиксирует круг | Контур и независимая заливка с прозрачностью; показывается `R` либо `Rx × Ry` |
| Текст | Клик открывает диалог | Многострочный текст, переменные HA, цвет/прозрачность, физический размер и угол; масштаб и поворот ручками |
| Мебель | Выбор символа и клик по центру | Символ, размер, цвет, толщина, поворот; магнитится к стене |
| Мебель | Выбор категории спереди, затем варианта сверху и клик по центру | Символ, размер, цвет, толщина, поворот; магнитится к стене |
| Стереть | Клик по объекту | После подтверждения удаляет объект; действие можно отменить через Undo |
Новые фигуры создаются не меньше половины клетки. После создания объект остаётся выбранным. Создание, движение и изменение размера всегда квантуются по сетке. При рисовании и перемещении действует лёгкий магнит к углам, серединам, центрам и рёбрам других объектов декора и комнат; устройства, проёмы и картинка-подложка не являются целями магнита.
@@ -1255,6 +1255,11 @@ Power=`off`/`unavailable` подавляет даже устаревший ак
Редактор подложки использует тот же именованный Undo/Redo на 50 команд, что и геометрия плана. `Esc` отменяет только незавершённое рисование или текущий drag/resize/поворот; после отпускания указателя используйте `Ctrl+Z`/`Cmd+Z`. Повтор — `Ctrl+Shift+Z` либо `Ctrl+Y`.
Палитра мебели всегда двухуровневая: сначала категории, затем доступные виды
сверху. Кнопка **Все категории** возвращает на первый уровень и снимает выбор
символа. Уже размещённая мебель сохраняет размеры и положение при обновлении
встроенных рисунков.
### Двойной клик в «Выбрать»
| Объект | Что открывается |