mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
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
434 lines
18 KiB
TypeScript
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);
|
|
}
|