mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
perf: fingerprint Resize handles once per frame
Issue: #277 User-Visible: no
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,78 @@
|
||||
// #277: warm Resize-layer render cost on the supported large-house ceiling.
|
||||
// The deterministic snapshot-call assertion catches the original regression
|
||||
// even when runner timing noise happens to keep the p95 below its budget.
|
||||
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
|
||||
import { launch } from './serve.mjs';
|
||||
|
||||
const WARMUPS = 3;
|
||||
const SAMPLES = 20;
|
||||
const RENDER_P95_MS = 25;
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const config = { ...fixture.config, spaces: [fixture.config.spaces[0]] };
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const result = await page.evaluate(async ({ config, warmups, samples }) => {
|
||||
const card = window.__card;
|
||||
card._serverCfg = structuredClone(config);
|
||||
card._space = config.spaces[0].id;
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
|
||||
const view = card._viewOr(card._baseVb());
|
||||
const originalSnapshot = card._rszSnapshot.bind(card);
|
||||
let snapshotCalls = 0;
|
||||
card._rszSnapshot = () => {
|
||||
snapshotCalls++;
|
||||
return originalSnapshot();
|
||||
};
|
||||
|
||||
for (let index = 0; index < warmups; index++) card._renderResizeLayer(view);
|
||||
snapshotCalls = 0;
|
||||
const times = [];
|
||||
for (let index = 0; index < samples; index++) {
|
||||
const started = performance.now();
|
||||
card._renderResizeLayer(view);
|
||||
times.push(performance.now() - started);
|
||||
}
|
||||
card._rszSnapshot = originalSnapshot;
|
||||
return {
|
||||
times,
|
||||
snapshotCalls,
|
||||
roomCount: card._rszRooms().length,
|
||||
handleCount: card._rszRooms().reduce((sum, room) => sum + room.poly.length, 0),
|
||||
};
|
||||
}, { config, warmups: WARMUPS, samples: SAMPLES });
|
||||
|
||||
await browser.close();
|
||||
const quantile = (values, ratio) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
|
||||
};
|
||||
const render = {
|
||||
min: Math.min(...result.times),
|
||||
median: quantile(result.times, 0.5),
|
||||
p95: quantile(result.times, 0.95),
|
||||
max: Math.max(...result.times),
|
||||
};
|
||||
const pass = result.snapshotCalls === SAMPLES
|
||||
&& result.roomCount === 20
|
||||
&& result.handleCount === 80
|
||||
&& render.p95 <= RENDER_P95_MS;
|
||||
console.log(JSON.stringify({
|
||||
issue: 277,
|
||||
fixture: LARGE_HOUSE_COUNTS,
|
||||
warmups: WARMUPS,
|
||||
samples: SAMPLES,
|
||||
roomCount: result.roomCount,
|
||||
handleCount: result.handleCount,
|
||||
snapshotCalls: result.snapshotCalls,
|
||||
snapshotCallsPerFrame: result.snapshotCalls / SAMPLES,
|
||||
render,
|
||||
budgets: { renderP95Ms: RENDER_P95_MS, maxSnapshotCallsPerFrame: 1 },
|
||||
pass,
|
||||
}, null, 2));
|
||||
if (!pass) process.exitCode = 1;
|
||||
Vendored
+8
-8
File diff suppressed because one or more lines are too long
@@ -134,6 +134,8 @@ building the preview frame itself.
|
||||
preview/commit/Undo, disabled accessibility, real fixture topology,
|
||||
production-preflight failure and cancellation;
|
||||
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
|
||||
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
|
||||
and exactly one geometry snapshot per rendered frame;
|
||||
- mutation gate: eligibility, third-room, topology, jamb and commit-preflight
|
||||
bypass mutants.
|
||||
|
||||
|
||||
@@ -2001,6 +2001,9 @@ require hands on real hardware — they remain for the human pass.
|
||||
- [ ] `npm run benchmark:safe-resize`: pointer clamp p95 ≤16 ms, ≤20% over the
|
||||
same-run historical baseline (with bounded noise); cached pointerup
|
||||
preflight p95 ≤75 ms; active-plan delta cache ≤4096 entries
|
||||
- [ ] `npm run benchmark:safe-resize-render`: on the 20-room/80-handle floor,
|
||||
a warm Resize layer takes one geometry snapshot per frame and stays at
|
||||
p95 ≤25 ms
|
||||
- [ ] Five #277 mutants are caught: axis eligibility, third-room cascade,
|
||||
topology signature, physical jamb and commit preflight
|
||||
- [ ] Device markers do not move; the room settings gear re-centres itself
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"benchmark:optimize-geometry-preflight": "node demo/benchmark_optimize_geometry_preflight.mjs",
|
||||
"benchmark:coincident-partitions": "node demo/benchmark_coincident_partitions.mjs",
|
||||
"benchmark:safe-resize": "node demo/benchmark_safe_resize.mjs",
|
||||
"benchmark:safe-resize-render": "node demo/benchmark_safe_resize_render.mjs",
|
||||
"golden:capture": "node demo/golden/run.mjs --mode=capture",
|
||||
"golden:verify": "node demo/golden/run.mjs --mode=verify",
|
||||
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
|
||||
|
||||
+10
-3
@@ -8329,8 +8329,10 @@ class HouseplanCard extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _rszResolution(roomId: string, edge: number): SafeResizeResolution {
|
||||
const snap = this._rszDrag?.snap || this._rszSnapshot();
|
||||
private _rszResolution(
|
||||
roomId: string, edge: number, renderSnapshot?: string,
|
||||
): SafeResizeResolution {
|
||||
const snap = this._rszDrag?.snap || renderSnapshot || this._rszSnapshot();
|
||||
const key = `${this._space}|${this._cellCm}|${this._gridPitch}|${snap}`;
|
||||
if (!this._rszEligibilityCache || this._rszEligibilityCache.key !== key) {
|
||||
this._rszEligibilityCache = { key, values: new Map() };
|
||||
@@ -8699,13 +8701,18 @@ class HouseplanCard extends LitElement {
|
||||
` M 0 ${f(0.22 * s)} V ${f(s)} M ${f(-0.32 * s)} ${f(0.6 * s)} L 0 ${f(s)} L ${f(0.32 * s)} ${f(0.6 * s)}`;
|
||||
const parts: TemplateResult[] = [];
|
||||
const rooms = this._rszRooms();
|
||||
// A geometry snapshot is a deep clone/serialization. It is the cache key
|
||||
// for every handle in this frame, so compute it once per layer render —
|
||||
// never once per room edge. During a drag the immutable gesture snapshot
|
||||
// remains authoritative inside _rszResolution.
|
||||
const renderSnapshot = this._rszDrag?.snap || this._rszSnapshot();
|
||||
for (const r of rooms) {
|
||||
for (let i = 0; i < r.poly.length; i++) {
|
||||
const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length];
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this._gridPitch) continue;
|
||||
const mx = f((a[0] + b[0]) / 2), my = f((a[1] + b[1]) / 2);
|
||||
const ang = f(Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI);
|
||||
const resolution = this._rszResolution(r.id, i);
|
||||
const resolution = this._rszResolution(r.id, i, renderSnapshot);
|
||||
const disabled = !resolution.enabled;
|
||||
const reason = disabled ? this._rszReasonText(resolution.reason) : this._t('title.markup_resize');
|
||||
parts.push(svg`<circle class="rszhandle ${disabled ? 'disabled' : ''}"
|
||||
|
||||
@@ -49,3 +49,9 @@ test('#277 a lossy persistence rekey stops at the last complete preview', () =>
|
||||
assert.match(card, /this\._rszPreview = previousPreview/);
|
||||
assert.match(card, /g\.d = previousD/);
|
||||
});
|
||||
|
||||
test('#277 Resize render fingerprints geometry once for the whole handle layer', () => {
|
||||
assert.match(card, /const renderSnapshot = this\._rszDrag\?\.snap \|\| this\._rszSnapshot\(\)/);
|
||||
assert.match(card, /this\._rszResolution\(r\.id, i, renderSnapshot\)/);
|
||||
assert.doesNotMatch(card, /const resolution = this\._rszResolution\(r\.id, i\);/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user