Files
houseplan-card/docs/FURNITURE.md
T

15 KiB
Raw Permalink Blame History

The furniture library — top-view symbols in the decor layer

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.

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 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:

/* every bathroom fixture in blue */
.decorlayer [data-kind="furniture"][data-symbol="toilet"],
.decorlayer [data-kind="furniture"][data-symbol="bathtub"] { stroke: #4fc3f7; }

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.

8. Backend

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 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.

9. What this is not

  • 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.