perf(stairs): draw all treads of a stair as one path (#740)

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
This commit is contained in:
Claude
2026-10-01 18:27:53 +00:00
committed by claude[bot]
parent 9f695d8a5d
commit d709ea110d
9 changed files with 145 additions and 13 deletions
+45
View File
@@ -4,6 +4,7 @@ import test from 'node:test';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import {
cachedStairMarkup,
cachedStairRenderGeometry,
floorAreaMinusStairs,
geometryAreaMinusStairs,
@@ -17,6 +18,7 @@ import {
stairStrokePrintMm,
stairStrokeUnits,
stairStyleVars,
stairTreadPath,
stairVisualFields,
stairVisualStyle,
} from '../test-build/stairs.js';
@@ -212,6 +214,49 @@ test('#663 cached render geometry survives live repaints and invalidates only on
assert.notEqual(changed, first, 'in-place edits cannot leave a stale cache entry');
});
test('#740 AC1 all treads of a stair are one path with the separate lines\' numbers', () => {
const joined = (treads) => treads
.map((line) => `M ${line.a[0]} ${line.a[1]} L ${line.b[0]} ${line.b[1]}`).join(' ');
const cases = [
['straight forward', straight({ angle: 17 })],
['straight backward', straight({ direction: 'backward', angle: -33 })],
['spiral clockwise', spiral({ angle: 30 })],
['spiral counterclockwise', spiral({ direction: 'counterclockwise' })],
];
for (const [label, stair] of cases) {
const geometry = stairRenderGeometry(stair, 5);
assert.ok(geometry.treads.length > 1, `${label}: the fixture has treads`);
assert.equal(stairTreadPath(geometry.treads), joined(geometry.treads), label);
const markup = cachedStairMarkup(geometry);
assert.equal(markup.treads, joined(geometry.treads), `${label}: treads in geometry order`);
assert.equal(markup.treads.match(/M /g).length, geometry.treads.length,
`${label}: one subpath per tread`);
assert.equal(markup.treads.match(/L /g).length, geometry.treads.length, label);
assert.equal(markup.outline, geometry.outline.map((point) => point.join(',')).join(' '),
`${label}: the outline points are the polygon's former string`);
}
const short = stairRenderGeometry(straight({ length: 35 / 1200 }), 5);
assert.equal(short.treads.length, 0, 'a straight stair shorter than 40 cm has one interval');
assert.equal(stairTreadPath(short.treads), '');
assert.equal(cachedStairMarkup(short).treads, '', 'no treads, no path data');
});
test('#740 AC1 tread and outline strings are built once per geometry object', () => {
const item = straight();
const geometry = cachedStairRenderGeometry(item, 5);
const first = cachedStairMarkup(geometry);
assert.equal(cachedStairMarkup(geometry), first, 'the same geometry reuses the built strings');
assert.equal(cachedStairMarkup(cachedStairRenderGeometry(item, 5)), first,
'a repaint of an unchanged stair reuses them through the geometry cache');
item.angle = 45;
const moved = cachedStairMarkup(cachedStairRenderGeometry(item, 5));
assert.notEqual(moved, first, 'a geometry change builds new strings');
assert.notEqual(moved.treads, first.treads);
const copy = stairRenderGeometry(item, 5);
assert.notEqual(cachedStairMarkup(copy), moved, 'the cache key is the geometry object');
assert.deepEqual(cachedStairMarkup(copy), moved, 'equal geometry yields equal strings');
});
test('#663 stair magnet covers rectangle/rectangle, rectangle/circle and circle/circle footprints', () => {
const rect = straight({ id: 'rect', x: 0.5, y: 0.5, length: 0.2, width: 0.1 });
const circle = spiral({ id: 'circle', x: 0.5, y: 0.5, radius: 0.08 });