- 까닭: 화면 상자 조회가 엔티티 칸(64x64) 밖 빈 칸까지 돎 — 0.01배에서 한 화면 19.3 초 - 칸 범위를 0~64 로 잘라 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms - 모눈은 화면 clear() 만 그림 — 모눈 켜도 정지 화면에서 장면 다시 그리지 않음 - 측정 · 회귀 시험 zoom-out-perf.test.ts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
import { Point } from '@flatten-js/core';
|
|
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
/** 줌아웃 느려짐(227-1) — 가짜 캔버스로 한 화면 그리기 비용을 잰다. */
|
|
const counts = vi.hoisted(() => {
|
|
const tally = { segments: 0, arcs: 0, strokes: 0, fills: 0, texts: 0 };
|
|
const g = globalThis as Record<string, unknown>;
|
|
g.window = { dispatchEvent: () => true, matchMedia: () => ({ matches: true }) };
|
|
g.Path2D = class {
|
|
moveTo() {}
|
|
lineTo() {
|
|
tally.segments++;
|
|
}
|
|
arc() {
|
|
tally.arcs++;
|
|
}
|
|
};
|
|
const noop = () => {};
|
|
g.__fakeContext = {
|
|
fillRect: noop,
|
|
clearRect: noop,
|
|
setLineDash: noop,
|
|
beginPath: noop,
|
|
moveTo: noop,
|
|
lineTo: () => tally.segments++,
|
|
arc: () => tally.arcs++,
|
|
closePath: noop,
|
|
stroke: () => tally.strokes++,
|
|
fill: () => tally.fills++,
|
|
save: noop,
|
|
restore: noop,
|
|
translate: noop,
|
|
rotate: noop,
|
|
fillText: () => tally.texts++,
|
|
measureText: (label: string) => ({ width: label.length * 7 }),
|
|
drawImage: noop,
|
|
};
|
|
return tally;
|
|
});
|
|
|
|
import type { Layer } from '../App.types';
|
|
import { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
|
import type { Entity } from '../entities/Entity';
|
|
import { HatchEntity } from '../entities/HatchEntity';
|
|
import { LineEntity } from '../entities/LineEntity';
|
|
import { MeasurementEntity } from '../entities/MeasurementEntity';
|
|
import { PolyLineEntity } from '../entities/PolyLineEntity';
|
|
import { TextEntity } from '../entities/TextEntity';
|
|
import { setEntities, setGridEnabled, setLayers } from '../state';
|
|
import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache';
|
|
import { queryEntitiesInBox } from './spatial-index';
|
|
|
|
const LAYER = 'perf';
|
|
|
|
function polyline(points: Point[]): PolyLineEntity {
|
|
const segments = points
|
|
.slice(1)
|
|
.map((point, index) => new LineEntity(LAYER, points[index], point));
|
|
return new PolyLineEntity(LAYER, segments);
|
|
}
|
|
|
|
/** B07 횡단 장 꼴 — 횡단 40 개(지반선 · 계획선 · 절토 · 성토 해치 · 글자 · 치수 · 모눈) + 등고선 */
|
|
function crossSectionSheet(): Entity[] {
|
|
const entities: Entity[] = [];
|
|
for (let index = 0; index < 40; index++) {
|
|
const ox = (index % 4) * 80;
|
|
const oy = Math.floor(index / 4) * 30;
|
|
const ground = Array.from(
|
|
{ length: 61 },
|
|
(_, k) => new Point(ox + k, oy + 10 + 3 * Math.sin(k / 7 + index))
|
|
);
|
|
entities.push(polyline(ground));
|
|
const design = [0, 15, 20, 25, 40, 45, 60].map(
|
|
(x, k) => new Point(ox + x, oy + 9 + (k % 2 ? 1 : 0))
|
|
);
|
|
entities.push(polyline(design));
|
|
const hatchOptions = { style: 'pattern', spacing: 0.3 } as const;
|
|
entities.push(
|
|
new HatchEntity(
|
|
LAYER,
|
|
[new Point(ox + 15, oy + 9), new Point(ox + 25, oy + 9), new Point(ox + 20, oy + 13)],
|
|
hatchOptions
|
|
),
|
|
new HatchEntity(
|
|
LAYER,
|
|
[new Point(ox + 40, oy + 9), new Point(ox + 60, oy + 9), new Point(ox + 50, oy + 6)],
|
|
hatchOptions
|
|
)
|
|
);
|
|
for (let k = 0; k < 25; k++) {
|
|
entities.push(
|
|
new TextEntity(LAYER, `${index}+${k}.00`, new Point(ox + 2 * k, oy + 2), { fontSize: 0.5 })
|
|
);
|
|
}
|
|
for (let k = 0; k < 6; k++) {
|
|
entities.push(
|
|
new MeasurementEntity(
|
|
LAYER,
|
|
new Point(ox + 10 * k, oy + 8),
|
|
new Point(ox + 10 * k + 8, oy + 8),
|
|
new Point(ox + 10 * k, oy + 7)
|
|
)
|
|
);
|
|
}
|
|
for (let k = 0; k <= 12; k++) {
|
|
entities.push(
|
|
new LineEntity(LAYER, new Point(ox + 5 * k, oy), new Point(ox + 5 * k, oy + 20))
|
|
);
|
|
}
|
|
}
|
|
for (let c = 0; c < 178; c++) {
|
|
const contour = Array.from(
|
|
{ length: 85 },
|
|
(_, k) => new Point(k * 3.7, c * 1.7 + 2 * Math.sin(k / 5 + c))
|
|
);
|
|
entities.push(polyline(contour));
|
|
}
|
|
return entities;
|
|
}
|
|
|
|
function viewAt(controller: ScreenCanvasDrawController, factor: number, fitScale: number) {
|
|
const size = controller.getCanvasSize();
|
|
const center = controller.targetToWorld(new Point(size.x / 2, size.y / 2));
|
|
const scale = fitScale * factor;
|
|
controller.setScreenScale(scale);
|
|
controller.setScreenOffset(
|
|
new Point(center.x - size.x / 2 / scale, center.y - size.y / 2 / scale)
|
|
);
|
|
}
|
|
|
|
describe('줌아웃 그리기 비용(227-1)', () => {
|
|
const controller = new ScreenCanvasDrawController(
|
|
(globalThis as unknown as { __fakeContext: CanvasRenderingContext2D }).__fakeContext
|
|
);
|
|
let fitScale = 1;
|
|
|
|
beforeAll(() => {
|
|
const g = globalThis as Record<string, unknown>;
|
|
g.document = {
|
|
documentElement: { hasAttribute: () => false, getAttribute: () => null },
|
|
createElement: () => ({ width: 0, height: 0, getContext: () => g.__fakeContext }),
|
|
};
|
|
g.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
|
setLayers(
|
|
[{ id: LAYER, name: LAYER, isVisible: true, isLocked: false } as unknown as Layer],
|
|
false
|
|
);
|
|
setEntities(crossSectionSheet(), false);
|
|
controller.setCanvasSize(new Point(1600, 900));
|
|
controller.zoomToFitScreen();
|
|
fitScale = controller.getScreenScale();
|
|
});
|
|
|
|
it('배율별 한 화면 다시 그리기 — 시간 · 그린 수(보고용 표)', () => {
|
|
const rows = [];
|
|
for (const factor of [10, 1, 0.1, 0.01]) {
|
|
viewAt(controller, factor, fitScale);
|
|
for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0;
|
|
invalidateSceneCache();
|
|
const startedAt = performance.now();
|
|
drawScene(controller, 0);
|
|
const ms = performance.now() - startedAt;
|
|
rows.push({ factor, ms: Number(ms.toFixed(1)), ...counts });
|
|
}
|
|
console.table(rows);
|
|
expect(rows).toHaveLength(4);
|
|
});
|
|
|
|
it('화면보다 아주 큰 영역 조회도 빈 칸을 돌지 않음 — 0.001배 상자 50ms 안', () => {
|
|
viewAt(controller, 1, fitScale);
|
|
const all = queryEntitiesInBox(-1e9, -1e9, 1e9, 1e9).length;
|
|
const size = controller.getCanvasSize();
|
|
const half = (Math.max(size.x, size.y) / (fitScale * 0.001)) * 1.5;
|
|
const startedAt = performance.now();
|
|
const found = queryEntitiesInBox(-half, -half, half, half);
|
|
const ms = performance.now() - startedAt;
|
|
expect(found.length).toBe(all);
|
|
expect(ms).toBeLessThan(50);
|
|
});
|
|
|
|
it('모눈을 켜도 화면이 그대로면 장면을 다시 그리지 않음', () => {
|
|
viewAt(controller, 1, fitScale);
|
|
setGridEnabled(true);
|
|
drawScene(controller, 0);
|
|
const rebuilds = scenePerf.sceneRebuilds;
|
|
drawScene(controller, 1000);
|
|
drawScene(controller, 2000);
|
|
setGridEnabled(false);
|
|
expect(scenePerf.sceneRebuilds).toBe(rebuilds);
|
|
});
|
|
});
|