perf: вычитать лестницы из площади пола только по запросу (#669)

Путь чистого пола строят четыре места рендера, а площадь читают только
подсказка комнаты и PDF. cleanFloorForRoom больше не вычитает лестницы при
построении пути: площадь считается при первом чтении и хранится в том же
закэшированном объекте. geometryMinusStairs передаёт в polyclip только
контуры лестниц, чей габарит пересекает вычитаемую геометрию, — результат
тот же, а комната на этаже с 250 лестницами больше не прогоняет все 250.

Issue: #669
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-09-27 09:48:02 +03:00
parent 2728a0ee54
commit 47c245b5ba
5 changed files with 195 additions and 5 deletions
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { cleanFloorForRoom } from '../test-build/clean-floor.js';
import { geometryAreaMinusStairs } from '../test-build/stairs.js';
// #669: the four render paths read only `path`; the room tooltip and the PDF
// read `area`. The stair subtraction behind `area` must wait for that read.
const floor = [[100, 100], [500, 100], [500, 500], [100, 500]];
const room = { id: 'room-a' };
const stair = {
id: 'stair-a', kind: 'straight', x: 0.3, y: 0.3, angle: 0,
direction: 'forward', length: 0.1, width: 0.05, target_space_id: 'upper',
};
const column = [[400, 400], [440, 400], [440, 440], [400, 440]];
const space = { id: 'space-a', rooms: [room], stairs: [stair] };
const counting = () => {
const calls = { count: 0 };
const areaMinusStairs = (source, stairs) => {
calls.count += 1;
return geometryAreaMinusStairs(source, stairs);
};
return { calls, areaMinusStairs };
};
const build = (overrides = {}) => cleanFloorForRoom({
room, floor, space, configEpoch: 3, resizePreview: false, cache: new Map(),
physicalBodies: () => [column], ...overrides,
});
test('#669 AC1 a floor path does not subtract stairs until the area is read', () => {
const { calls, areaMinusStairs } = counting();
const cache = new Map();
const result = build({ cache, areaMinusStairs });
assert.ok(result.path.length > 0, 'the path is built from the floor minus the column');
assert.equal(calls.count, 0, 'building the path must not run the stair subtraction');
const area = result.area;
assert.equal(calls.count, 1, 'the first read subtracts once');
assert.equal(result.area, area);
assert.equal(calls.count, 1, 'a second read reuses the value');
const again = build({ cache, areaMinusStairs });
assert.equal(again, result, 'the cached result is returned');
assert.equal(again.area, area);
assert.equal(calls.count, 1, 'a cache hit does not subtract again');
});
test('#669 AC1 the lazy area equals the eager subtraction and still removes stairs', () => {
const result = build();
const expected = geometryAreaMinusStairs(result.geom, space.stairs);
assert.ok(Math.abs(result.area - expected) <= expected * 1e-9);
const withoutStairs = build({ space: { ...space, stairs: [] } }).area;
assert.ok(result.area < withoutStairs - 1, 'the stair footprint is subtracted');
});
test('#669 AC1 the resize preview is not cached and stays lazy', () => {
const { calls, areaMinusStairs } = counting();
const cache = new Map();
const preview = build({ cache, resizePreview: true, areaMinusStairs });
assert.equal(cache.size, 0);
assert.equal(calls.count, 0);
assert.ok(preview.area > 0);
assert.equal(calls.count, 1);
});
test('#669 AC1 a room without independent bodies subtracts stairs from the plain floor', () => {
const { calls, areaMinusStairs } = counting();
const result = build({ physicalBodies: () => [], areaMinusStairs });
assert.equal(result.geom, null);
assert.equal(calls.count, 0);
const plain = geometryAreaMinusStairs([[[...floor, floor[0]]]], space.stairs);
assert.ok(Math.abs(result.area - plain) <= plain * 1e-9);
assert.equal(calls.count, 1);
});
+57
View File
@@ -8,8 +8,12 @@ import {
geometryAreaMinusStairs,
geometryMinusStairsSteps,
isStair,
stairFootprintGeometry,
stairFootprintsTouching,
stairRenderGeometry,
} from '../test-build/stairs.js';
import { geometryArea } from '../test-build/physical-geometry.js';
import { difference } from 'polyclip-ts';
import {
convertStairKind,
defaultStair,
@@ -207,3 +211,56 @@ test('#663 legacy-no-stairs-config never materializes an empty stair collection'
assert.equal(Object.hasOwn(result.config.spaces[0], 'stairs'), false);
assert.deepEqual(legacy, before, 'Optimize remains immutable for the caller');
});
// #669 AC2: the bounds filter must not change the subtraction it shortens.
const unfilteredArea = (source, stairs) => {
const footprints = stairs.map((stair) => stairFootprintGeometry(stair));
return Math.max(0, geometryArea(footprints.length ? difference(source, ...footprints) : source));
};
test('#669 AC2 stair area with the bounds filter equals the unfiltered subtraction', () => {
const room = [[[[200, 200], [600, 200], [600, 600], [200, 600], [200, 200]]]];
const stairs = [
straight({ id: 'inside', x: 0.3, y: 0.3, length: 0.1, width: 0.05 }),
straight({ id: 'rotated', x: 0.45, y: 0.45, angle: 45, length: 0.12, width: 0.04 }),
straight({ id: 'edge', x: 0.6, y: 0.4, angle: 90, length: 0.1, width: 0.05 }),
straight({ id: 'corner', x: 0.2, y: 0.2, angle: 135, length: 0.08, width: 0.05 }),
spiral({ id: 'spiral-inside', x: 0.5, y: 0.3, radius: 0.04 }),
spiral({ id: 'spiral-edge', x: 0.4, y: 0.6, radius: 0.05 }),
straight({ id: 'overlap-a', x: 0.35, y: 0.5, length: 0.1, width: 0.06 }),
straight({ id: 'overlap-b', x: 0.36, y: 0.52, angle: 90, length: 0.1, width: 0.06 }),
straight({ id: 'far-a', x: 0.9, y: 0.9, length: 0.1, width: 0.05 }),
spiral({ id: 'far-b', x: 0.05, y: 0.9, radius: 0.03 }),
];
const expected = unfilteredArea(room, stairs);
const actual = geometryAreaMinusStairs(room, stairs);
assert.ok(Math.abs(actual - expected) <= expected * 1e-9, `${actual} vs ${expected}`);
assert.ok(actual < 160_000, 'the touching stairs are subtracted');
});
test('#669 AC2 stairs whose bounds miss the room never reach polyclip', () => {
const room = [[[[200, 200], [600, 200], [600, 600], [200, 600], [200, 200]]]];
const stairs = [
straight({ id: 'inside', x: 0.3, y: 0.3, length: 0.1, width: 0.05 }),
spiral({ id: 'edge', x: 0.4, y: 0.6, radius: 0.05 }),
straight({ id: 'far-a', x: 0.9, y: 0.9, length: 0.1, width: 0.05 }),
spiral({ id: 'far-b', x: 0.05, y: 0.9, radius: 0.03 }),
];
const touching = stairFootprintsTouching(room, stairs);
assert.equal(touching.length, 2, 'only the inside and the edge stair are passed on');
assert.deepEqual(stairFootprintsTouching([], stairs), [], 'an empty subject touches nothing');
});
test('#669 AC2 a maximum stair collection keeps the room area and passes a bounded subset', () => {
const stairs = Array.from({ length: 250 }, (_, index) => {
const common = { id: `grid-${index}`, x: 0.025 + (index % 25) * 0.039, y: 0.03 + Math.floor(index / 25) * 0.1,
angle: (index % 8) * 45 };
return index % 2 ? spiral({ ...common, radius: 0.05 }) : straight({ ...common, length: 0.12, width: 0.045 });
});
const room = [[[[100, 100], [300, 100], [300, 300], [100, 300], [100, 100]]]];
const touching = stairFootprintsTouching(room, stairs);
assert.ok(touching.length > 0 && touching.length < 50, `${touching.length} of 250 touch the room bounds`);
const expected = unfilteredArea(room, stairs);
const actual = geometryAreaMinusStairs(room, stairs);
assert.ok(Math.abs(actual - expected) <= Math.max(expected, 1) * 1e-9, `${actual} vs ${expected}`);
});