Files
houseplan-card/docs/FURNITURE.md
T

264 lines
15 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 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`](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; }
```
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.