mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
A floor with stairs pays for them on every switch to it: the stair layer is emptied on other floors, so Lit recreates every symbol on each return and the browser lays out and paints it again. With 250 stairs (the large-house fixture, the per-floor limit) that was 2,875 SVG elements and about 40 ms per entry locally; each stair carried 3-7 separate tread lines with four bound coordinates each. The treads of one stair are now a single <path class="hp-stair-tread"> with one `M a L b` subpath per tread, in geometry order and with the numbers the lines carried. Treads of one stair never overlap (straight: parallel, >= 20 cm apart; spiral: inner ends >= 6.7 cm apart at the 3.6 cm stroke), so the path paints the same pixels at any opacity. The outline points and the tread data are built once per cached geometry object (cachedStairMarkup, weak keys), not on every render. The View and plan-editor layers share the strings; outline, hit polygon, trapezoid, arrow, attributes and handlers are unchanged. Floor 1 of the fixture drops from 4,210 to 2,960 elements. Witnesses: the unit test for the path data and its cache, and the smoke_stairs markup checks in View and in the plan editor, are red on dev. The stairs-view-tread-lines mutant restores the View lines. Issue: #740 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
import { difference, type Geom } from 'polyclip-ts';
|
|
import { geometryArea } from './physical-geometry';
|
|
import { CANVAS_LIMIT, GRID_N, NORM_W } from './canvas-constants';
|
|
import { safeStoredColor } from './color';
|
|
|
|
export const STAIR_TREAD_CM = 30;
|
|
/** One physical line weight for every visible part of either stair symbol (#688). */
|
|
export const STAIR_STROKE_CM = 3.6;
|
|
export const MAX_STAIRS_PER_SPACE = 250;
|
|
|
|
const STAIR_REFERENCE_CELL_CM = 5;
|
|
const STAIR_REFERENCE_GRID_PITCH = NORM_W / GRID_N;
|
|
|
|
/** Physical stair line weight expressed in the current plan coordinate system. */
|
|
export function stairStrokeUnits(
|
|
cellCm: unknown,
|
|
gridPitch: unknown = STAIR_REFERENCE_GRID_PITCH,
|
|
): number {
|
|
const rawCell = Number(cellCm);
|
|
const cell = Number.isFinite(rawCell) && rawCell > 0 ? rawCell : STAIR_REFERENCE_CELL_CM;
|
|
const rawPitch = Number(gridPitch);
|
|
const pitch = Number.isFinite(rawPitch) && rawPitch > 0
|
|
? rawPitch : STAIR_REFERENCE_GRID_PITCH;
|
|
return (STAIR_STROKE_CM / cell) * pitch;
|
|
}
|
|
|
|
/** Physical stair line weight on paper, in millimetres, at a 1:N scale. */
|
|
export function stairStrokePrintMm(printScale: unknown): number {
|
|
const rawScale = Number(printScale);
|
|
const scale = Number.isFinite(rawScale) && rawScale > 0 ? rawScale : 1;
|
|
return (STAIR_STROKE_CM * 10) / scale;
|
|
}
|
|
|
|
export type StraightStairDirection = 'forward' | 'backward';
|
|
export type SpiralStairDirection = 'clockwise' | 'counterclockwise';
|
|
|
|
interface StairCommon {
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
angle: number;
|
|
target_space_id?: string | null;
|
|
color?: string;
|
|
opacity?: number;
|
|
fill_color?: string;
|
|
fill_opacity?: number;
|
|
}
|
|
|
|
export interface StairVisualStyle {
|
|
color: string;
|
|
opacity: number;
|
|
fillColor: string;
|
|
fillOpacity: number;
|
|
}
|
|
|
|
export type StairVisualFields = Pick<
|
|
StairCommon, 'color' | 'opacity' | 'fill_color' | 'fill_opacity'
|
|
>;
|
|
|
|
export const DEFAULT_STAIR_VISUAL_STYLE: StairVisualStyle = {
|
|
color: '#607d8b',
|
|
opacity: 1,
|
|
fillColor: '#607d8b',
|
|
fillOpacity: 0,
|
|
};
|
|
|
|
export interface StraightStair extends StairCommon {
|
|
kind: 'straight';
|
|
direction: StraightStairDirection;
|
|
length: number;
|
|
width: number;
|
|
}
|
|
|
|
export interface SpiralStair extends StairCommon {
|
|
kind: 'spiral';
|
|
direction: SpiralStairDirection;
|
|
radius: number;
|
|
}
|
|
|
|
export type Stair = StraightStair | SpiralStair;
|
|
|
|
export interface StairLine {
|
|
a: [number, number];
|
|
b: [number, number];
|
|
}
|
|
|
|
export interface StairRenderGeometry {
|
|
outline: number[][];
|
|
trapezoid: StairLine[];
|
|
treads: StairLine[];
|
|
arrowPath: string;
|
|
center: [number, number];
|
|
}
|
|
|
|
type CachedRenderGeometry = {
|
|
fingerprint: string;
|
|
geometry: StairRenderGeometry;
|
|
};
|
|
|
|
/**
|
|
* Live entity updates repaint the card without changing persisted stairs.
|
|
* Keep the comparatively dense spiral/tread geometry behind an object-scoped
|
|
* cache so those unrelated updates do not rebuild it on every render.
|
|
*
|
|
* The fingerprint also makes the helper safe for the few write paths which
|
|
* temporarily mutate a config record in place before the authoritative model
|
|
* is rebuilt. Weak keys keep deleted spaces/stairs collectible.
|
|
*/
|
|
const RENDER_GEOMETRY_CACHE = new WeakMap<object, CachedRenderGeometry>();
|
|
|
|
function renderFingerprint(stair: Stair, cellCm: number, scale: number): string {
|
|
return stair.kind === 'straight'
|
|
? `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.length}|${stair.width}`
|
|
: `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.radius}`;
|
|
}
|
|
|
|
const finite = (value: unknown): number | null => {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
};
|
|
|
|
const clamp01 = (value: unknown, fallback: number): number => {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? Math.min(1, Math.max(0, number)) : fallback;
|
|
};
|
|
|
|
/** Resolve optional persisted colours without mutating a legacy stair record. */
|
|
export function stairVisualStyle(
|
|
stair: StairVisualFields,
|
|
fallback: Pick<StairVisualStyle, 'color' | 'opacity'> = DEFAULT_STAIR_VISUAL_STYLE,
|
|
): StairVisualStyle {
|
|
const fallbackColor = safeStoredColor(fallback.color, DEFAULT_STAIR_VISUAL_STYLE.color);
|
|
const color = safeStoredColor(stair.color, fallbackColor);
|
|
return {
|
|
color,
|
|
opacity: clamp01(stair.opacity, clamp01(fallback.opacity, 1)),
|
|
fillColor: safeStoredColor(stair.fill_color, color),
|
|
fillOpacity: clamp01(stair.fill_opacity, 0),
|
|
};
|
|
}
|
|
|
|
export function stairVisualFields(style: StairVisualStyle): StairVisualFields {
|
|
const resolved = stairVisualStyle({
|
|
color: style.color,
|
|
opacity: style.opacity,
|
|
fill_color: style.fillColor,
|
|
fill_opacity: style.fillOpacity,
|
|
});
|
|
return {
|
|
color: resolved.color,
|
|
opacity: resolved.opacity,
|
|
fill_color: resolved.fillColor,
|
|
fill_opacity: resolved.fillOpacity,
|
|
};
|
|
}
|
|
|
|
export function stairStyleVars(
|
|
stair: Stair,
|
|
cellCm: unknown,
|
|
gridPitch: unknown,
|
|
fallback: Pick<StairVisualStyle, 'color' | 'opacity'> = DEFAULT_STAIR_VISUAL_STYLE,
|
|
): string {
|
|
const style = stairVisualStyle(stair, fallback);
|
|
return `--hp-stair-line:${style.color};--hp-stair-line-opacity:${style.opacity};`
|
|
+ `--hp-stair-fill:${style.fillColor};--hp-stair-fill-opacity:${style.fillOpacity};`
|
|
+ `--hp-stair-stroke:${stairStrokeUnits(cellCm, gridPitch)}`;
|
|
}
|
|
|
|
/** Number of equal intervals whose physical size is closest to 30 cm. */
|
|
export function stairIntervalCount(pathCm: number): number {
|
|
const length = Number(pathCm);
|
|
if (!(length > 0) || !Number.isFinite(length)) return 1;
|
|
const quotient = length / STAIR_TREAD_CM;
|
|
const lower = Math.max(1, Math.floor(quotient));
|
|
const upper = Math.max(1, Math.ceil(quotient));
|
|
const error = (count: number): number => Math.abs(length / count - STAIR_TREAD_CM);
|
|
return error(upper) <= error(lower) ? upper : lower;
|
|
}
|
|
|
|
export function isStair(value: unknown): value is Stair {
|
|
if (!value || typeof value !== 'object') return false;
|
|
const item = value as Record<string, unknown>;
|
|
if (typeof item.id !== 'string' || !item.id || (item.kind !== 'straight' && item.kind !== 'spiral')) return false;
|
|
if (![item.x, item.y, item.angle].every((entry) => finite(entry) !== null)) return false;
|
|
if (Math.abs(Number(item.x)) > CANVAS_LIMIT || Math.abs(Number(item.y)) > CANVAS_LIMIT
|
|
|| Math.abs(Number(item.angle)) > 360) return false;
|
|
if (item.target_space_id != null && typeof item.target_space_id !== 'string') return false;
|
|
const validSize = (entry: unknown): boolean => {
|
|
const size = finite(entry) ?? 0;
|
|
return size > 0 && size <= CANVAS_LIMIT;
|
|
};
|
|
if (item.kind === 'straight') {
|
|
return item.direction === 'forward' || item.direction === 'backward'
|
|
? validSize(item.length) && validSize(item.width)
|
|
: false;
|
|
}
|
|
return item.direction === 'clockwise' || item.direction === 'counterclockwise'
|
|
? validSize(item.radius)
|
|
: false;
|
|
}
|
|
|
|
export function stairList(value: unknown): Stair[] {
|
|
return Array.isArray(value) ? value.filter(isStair).slice(0, MAX_STAIRS_PER_SPACE) : [];
|
|
}
|
|
|
|
const rotate = (x: number, y: number, angle: number): [number, number] => {
|
|
const radians = angle * Math.PI / 180;
|
|
const cosine = Math.cos(radians), sine = Math.sin(radians);
|
|
return [x * cosine - y * sine, x * sine + y * cosine];
|
|
};
|
|
|
|
const worldPoint = (stair: Stair, localX: number, localY: number, scale: number): [number, number] => {
|
|
const [dx, dy] = rotate(localX * scale, localY * scale, stair.angle);
|
|
return [stair.x * scale + dx, stair.y * scale + dy];
|
|
};
|
|
|
|
export function stairOutline(stair: Stair, scale = NORM_W, circleSegments = 64): number[][] {
|
|
if (stair.kind === 'straight') {
|
|
const halfLength = stair.length * scale / 2;
|
|
const halfWidth = stair.width * scale / 2;
|
|
return [
|
|
[-halfLength, -halfWidth], [halfLength, -halfWidth],
|
|
[halfLength, halfWidth], [-halfLength, halfWidth],
|
|
].map(([x, y]) => {
|
|
const [dx, dy] = rotate(x, y, stair.angle);
|
|
return [stair.x * scale + dx, stair.y * scale + dy];
|
|
});
|
|
}
|
|
const count = Math.max(24, Math.round(circleSegments));
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const radians = index * Math.PI * 2 / count;
|
|
return [
|
|
(stair.x + Math.cos(radians) * stair.radius) * scale,
|
|
(stair.y + Math.sin(radians) * stair.radius) * scale,
|
|
];
|
|
});
|
|
}
|
|
|
|
const arrowHead = (
|
|
tip: [number, number], bearing: number, size: number,
|
|
): string => {
|
|
const left = rotate(-size, -size * 0.62, bearing);
|
|
const right = rotate(-size, size * 0.62, bearing);
|
|
return `M ${tip[0]} ${tip[1]} L ${tip[0] + left[0]} ${tip[1] + left[1]} M ${tip[0]} ${tip[1]} L ${tip[0] + right[0]} ${tip[1] + right[1]}`;
|
|
};
|
|
|
|
export function stairRenderGeometry(
|
|
stair: Stair, cellCm: number, scale = NORM_W,
|
|
): StairRenderGeometry {
|
|
const center: [number, number] = [stair.x * scale, stair.y * scale];
|
|
const outline = stairOutline(stair, scale);
|
|
if (stair.kind === 'straight') {
|
|
const count = stairIntervalCount(stair.length * cellCm * GRID_N);
|
|
const startHalfWidth = stair.width * (stair.direction === 'forward' ? 0.4 : 0.5);
|
|
const endHalfWidth = stair.width * (stair.direction === 'forward' ? 0.5 : 0.4);
|
|
const startX = -stair.length / 2;
|
|
const endX = stair.length / 2;
|
|
const startTop = worldPoint(stair, startX, -startHalfWidth, scale);
|
|
const startBottom = worldPoint(stair, startX, startHalfWidth, scale);
|
|
const endTop = worldPoint(stair, endX, -endHalfWidth, scale);
|
|
const endBottom = worldPoint(stair, endX, endHalfWidth, scale);
|
|
// The 100% base is already the matching short side of the outer outline.
|
|
// Draw it only once; the trapezoid contributes its two legs and 80% base.
|
|
const trapezoid: StairLine[] = [
|
|
{ a: startTop, b: endTop },
|
|
{ a: startBottom, b: endBottom },
|
|
stair.direction === 'forward'
|
|
? { a: startTop, b: startBottom }
|
|
: { a: endTop, b: endBottom },
|
|
];
|
|
const treads: StairLine[] = [];
|
|
for (let index = 1; index < count; index++) {
|
|
const fraction = index / count;
|
|
const x = startX + stair.length * fraction;
|
|
const halfWidth = startHalfWidth + (endHalfWidth - startHalfWidth) * fraction;
|
|
treads.push({
|
|
a: worldPoint(stair, x, -halfWidth, scale),
|
|
b: worldPoint(stair, x, halfWidth, scale),
|
|
});
|
|
}
|
|
const from = worldPoint(stair, -0.3 * stair.length, 0, scale);
|
|
const tip = worldPoint(stair, 0.3 * stair.length, 0, scale);
|
|
const bearing = stair.angle;
|
|
const size = Math.min(stair.width, stair.length) * scale * 0.16;
|
|
return {
|
|
outline, trapezoid, treads, center,
|
|
arrowPath: `M ${from[0]} ${from[1]} L ${tip[0]} ${tip[1]} ${arrowHead(tip, bearing, size)}`,
|
|
};
|
|
}
|
|
|
|
const travelRadius = stair.radius * 2 / 3;
|
|
const circumference = Math.PI * 2 * travelRadius;
|
|
const count = stairIntervalCount(circumference * cellCm * GRID_N);
|
|
const sign = stair.direction === 'clockwise' ? 1 : -1;
|
|
const treads: StairLine[] = [];
|
|
for (let index = 0; index < count; index++) {
|
|
const localAngle = index * (Math.PI * 2 / count) * sign;
|
|
const degrees = stair.angle + localAngle * 180 / Math.PI;
|
|
const inner = rotate(stair.radius * scale * 0.18, 0, degrees);
|
|
const outer = rotate(stair.radius * scale, 0, degrees);
|
|
treads.push({
|
|
a: [center[0] + inner[0], center[1] + inner[1]],
|
|
b: [center[0] + outer[0], center[1] + outer[1]],
|
|
});
|
|
}
|
|
const radius = stair.radius * scale * 0.62;
|
|
const start = stair.angle * Math.PI / 180;
|
|
const sweep = sign * Math.PI * 1.5;
|
|
const end = start + sweep;
|
|
const from: [number, number] = [center[0] + Math.cos(start) * radius, center[1] + Math.sin(start) * radius];
|
|
const tip: [number, number] = [center[0] + Math.cos(end) * radius, center[1] + Math.sin(end) * radius];
|
|
const sweepFlag = sign > 0 ? 1 : 0;
|
|
const tangent = end * 180 / Math.PI + (sign > 0 ? 90 : -90);
|
|
return {
|
|
outline, trapezoid: [], treads, center,
|
|
arrowPath: `M ${from[0]} ${from[1]} A ${radius} ${radius} 0 1 ${sweepFlag} ${tip[0]} ${tip[1]} ${arrowHead(tip, tangent, stair.radius * scale * 0.16)}`,
|
|
};
|
|
}
|
|
|
|
export function cachedStairRenderGeometry(
|
|
stair: Stair, cellCm: number, scale = NORM_W,
|
|
): StairRenderGeometry {
|
|
const fingerprint = renderFingerprint(stair, cellCm, scale);
|
|
const cached = RENDER_GEOMETRY_CACHE.get(stair);
|
|
if (cached?.fingerprint === fingerprint) return cached.geometry;
|
|
const geometry = stairRenderGeometry(stair, cellCm, scale);
|
|
RENDER_GEOMETRY_CACHE.set(stair, { fingerprint, geometry });
|
|
return geometry;
|
|
}
|
|
|
|
/** Attribute strings of one stair symbol, shared by the View and plan-editor layers. */
|
|
export interface StairMarkup {
|
|
/** `points` of the outline polygon and of its hit twin. */
|
|
outline: string;
|
|
/** `d` of the single tread path; empty when the stair has no treads. */
|
|
treads: string;
|
|
}
|
|
|
|
/**
|
|
* #740: all treads of one stair are a single `<path>`, one `M a L b` subpath
|
|
* per tread in geometry order with the numbers the separate `<line>`s carried.
|
|
* Treads of one stair never overlap (parallel ≥ 20 cm apart; spiral inner ends
|
|
* ≥ 6.7 cm apart at the 3.6 cm stroke), so the path paints the same pixels.
|
|
*/
|
|
export function stairTreadPath(treads: readonly StairLine[]): string {
|
|
return treads.map((line) => `M ${line.a[0]} ${line.a[1]} L ${line.b[0]} ${line.b[1]}`).join(' ');
|
|
}
|
|
|
|
const MARKUP_CACHE = new WeakMap<StairRenderGeometry, StairMarkup>();
|
|
|
|
/**
|
|
* The strings are built once per geometry object, not on every render: the
|
|
* render-geometry cache hands out a new object only when the geometry changes,
|
|
* and weak keys release the strings together with it.
|
|
*/
|
|
export function cachedStairMarkup(geometry: StairRenderGeometry): StairMarkup {
|
|
let markup = MARKUP_CACHE.get(geometry);
|
|
if (!markup) {
|
|
markup = {
|
|
outline: geometry.outline.map((point) => point.join(',')).join(' '),
|
|
treads: stairTreadPath(geometry.treads),
|
|
};
|
|
MARKUP_CACHE.set(geometry, markup);
|
|
}
|
|
return markup;
|
|
}
|
|
|
|
export function stairFootprintGeometry(stair: Stair, scale = NORM_W): Geom {
|
|
const ring = stairOutline(stair, scale);
|
|
return (ring.length ? [[[...ring, ring[0]]]] : []) as unknown as Geom;
|
|
}
|
|
|
|
export function floorAreaMinusStairs(
|
|
floor: readonly number[][], stairs: readonly Stair[] | null | undefined, scale = NORM_W,
|
|
): number {
|
|
if (floor.length < 3) return 0;
|
|
const geometry = [[[...floor.map((point) => [point[0], point[1]]), [floor[0][0], floor[0][1]]]]] as Geom;
|
|
return geometryAreaMinusStairs(geometry, stairs, scale);
|
|
}
|
|
|
|
export function geometryAreaMinusStairs(
|
|
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
|
|
): number {
|
|
return Math.max(0, geometryArea(geometryMinusStairs(source, stairs, scale)));
|
|
}
|
|
|
|
export function geometryMinusStairs(
|
|
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
|
|
): Geom {
|
|
const footprints = stairFootprintsTouching(source, stairs, scale);
|
|
if (!footprints.length) return source;
|
|
try {
|
|
// A single sweep avoids repeatedly rebuilding the same subject for dense floors.
|
|
return difference(source, ...footprints);
|
|
} catch {
|
|
// Keep a valid room usable if one future record reaches this layer malformed.
|
|
let geometry = source;
|
|
for (const footprint of footprints) {
|
|
try { geometry = difference(geometry, footprint); } catch { /* skip only the bad record */ }
|
|
}
|
|
return geometry;
|
|
}
|
|
}
|
|
|
|
type Bounds = readonly [number, number, number, number];
|
|
|
|
function geometryBounds(geometry: Geom): Bounds | null {
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
const visit = (value: unknown): void => {
|
|
if (!Array.isArray(value)) return;
|
|
if (typeof value[0] === 'number') {
|
|
const x = value[0];
|
|
const y = value[1] as number;
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
return;
|
|
}
|
|
for (const item of value) visit(item);
|
|
};
|
|
visit(geometry);
|
|
return minX <= maxX && minY <= maxY ? [minX, minY, maxX, maxY] : null;
|
|
}
|
|
|
|
const boundsOverlap = (a: Bounds, b: Bounds | null): boolean => !!b
|
|
&& a[0] <= b[2] && b[0] <= a[2] && a[1] <= b[3] && b[1] <= a[3];
|
|
|
|
/**
|
|
* #669: only a footprint whose bounds overlap the subject can change the
|
|
* difference. A room on a maximum-size floor otherwise sends all 250 stair
|
|
* footprints into one polyclip sweep, and the room count multiplies it.
|
|
*/
|
|
export function stairFootprintsTouching(
|
|
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
|
|
): Geom[] {
|
|
const bounds = geometryBounds(source);
|
|
if (!bounds) return [];
|
|
return stairList(stairs)
|
|
.map((stair) => stairFootprintGeometry(stair, scale))
|
|
.filter((footprint) => boundsOverlap(bounds, geometryBounds(footprint)));
|
|
}
|
|
|
|
/**
|
|
* The summary panel calculates floor area after the first paint. A maximum
|
|
* size space may contain 250 stairs; clipping all their footprints in one
|
|
* polyclip call is observably one long main-thread task on slower clients.
|
|
* Keep the synchronous helper above for ordinary room-sized callers, while
|
|
* this iterator bounds each background slice and lets the scheduler yield.
|
|
*/
|
|
export function* geometryMinusStairsSteps(
|
|
source: Geom,
|
|
stairs: readonly Stair[] | null | undefined,
|
|
scale = NORM_W,
|
|
batchSize = 24,
|
|
): Generator<void, Geom, void> {
|
|
const footprints = stairList(stairs).map((stair) => stairFootprintGeometry(stair, scale));
|
|
const size = Math.max(1, Math.floor(batchSize));
|
|
let geometry = source;
|
|
for (let index = 0; index < footprints.length; index += size) {
|
|
const batch = footprints.slice(index, index + size);
|
|
try {
|
|
geometry = difference(geometry, ...batch);
|
|
} catch {
|
|
for (const footprint of batch) {
|
|
try { geometry = difference(geometry, footprint); } catch { /* skip only the bad record */ }
|
|
}
|
|
}
|
|
yield;
|
|
}
|
|
return geometry;
|
|
}
|