diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 8d1945d8..235b74c1 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -96,6 +96,18 @@ const MUTANT_DEFINITIONS = [ replace: ' return source; // mutant: footprints no longer affect clean area\n', }], }, + { + id: 'stairs-summary-area-clips-all-footprints-in-one-task', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test test/stairs.test.mjs', + because: '#663 AC13: the maximum 250 stair footprints must be clipped in bounded slices; ' + + 'one polyclip sweep blocks the summary panel main thread on slower clients.', + patches: [{ + file: 'src/stairs.ts', + find: ' batchSize = 24,\n', + replace: ' batchSize = MAX_STAIRS_PER_SPACE, // mutant: one long main-thread clip\n', + }], + }, { id: 'stairs-view-pan-opens-target-floor', guard: 'node demo/smoke_stairs.mjs', diff --git a/src/stairs.ts b/src/stairs.ts index 1543f340..23ad5916 100644 --- a/src/stairs.ts +++ b/src/stairs.ts @@ -247,3 +247,33 @@ export function geometryMinusStairs( return geometry; } } + +/** + * 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 { + 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; +} diff --git a/src/summary-panel-metrics.ts b/src/summary-panel-metrics.ts index 8c987951..320f1964 100644 --- a/src/summary-panel-metrics.ts +++ b/src/summary-panel-metrics.ts @@ -9,7 +9,7 @@ import { innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry } fr import type { Marker, ServerConfig, SpaceModel, SummaryPanelSource } from './types'; import type { HaRegistrySnapshot } from './ha-binding-status'; import type { SummaryHass } from './summary-panel-host'; -import { geometryMinusStairs } from './stairs'; +import { geometryMinusStairsSteps } from './stairs'; /** Count the unique real HA devices represented anywhere on the plan. */ export function representedHaDeviceIds(input: { @@ -119,9 +119,16 @@ export function* cleanFloorAreaSteps( spaceFloor = unionGeometry(spaceFloor, clean); yield; } - // Difference distributes over union. Subtracting the shared stair set once - // avoids rooms x stairs polygon clips on dense plans (#663). - spaceFloor = geometryMinusStairs(spaceFloor || [], space.stairs); + // Difference distributes over union. Subtract the shared stair set once, + // but in bounded batches: 250 footprints in one polyclip sweep becomes a + // visible long task on slower clients (#663). + const stairSteps = geometryMinusStairsSteps(spaceFloor || [], space.stairs); + let stairStep = stairSteps.next(); + while (!stairStep.done) { + yield; + stairStep = stairSteps.next(); + } + spaceFloor = stairStep.value; const cmPerUnit = prepared.cellCm / GRID_PITCH; total += geometryArea(spaceFloor) * cmPerUnit * cmPerUnit / 1e4; } diff --git a/test/stairs.test.mjs b/test/stairs.test.mjs index a962f576..388cd8c6 100644 --- a/test/stairs.test.mjs +++ b/test/stairs.test.mjs @@ -6,6 +6,7 @@ import { cachedStairRenderGeometry, floorAreaMinusStairs, geometryAreaMinusStairs, + geometryMinusStairsSteps, isStair, stairRenderGeometry, } from '../test-build/stairs.js'; @@ -128,6 +129,31 @@ test('#663 area removes only stair overlap and never becomes negative', () => { assert.equal(geometryAreaMinusStairs(source, [straight({ length: 2, width: 2 })]), 0); }); +test('#663 dense stair subtraction yields between bounded polygon batches', () => { + const source = [[[[0, 0], [1000, 0], [1000, 1000], [0, 1000], [0, 0]]]]; + const stairs = Array.from({ length: 5 }, (_, index) => straight({ + id: `stair-${index}`, x: 0.15 + index * 0.16, length: 0.1, width: 0.1, + })); + const steps = geometryMinusStairsSteps(source, stairs, 1000, 2); + assert.equal(steps.next().done, false); + assert.equal(steps.next().done, false); + assert.equal(steps.next().done, false); + const finished = steps.next(); + assert.equal(finished.done, true); + assert.equal(geometryAreaMinusStairs(finished.value, []), 950_000); + + const denseSteps = geometryMinusStairsSteps(source, Array.from({ length: 49 }, (_, index) => ( + straight({ id: `dense-${index}` }) + ))); + let slices = 0; + let denseStep = denseSteps.next(); + while (!denseStep.done) { + slices += 1; + denseStep = denseSteps.next(); + } + assert.equal(slices, 3, 'the default keeps a 49-stair calculation out of one main-thread task'); +}); + test('#663 target states distinguish active, missing, self, deleted and fixed', () => { const spaces = new Set(['ground', 'upper']); assert.equal(stairTargetState(straight(), 'ground', spaces, false), 'active');