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
+18 -3
View File
@@ -8,9 +8,12 @@ export type CleanFloorResult = {
floor: number[][];
geom: Geom | null;
path: string;
area: number;
/** Clean-floor area minus stairs, computed on the first read (#669). */
readonly area: number;
};
type StairAreaFn = (source: Geom, stairs: SpaceModel['stairs'] | undefined) => number;
export function cleanFloorForRoom(input: {
room: RoomCfg;
floor: number[][];
@@ -19,6 +22,8 @@ export function cleanFloorForRoom(input: {
resizePreview: boolean;
cache: Map<string, CleanFloorResult>;
physicalBodies(space: SpaceModel): number[][][];
/** Test seam (#669): the stair subtraction behind `area`. */
areaMinusStairs?: StairAreaFn;
}): CleanFloorResult {
const { room, floor, space } = input;
if (!space) return {
@@ -40,11 +45,21 @@ export function cleanFloorForRoom(input: {
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
});
const geom = candidates.length ? floorMinusBodies(floor, candidates) : null;
const result = {
const subject = (geom || [[[...floor, floor[0]]]]) as Geom;
const stairs = space.stairs;
const areaMinusStairs: StairAreaFn = input.areaMinusStairs ?? geometryAreaMinusStairs;
let area: number | undefined;
const result: CleanFloorResult = {
floor,
geom,
path: geom ? polyclipPathD(geom) : '',
area: geometryAreaMinusStairs(geom || [[[...floor, floor[0]]]], space.stairs),
// #669: four render paths read only `path`; the room tooltip and the PDF
// read the area. Subtracting up to 250 stair footprints per room belongs
// to that first read, not to every render of the floor.
get area() {
if (area === undefined) area = areaMinusStairs(subject, stairs);
return area;
},
};
if (!input.resizePreview) lruWrite(input.cache, key, result, 600);
return result;
+43 -1
View File
@@ -234,7 +234,7 @@ export function geometryAreaMinusStairs(
export function geometryMinusStairs(
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
): Geom {
const footprints = stairList(stairs).map((stair) => stairFootprintGeometry(stair, scale));
const footprints = stairFootprintsTouching(source, stairs, scale);
if (!footprints.length) return source;
try {
// A single sweep avoids repeatedly rebuilding the same subject for dense floors.
@@ -249,6 +249,48 @@ export function geometryMinusStairs(
}
}
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