Files
houseplan-card/src/editors/decor/geometry.ts
T
Codex 10a1a26612 feat: persist the Background editor's default style with the plan (#377)
settings.decor_default_style (all fields optional, validated) seeds
_decorStyle once from the first config that arrives; every UI change of
the session default flows through one runtime method with a 1s debounce
and the ordinary serialized expected_rev write path. The built-in default
is stored as the absence of the key; a partial or garbage key falls back
per-field. decorStyleFromSettings/decorStyleToSettings are the single
snake_case<->camelCase conversion point.

User-Visible: yes
Issue: #377
2026-08-29 21:39:08 +03:00

344 lines
12 KiB
TypeScript
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.
import type { DecorShape, DecorStyle } from './types';
import { safeStoredColor } from '../../color';
export interface DecorBox {
x: number;
y: number;
w: number;
h: number;
angle?: number;
}
export interface SnapSegment {
a: number[];
b: number[];
}
export interface SnapGeometry {
points: number[][];
segments: SnapSegment[];
}
export interface SnapResult {
point: number[];
target: number[] | null;
kind: 'grid' | 'point' | 'edge';
}
export const DEFAULT_DECOR_STYLE: DecorStyle = {
color: '#607d8b',
opacity: 1,
// The old default was 3 render units. On the default 5 cm/cell plan this
// is 3.6 cm, retained so a new canonical object looks familiar.
widthCm: 3.6,
fill: false,
fillColor: '#607d8b',
fillOpacity: 0.25,
};
export const clamp01 = (value: unknown, fallback = 1): number => {
const n = Number(value);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback;
};
export const normalizeAngle = (value: unknown): number => {
let n = Number(value);
if (!Number.isFinite(n)) return 0;
n = ((n % 360) + 360) % 360;
return n > 180 ? n - 360 : n;
};
/**
* Axis-aligned boxes keep their stored top-left on the lattice. For an
* oriented resize, however, resizeDecorBox has already derived this top-left
* from the fixed opposite corner. Snapping it again translates the whole box
* and breaks the fixed-corner contract.
*/
export function resizedBoxTopLeft(
box: Pick<DecorBox, 'x' | 'y'>,
angle: unknown,
grid: (point: number[]) => number[],
): number[] {
return normalizeAngle(angle) ? [box.x, box.y] : grid([box.x, box.y]);
}
/** A rectangle/ellipse must have two non-zero axes; a line only needs length. */
export function validDecorDraft(
kind: 'line' | 'rect' | 'ellipse',
a: number[], b: number[], minSize: number,
): boolean {
const dx = Math.abs(b[0] - a[0]), dy = Math.abs(b[1] - a[1]);
if (kind === 'line') return Math.hypot(dx, dy) >= minSize;
return dx >= minSize && dy >= minSize;
}
/** Render units represented by a physical number of centimetres. */
export const decorCmToUnits = (cm: unknown, cellCm: number, gridPitch: number): number => {
const n = Number(cm);
const cell = Number.isFinite(cellCm) && cellCm > 0 ? cellCm : 5;
return Number.isFinite(n) && n > 0 ? (n / cell) * gridPitch : 0;
};
/** Physical centimetres represented by render units. */
export const decorUnitsToCm = (units: unknown, cellCm: number, gridPitch: number): number => {
const n = Number(units);
const cell = Number.isFinite(cellCm) && cellCm > 0 ? cellCm : 5;
return Number.isFinite(n) && gridPitch > 0 ? (n / gridPitch) * cell : 0;
};
/** Canonical width wins; legacy width remains pixel-identical until edited. */
export const decorStrokeCm = (
shape: Pick<DecorShape, 'width_cm' | 'width'> | null | undefined,
cellCm: number,
gridPitch: number,
fallback = DEFAULT_DECOR_STYLE.widthCm,
): number => {
const canonical = Number(shape?.width_cm);
if (Number.isFinite(canonical) && canonical > 0) return canonical;
const legacy = Number(shape?.width);
if (Number.isFinite(legacy) && legacy > 0) return decorUnitsToCm(legacy, cellCm, gridPitch);
return fallback;
};
export const decorStrokeUnits = (
shape: Pick<DecorShape, 'width_cm' | 'width'> | null | undefined,
cellCm: number,
gridPitch: number,
fallback = DEFAULT_DECOR_STYLE.widthCm,
): number => {
const canonical = Number(shape?.width_cm);
if (Number.isFinite(canonical) && canonical > 0)
return decorCmToUnits(canonical, cellCm, gridPitch);
const legacy = Number(shape?.width);
if (Number.isFinite(legacy) && legacy > 0) return legacy;
return decorCmToUnits(fallback, cellCm, gridPitch);
};
export function decorStyleOf(
shape: DecorShape | null | undefined,
cellCm: number,
gridPitch: number,
fallback: DecorStyle = DEFAULT_DECOR_STYLE,
): DecorStyle {
const fillable = shape?.kind === 'rect' || shape?.kind === 'ellipse';
const source = shape as any;
const color = safeStoredColor(source?.color, fallback.color);
return {
color,
opacity: clamp01(source?.opacity, fallback.opacity),
widthCm: decorStrokeCm(shape, cellCm, gridPitch, fallback.widthCm),
fill: fillable ? source?.fill === true : false,
fillColor: safeStoredColor(source?.fill_color, source?.fill ? color : fallback.fillColor),
// Legacy fill was hard-coded to 0.25.
fillOpacity: fillable && source?.fill
? clamp01(source?.fill_opacity, 0.25)
: fallback.fillOpacity,
};
}
/** Canonical persisted style. Legacy `width` is intentionally removed. */
/**
* #377: the single conversion point between the persisted settings key
* `settings.decor_default_style` (snake_case) and the in-memory `DecorStyle`
* (camelCase). Unknown fields are ignored; garbage falls back per-field to
* the base so a partial or hand-edited key still yields a whole style.
*/
export function decorStyleFromSettings(raw: unknown, base: DecorStyle): DecorStyle {
if (!raw || typeof raw !== 'object') return { ...base };
const source = raw as Record<string, unknown>;
const color = typeof source.color === 'string' && source.color ? source.color : base.color;
const fillColor = typeof source.fill_color === 'string' && source.fill_color
? source.fill_color : base.fillColor;
const widthRaw = Number(source.width_cm);
return {
color,
opacity: clamp01(source.opacity, base.opacity),
widthCm: Number.isFinite(widthRaw) && widthRaw > 0
? Math.max(0.1, Math.min(100, widthRaw)) : base.widthCm,
fill: typeof source.fill === 'boolean' ? source.fill : base.fill,
fillColor,
fillOpacity: clamp01(source.fill_opacity, base.fillOpacity),
};
}
/**
* #377: the inverse conversion. Returns null when the style equals
* DEFAULT_DECOR_STYLE — the default is stored as the ABSENCE of the key
* (the bg_color / fill_colors settings pattern).
*/
export function decorStyleToSettings(style: DecorStyle): Record<string, unknown> | null {
const def = DEFAULT_DECOR_STYLE;
if (style.color === def.color && style.opacity === def.opacity
&& style.widthCm === def.widthCm && style.fill === def.fill
&& style.fillColor === def.fillColor && style.fillOpacity === def.fillOpacity) return null;
return {
color: style.color,
opacity: clamp01(style.opacity),
width_cm: Math.max(0.1, Math.min(100, Number(style.widthCm) || 0.1)),
fill: !!style.fill,
fill_color: style.fillColor,
fill_opacity: clamp01(style.fillOpacity, 0.25),
};
}
export function decorStylePatch(style: DecorStyle, fillable: boolean): Record<string, unknown> {
return {
color: style.color,
opacity: clamp01(style.opacity),
width_cm: Math.max(0.1, Math.min(100, Number(style.widthCm) || 0.1)),
...(fillable ? {
fill: style.fill,
fill_color: style.fillColor,
fill_opacity: clamp01(style.fillOpacity, 0.25),
} : {}),
};
}
export function boxCorners(box: DecorBox): number[][] {
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
const a = (normalizeAngle(box.angle) * Math.PI) / 180;
const cs = Math.cos(a), sn = Math.sin(a);
const rot = (x: number, y: number): number[] => {
const dx = x - cx, dy = y - cy;
return [cx + dx * cs - dy * sn, cy + dx * sn + dy * cs];
};
return [
rot(box.x, box.y), rot(box.x + box.w, box.y),
rot(box.x + box.w, box.y + box.h), rot(box.x, box.y + box.h),
];
}
export function boxAnchors(box: DecorBox): SnapGeometry {
const c = boxCorners(box);
const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const center = [(c[0][0] + c[2][0]) / 2, (c[0][1] + c[2][1]) / 2];
return {
points: [...c, mid(c[0], c[1]), mid(c[1], c[2]), mid(c[2], c[3]), mid(c[3], c[0]), center],
segments: c.map((p, i) => ({ a: p, b: c[(i + 1) % 4] })),
};
}
/** Resize an oriented box about the opposite corner. */
export function resizeDecorBox(
orig: DecorBox,
sgx: number,
sgy: number,
px: number,
py: number,
keepAspect: boolean,
step: number,
minSize: number,
): DecorBox {
const a = (normalizeAngle(orig.angle) * 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 : orig.w / 2;
const fly = sgy > 0 ? -orig.h / 2 : orig.h / 2;
const fx = cx + flx * ux + fly * vx;
const fy = cy + flx * uy + fly * vy;
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);
let proportionalScale: number | null = null;
if (keepAspect) {
const kx = w / Math.max(orig.w, minSize);
const ky = h / Math.max(orig.h, minSize);
proportionalScale = Math.max(
minSize / Math.max(orig.w, minSize),
minSize / Math.max(orig.h, minSize),
kx, ky,
);
w = orig.w * proportionalScale;
h = orig.h * proportionalScale;
}
if (step > 0) {
if (keepAspect) {
const wCells = Math.max(1, Math.round(orig.w / step));
const hCells = Math.max(1, Math.round(orig.h / step));
const aligned = Math.abs(orig.w - wCells * step) < 1e-6
&& Math.abs(orig.h - hCells * step) < 1e-6;
if (aligned) {
// A proportional box can keep BOTH dimensions on the grid only at a
// common scale increment. For a 36×18-cell sofa that increment is
// 1/18; snapping just the dominant side would leave the depth halfway
// between cells. Shift remains the independent-axis escape hatch.
const gcd = (a: number, b: number): number => {
let x = Math.abs(a), y = Math.abs(b);
while (y) [x, y] = [y, x % y];
return Math.max(1, x);
};
const scaleStep = 1 / gcd(wCells, hCells);
const minimumScale = Math.max(minSize / orig.w, minSize / orig.h);
const scale = Math.max(
minimumScale,
Math.round((proportionalScale ?? 1) / scaleStep) * scaleStep,
);
w = orig.w * scale;
h = orig.h * scale;
} else {
// Legacy off-grid boxes cannot satisfy exact ratio + two-axis grid at
// once. Preserve their ratio and snap the visually dominant side.
const dominantW = orig.w >= orig.h;
if (dominantW) {
w = Math.round(w / step) * step;
h = w * (orig.h / Math.max(orig.w, minSize));
} else {
h = Math.round(h / step) * step;
w = h * (orig.w / Math.max(orig.h, minSize));
}
}
} else {
w = Math.round(w / step) * step;
h = Math.round(h / step) * step;
}
}
w = Math.max(minSize, w);
h = Math.max(minSize, h);
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, angle: normalizeAngle(orig.angle) || undefined };
}
const closestOnSegment = (p: number[], a: number[], b: number[]): number[] => {
const dx = b[0] - a[0], dy = b[1] - a[1];
const d2 = dx * dx + dy * dy;
if (d2 < 1e-12) return [...a];
const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / d2));
return [a[0] + dx * t, a[1] + dy * t];
};
/**
* Light magnetism that never violates the grid invariant. Candidate points and
* edge projections are quantised through the caller's `grid` function before
* they can win.
*/
export function snapDecorPoint(
raw: number[], geometry: SnapGeometry, tolerance: number,
grid: (point: number[]) => number[],
): SnapResult {
const base = grid(raw);
let best: { p: number[]; d: number; kind: 'point' | 'edge'; target: number[] } | null = null;
for (const target of geometry.points) {
const p = grid(target);
const d = Math.hypot(p[0] - raw[0], p[1] - raw[1]);
if (d <= tolerance && (!best || d < best.d)) best = { p, d, kind: 'point', target };
}
for (const edge of geometry.segments) {
const target = closestOnSegment(raw, edge.a, edge.b);
const p = grid(target);
const d = Math.hypot(p[0] - raw[0], p[1] - raw[1]);
if (d <= tolerance && (!best || d < best.d)) best = { p, d, kind: 'edge', target };
}
return best
? { point: best.p, target: best.target, kind: best.kind }
: { point: base, target: null, kind: 'grid' };
}
export function mergeSnapGeometry(parts: ReadonlyArray<SnapGeometry>): SnapGeometry {
return {
points: parts.flatMap((p) => p.points),
segments: parts.flatMap((p) => p.segments),
};
}