Files
houseplan-card/src/furniture.ts
T
Claude 20457660fa fix(furniture): correct exercise and shelf artwork (#606)
Publish corrected 0.4.1 pack, preserve old cactus objects as a read-only alias, and cover the catalog, renderers, editor, PDF and visual scene.

Issue: #606
User-Visible: yes
2026-09-21 15:01:18 +03:00

434 lines
18 KiB
TypeScript

/**
* The furniture library of the decor layer (docs/FURNITURE.md).
*
* Pure geometry and pure data: no Lit, no DOM, no `hass`. Everything here is
* unit-testable, and the card only turns the output into one `<path>`.
*
* WHY OUR OWN SYMBOLS AND NOT AN ICON SET. Every general-purpose icon pack —
* mdi included, and it is already in this bundle — draws a PICTOGRAM: a sofa
* seen from the front, inside a 24x24 square. A plan is drawn from ABOVE and
* to SCALE: a sofa is a 2.2 x 0.9 m rectangle with a back along one long
* side. Putting a 24x24 pictogram into that rectangle stretches a drawing
* that was never meant to be stretched, and the result reads as an icon lying
* on the floor, not as a piece of furniture. The library is therefore
* generated from House Plan's own top-view SVG pack — all of it, since #593:
* the 12 compatibility symbols that used to be drawn from unit-box primitives
* got designer artwork under their existing ids. Every symbol resolves to one
* path plus its native coordinate box; the renderer scales that box to the
* stored real-world size. The renderer keeps the configured decor stroke
* stable through that local non-uniform transform, while
* `furniturePlanScreenScale` restores the outer physical plan zoom.
*
* THE CONVENTION EVERY SYMBOL OBEYS:
* - x points right and y points DOWN (SVG); art keeps its native viewBox;
* - `y = 0` is the BACK of the object — the side that goes against a wall.
* That is what makes the wall magnet meaningful: a sofa's back, a bed's
* headboard, a wardrobe's rear panel and a worktop's edge all mean the
* same thing to the furniture wall-placement helper;
* - nothing is filled. The card strokes the whole symbol in the decor
* colour, so a plan stays a drawing (owner: «символы рисуются линейно»).
*/
import { NORM_W, GRID_PITCH, CANVAS_LIMIT } from './space-geometry';
import { GENERATED_FURNITURE_CATALOG } from './furniture-plan-catalog.generated';
import { FURNITURE_ART_RUNTIME, type FurnitureArtHost } from './furniture-art-runtime';
import { canonicalFurnitureId } from './furniture-id';
/** Groups the palette shows, in the order it shows them. */
export const FURNITURE_GROUPS = ['furniture', 'appliance', 'sanitary', 'other'] as const;
export type FurnitureGroup = (typeof FURNITURE_GROUPS)[number];
export interface FurnitureSymbol {
/** Stable id — it is what the config stores and what `data-symbol` carries. */
id: string;
group: FurnitureGroup;
/** Category tile in the two-level editor palette. */
category: string;
/** Default REAL size in centimetres: width ALONG the back edge x depth. */
w: number;
h: number;
}
export interface FurnitureGraphic {
d: string;
viewW: number;
viewH: number;
}
/**
* The public library: every symbol of the designer pack, nothing else.
*
* Until #593 this was a union of the generated catalogue and 12 symbols drawn
* from unit-box primitives, kept because the pack had no top view for them.
* Pack 0.4.0 draws all 60, so the union is gone — and with it the trap it
* carried: both halves used the SAME public ids, and `BY_ID` below keeps the
* LAST entry, so a primitive silently won over the designer drawing it was
* supposed to be replaced by.
*
* Sizes are the ones a European flat actually has; they are DEFAULTS, not
* limits — the palette lets the user type over both numbers before placing,
* and the corner handles change them afterwards.
*/
export const FURNITURE: FurnitureSymbol[] = GENERATED_FURNITURE_CATALOG.map((symbol) => ({
id: symbol.id,
group: symbol.group as FurnitureGroup,
category: symbol.category,
w: symbol.w,
h: symbol.h,
}));
const BY_ID = new Map(FURNITURE.map((s) => [s.id, s]));
/** The symbol with this id, or null. An unknown id is DATA, not a crash: a
* plan written by a newer card must still validate, save and simply render
* nothing in an older one. */
export function furnitureSymbol(id: string | null | undefined): FurnitureSymbol | null {
return (id && BY_ID.get(canonicalFurnitureId(id))) || null;
}
/** Is this a known symbol, and therefore one whose drawing arrives with the
* lazy artwork chunk (#474)? Since #593 the two questions have one answer:
* every symbol is designer artwork, and an unknown id has no drawing at all. */
export function furnitureArtIsLazy(id: string): boolean {
return BY_ID.has(canonicalFurnitureId(id));
}
/** The symbols of one group, in table order. */
export function furnitureOfGroup(group: FurnitureGroup): FurnitureSymbol[] {
return FURNITURE.filter((s) => s.group === group);
}
/** Default real size (cm) of a symbol; an unknown id falls back to 60 x 60 —
* a box the user can immediately resize rather than an error. */
export function furnitureDefaultCm(id: string): { w: number; h: number } {
const s = furnitureSymbol(id);
return s ? { w: s.w, h: s.h } : { w: 60, h: 60 };
}
// ------------------------- centimetres <-> the canvas -----------------------
/**
* Real centimetres -> a NORMALISED size on the canvas, through the space's
* `cell_cm` — the one scale this card has (`_cmToUnits` and `segmentCm` are
* the same conversion in render units). Kept pure and parameterised so a test
* can state the arithmetic without a card.
*/
export function cmToNorm(cm: number, cellCm: number,
gridPitch: number = GRID_PITCH, normW: number = NORM_W): number {
const c = Number(cellCm) > 0 ? Number(cellCm) : 5;
return ((Number(cm) || 0) / c) * gridPitch / normW;
}
/** ...and back, for the size fields and the live badges. */
export function normToCm(v: number, cellCm: number,
gridPitch: number = GRID_PITCH, normW: number = NORM_W): number {
const c = Number(cellCm) > 0 ? Number(cellCm) : 5;
return (((Number(v) || 0) * normW) / gridPitch) * c;
}
/** A piece may not be smaller than a stroke or larger than the canvas guard.
* Both are garbage insurance, not opinions about furniture. */
export const FURN_MIN_N = 0.0005; // half of a 1000-unit pixel
export const FURN_MAX_N = CANVAS_LIMIT;
export function clampFurnSize(n: number): number {
if (!Number.isFinite(n)) return FURN_MIN_N;
return Math.max(FURN_MIN_N, Math.min(FURN_MAX_N, n));
}
/** Real-size bounds for the palette's two fields: 1 cm ... 100 m. */
export const FURN_MIN_CM = 1;
export const FURN_MAX_CM = 10000;
export function clampFurnCm(cm: number): number {
if (!Number.isFinite(cm)) return FURN_MIN_CM;
return Math.max(FURN_MIN_CM, Math.min(FURN_MAX_CM, cm));
}
// ------------------------------ the drawing --------------------------------
/** Native one-path artwork and its coordinate box. The renderer scales this
* box non-uniformly to the user's stored dimensions; the furniture stroke
* helpers below separate that local scale from the outer plan camera. */
export function furnitureGraphic(id: string, host?: FurnitureArtHost): FurnitureGraphic | null {
if (!furnitureSymbol(id)) return null;
// Artwork is lazy (#474): `undefined` while the chunk is pending or settled
// into fallback renders the piece as an unknown symbol — nothing — and a
// host passed here is re-rendered once the runtime settles. Since #593 that
// is true for all 60 symbols, including the 12 that used to draw eagerly.
return FURNITURE_ART_RUNTIME.art(canonicalFurnitureId(id), host) ?? null;
}
/** Compatibility helper for callers that only need a path string. Designer
* artwork remains in its native viewBox; use `furnitureGraphic` when sizing it. */
export function furniturePathD(id: string, w: number, h: number): string {
if (!furnitureSymbol(id) || !(w > 0) || !(h > 0)) return '';
return FURNITURE_ART_RUNTIME.art(canonicalFurnitureId(id))?.d || '';
}
/**
* Uniform plan-user-unit -> CSS-pixel scale of the SVG viewport.
*
* Furniture artwork uses a local non-uniform transform for independent width
* and depth. `vector-effect=non-scaling-stroke` correctly rejects that local
* transform, but it also rejects the outer SVG viewBox camera. Supplying this
* one outer scale as the path's stroke width restores the physical zoom while
* keeping horizontal, vertical and diagonal artwork strokes equally thick.
*
* Invalid/zero layout is transient during first mount. Returning 1 is the
* finite old-behaviour fallback; ResizeObserver requests the measured render
* before the boot veil reveals the plan.
*/
export function furniturePlanScreenScale(
viewportWidth: unknown,
viewportHeight: unknown,
viewBoxWidth: unknown,
viewBoxHeight: unknown,
): number {
const viewportW = Number(viewportWidth);
const viewportH = Number(viewportHeight);
const viewW = Number(viewBoxWidth);
const viewH = Number(viewBoxHeight);
if (![viewportW, viewportH, viewW, viewH].every((value) => Number.isFinite(value) && value > 0))
return 1;
const scale = Math.min(viewportW / viewW, viewportH / viewH);
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
/** Physical furniture stroke expressed in CSS pixels for a non-scaling path. */
export function furnitureStrokePx(
strokeUnits: unknown,
planScreenScale: unknown,
fallbackStrokeUnits = 1,
): number {
const rawStroke = Number(strokeUnits);
const fallback = Number(fallbackStrokeUnits);
const stroke = Number.isFinite(rawStroke) && rawStroke > 0
? rawStroke
: Number.isFinite(fallback) && fallback > 0 ? fallback : 1;
const rawScale = Number(planScreenScale);
const scale = Number.isFinite(rawScale) && rawScale > 0 ? rawScale : 1;
const result = stroke * scale;
return Number.isFinite(result) && result > 0 ? result : stroke;
}
/**
* The four corners of a rotated box, world units, in the order the frame
* draws them: NW, NE, SE, SW. Pure, so the frame, the gestures and the tests
* all agree on where a corner is.
*/
export function furnitureCorners(
x: number, y: number, w: number, h: number, angle: number,
): number[][] {
const cx = x + w / 2, cy = y + h / 2;
const a = ((Number(angle) || 0) * Math.PI) / 180;
const cs = Math.cos(a), sn = Math.sin(a);
const rot = (px: number, py: number): number[] => {
const ux = px - cx, uy = py - cy;
return [cx + ux * cs - uy * sn, cy + ux * sn + uy * cs];
};
return [rot(x, y), rot(x + w, y), rot(x + w, y + h), rot(x, y + h)];
}
/**
* Resize by a dragged corner about the OPPOSITE one — the backdrop frame's
* rule, but with WIDTH AND DEPTH INDEPENDENT.
*
* Why not uniform: a picture has one true aspect ratio and stretching it is a
* lie, which is why the backdrop scales uniformly. Furniture is the opposite
* case — the ratio is a fact about THIS sofa, and the next one is 1.8 m long
* with the same 0.9 m depth. A uniform handle would force the user to make a
* bed deeper in order to make it wider, i.e. to state something false about
* the room. So both axes move, and the two live badges say what they became.
*
* `sgx/sgy` are the dragged corner as signs (+1 = the high side of the axis).
* `px/py` is the pointer. `step > 0` quantises each dimension to the grid —
* on an unrotated piece whose fixed corner sits on a node that also puts the
* dragged corner on a node, which is what "snap to the grid" has to mean
* here. The UI always passes a positive grid step; zero remains useful only
* for pure geometry callers that intentionally request no quantisation.
*/
export function furnitureResize(
orig: { x: number; y: number; w: number; h: number; angle?: number },
sgx: number, sgy: number, px: number, py: number, step = 0, minSize = 1e-6,
): { x: number; y: number; w: number; h: number } {
const a = ((Number(orig.angle) || 0) * Math.PI) / 180;
const ux = Math.cos(a), uy = Math.sin(a); // local +x in world
const vx = -Math.sin(a), vy = Math.cos(a); // local +y in world
const cx = orig.x + orig.w / 2, cy = orig.y + orig.h / 2;
// the fixed corner = the one opposite the dragged one
const fLocalX = sgx > 0 ? -orig.w / 2 : orig.w / 2;
const fLocalY = sgy > 0 ? -orig.h / 2 : orig.h / 2;
const fx = cx + fLocalX * ux + fLocalY * vx;
const fy = cy + fLocalX * uy + fLocalY * vy;
// the pointer, expressed along the piece's own axes from that fixed corner
const rx = px - fx, ry = py - fy;
let w = (rx * ux + ry * uy) * (sgx > 0 ? 1 : -1);
let h = (rx * vx + ry * vy) * (sgy > 0 ? 1 : -1);
if (step > 0) {
w = Math.round(w / step) * step;
h = Math.round(h / step) * step;
}
w = Math.max(minSize, w);
h = Math.max(minSize, h);
// rebuild the centre from the fixed corner, then the UNROTATED top-left
const ncx = fx + (sgx > 0 ? w / 2 : -w / 2) * ux + (sgy > 0 ? h / 2 : -h / 2) * vx;
const ncy = fy + (sgx > 0 ? w / 2 : -w / 2) * uy + (sgy > 0 ? h / 2 : -h / 2) * vy;
return { x: ncx - w / 2, y: ncy - h / 2, w, h };
}
export interface FurnitureTransformBox {
x: number;
y: number;
w: number;
h: number;
angle?: number;
flip_h?: boolean;
flip_v?: boolean;
}
export interface FurnitureResizeResult extends FurnitureTransformBox {
flip_h?: boolean;
flip_v?: boolean;
}
/**
* #383 — continuous furniture-only resize.
*
* `sgx`/`sgy` name the dragged local edge (-1, 0 or +1). Zero keeps that
* axis byte-for-byte, which is how the four new middle handles work. Unlike
* the legacy shared decor helper this function never snaps to the grid and a
* signed pointer distance may cross the fixed opposite edge. The sign is
* projected into flip flags; the returned extents remain strictly positive.
*/
export function resizeFurnitureTransform(
orig: FurnitureTransformBox,
sgx: number,
sgy: number,
px: number,
py: number,
keepAspect: boolean,
minSize: number,
): FurnitureResizeResult {
const minimum = Number.isFinite(minSize) && minSize > 0 ? minSize : 1e-6;
const a = ((Number(orig.angle) || 0) * Math.PI) / 180;
const ux = Math.cos(a), uy = Math.sin(a);
const vx = -Math.sin(a), vy = Math.cos(a);
const cx = orig.x + orig.w / 2, cy = orig.y + orig.h / 2;
const flx = sgx > 0 ? -orig.w / 2 : sgx < 0 ? orig.w / 2 : 0;
const fly = sgy > 0 ? -orig.h / 2 : sgy < 0 ? orig.h / 2 : 0;
const fx = cx + flx * ux + fly * vx;
const fy = cy + flx * uy + fly * vy;
const rx = px - fx, ry = py - fy;
const rawW = sgx
? (rx * ux + ry * uy) * (sgx > 0 ? 1 : -1)
: orig.w;
const rawH = sgy
? (rx * vx + ry * vy) * (sgy > 0 ? 1 : -1)
: orig.h;
let w = sgx ? Math.abs(rawW) : orig.w;
let h = sgy ? Math.abs(rawH) : orig.h;
if (keepAspect && sgx && sgy) {
const kx = w / Math.max(orig.w, minimum);
const ky = h / Math.max(orig.h, minimum);
// Follow whichever local pointer axis has moved farther from the original
// scale. Equal deltas deliberately prefer width for deterministic ties.
const scale = Math.abs(kx - 1) >= Math.abs(ky - 1) ? kx : ky;
const floor = Math.max(minimum / orig.w, minimum / orig.h);
const safeScale = Math.max(floor, Number.isFinite(scale) ? scale : floor);
w = orig.w * safeScale;
h = orig.h * safeScale;
}
w = Math.max(minimum, Number.isFinite(w) ? w : minimum);
h = Math.max(minimum, Number.isFinite(h) ? h : minimum);
const crossX = !!sgx && rawW < 0;
const crossY = !!sgy && rawH < 0;
const dirX = sgx ? (sgx > 0 ? 1 : -1) * (crossX ? -1 : 1) : 0;
const dirY = sgy ? (sgy > 0 ? 1 : -1) * (crossY ? -1 : 1) : 0;
const ncx = fx + dirX * (w / 2) * ux + dirY * (h / 2) * vx;
const ncy = fy + dirX * (w / 2) * uy + dirY * (h / 2) * vy;
const flipH = !!orig.flip_h !== crossX;
const flipV = !!orig.flip_v !== crossY;
return {
x: ncx - w / 2,
y: ncy - h / 2,
w,
h,
...(Number(orig.angle) ? { angle: Number(orig.angle) } : {}),
...(flipH ? { flip_h: true } : {}),
...(flipV ? { flip_v: true } : {}),
};
}
/** Furniture is free by default; Shift snaps to world-space 45° bearings. */
export function furnitureRotationAngle(
angle0: number,
pointerStartDeg: number,
pointerDeg: number,
snap45: boolean,
): number {
let angle = Number(angle0) + (Number(pointerDeg) - Number(pointerStartDeg));
if (![angle, pointerStartDeg, pointerDeg].every(Number.isFinite)) angle = 0;
if (snap45) {
const steps = angle / 45;
// Keep the exact half-step deterministic on both sides of zero. Native
// Math.round(-0.5) returns -0, which would make symmetric drags snap in
// different directions.
angle = (steps < 0 ? -Math.round(Math.abs(steps)) : Math.round(steps)) * 45;
}
angle = ((angle % 360) + 360) % 360;
return angle > 180 ? angle - 360 : angle;
}
/** One canonical transform for visible art, selection halo and future consumers. */
export function furnitureRenderTransform(
shape: FurnitureTransformBox,
canvasW: number,
canvasH: number,
artW: number,
artH: number,
): string {
const x = shape.x * canvasW, y = shape.y * canvasH;
const w = shape.w * canvasW, h = shape.h * canvasH;
const cx = x + w / 2, cy = y + h / 2;
const tx = x + (shape.flip_h ? w : 0);
const ty = y + (shape.flip_v ? h : 0);
const sx = (shape.flip_h ? -1 : 1) * w / artW;
const sy = (shape.flip_v ? -1 : 1) * h / artH;
const angle = Number(shape.angle) || 0;
return `${angle ? `rotate(${angle} ${cx} ${cy}) ` : ''}`
+ `translate(${tx} ${ty}) scale(${sx} ${sy})`;
}
/** Signed m/ft property field -> centimetres; null keeps invalid drafts visible. */
export function furnitureSignedFieldCm(
value: unknown,
imperial: boolean,
maxCm: number,
minCm = 0.1,
): number | null {
if (typeof value === 'string' && !value.trim()) return null;
const shown = Number(value);
if (!Number.isFinite(shown) || shown === 0) return null;
const cm = shown * (imperial ? 30.48 : 100);
const magnitude = Math.abs(cm);
if (magnitude < minCm || magnitude > maxCm) return null;
return cm;
}
/** Positive stored centimetres + one flag -> a lossless-enough signed m/ft field. */
export function furnitureSignedFieldValue(
cm: number,
flipped: boolean,
imperial: boolean,
): string {
const shown = Math.abs(Number(cm)) / (imperial ? 30.48 : 100);
// Six decimals keep the physical 0.1 cm minimum representable in both
// metres (0.001) and feet (~0.003281) without filling ordinary values with
// trailing zeroes.
const rounded = Number(shown.toFixed(6));
return String(flipped ? -rounded : rounded);
}