From 47c245b5ba34e9b2b8480fb205e400d4e0b0b976 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 09:48:02 +0300 Subject: [PATCH] =?UTF-8?q?perf:=20=D0=B2=D1=8B=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BB=D0=B5=D1=81=D1=82=D0=BD=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=B0=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=83=20?= =?UTF-8?q?(#669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Путь чистого пола строят четыре места рендера, а площадь читают только подсказка комнаты и PDF. cleanFloorForRoom больше не вычитает лестницы при построении пути: площадь считается при первом чтении и хранится в том же закэшированном объекте. geometryMinusStairs передаёт в polyclip только контуры лестниц, чей габарит пересекает вычитаемую геометрию, — результат тот же, а комната на этаже с 250 лестницами больше не прогоняет все 250. Issue: #669 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- src/clean-floor.ts | 21 +++++++++-- src/stairs.ts | 44 ++++++++++++++++++++++- test/clean-floor.test.mjs | 76 +++++++++++++++++++++++++++++++++++++++ test/stairs.test.mjs | 57 +++++++++++++++++++++++++++++ tsconfig.test.json | 2 +- 5 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 test/clean-floor.test.mjs diff --git a/src/clean-floor.ts b/src/clean-floor.ts index 26304477..a7d0e595 100644 --- a/src/clean-floor.ts +++ b/src/clean-floor.ts @@ -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; 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; diff --git a/src/stairs.ts b/src/stairs.ts index 7f13f6e3..10b36eae 100644 --- a/src/stairs.ts +++ b/src/stairs.ts @@ -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 diff --git a/test/clean-floor.test.mjs b/test/clean-floor.test.mjs new file mode 100644 index 00000000..323f3d6a --- /dev/null +++ b/test/clean-floor.test.mjs @@ -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); +}); diff --git a/test/stairs.test.mjs b/test/stairs.test.mjs index 8d282236..4a6d119d 100644 --- a/test/stairs.test.mjs +++ b/test/stairs.test.mjs @@ -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}`); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index 2b3a16c8..b73ee198 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -24,7 +24,7 @@ "src/virtual-light-state.ts", "src/config-store.ts", "src/config-reload-authority.ts", "src/config-write-conflict.ts", "src/summary-panel.ts", "src/summary-panel-metrics.ts", "src/summary-panel-i18n.ts", "src/summary-panel-identity.ts", "src/summary-panel-picker.ts", "src/summary-panel-runtime-loaded.ts", "src/header-menu.ts", "src/iso-materials.ts", "src/iso-first-frame.ts", "src/iso-tiles.ts", "src/iso-sun.ts", "src/backdrop-probe.ts", "src/types.ts", "src/canvas-constants.ts", "src/editors/dialog-baseline.ts", "src/editors/color-tile-ink.ts", "src/editors/general-form-state.ts", "src/editors/space-form-state.ts", "src/editors/marker-form-state.ts", "src/editors/room-form-state.ts", - "src/space-geometry.ts", "src/stairs.ts", "src/stairs-editor-model.ts", "src/junction-limits.ts", "src/room-gear-drag.ts", + "src/space-geometry.ts", "src/stairs.ts", "src/clean-floor.ts", "src/stairs-editor-model.ts", "src/junction-limits.ts", "src/room-gear-drag.ts", "src/space-order.ts", "src/card-editor-validation.ts", "src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts", "src/visual-continuity.ts", "src/version-recovery.ts", "src/version-recovery-card.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/boot-soft-layout.ts", "src/room-fit.ts", "src/editor-runtime-loader.ts", "src/editor-secondary.ts", "src/pointer-modality.ts", "src/touch-gesture-click-guard.ts",