diff --git a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts index e985ea346..cb1b0aea8 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -1,5 +1,5 @@ import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; -import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state'; +import { getHighlightedEntityIds, setHighlightedEntityIds } from '../state'; import { drawEntities } from './draw-functions'; import { getSceneVersion } from './scene-version'; import { queryEntitiesInBox } from './spatial-index'; @@ -78,7 +78,8 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void { const savedHighlight = getHighlightedEntityIds(); if (savedHighlight.length) setHighlightedEntityIds([]); drawController.withContext(offscreenContext, () => { - drawController.clear(); + // 투명 바탕 — 배경 · 모눈은 화면 clear() 가 그림(모눈이 비트맵에 박히면 매 프레임 다시 그려야 함) + offscreenContext.clearRect(0, 0, size.x, size.y); // Style-run batching + sub-pixel decimation: one stroke per style run drawController.beginBatch(); drawEntities(drawController, sceneEntitiesForViewport(drawController)); @@ -124,13 +125,7 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe const offsetChanged = !!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y); - // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted - // bitmap would drag the grid along — always re-render while grid is on. - if ( - paramsChanged || - getGridEnabled() || - (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS) - ) { + if (paramsChanged || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { rebuildScene(drawController); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts index 2c9c84532..970bf0660 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts @@ -26,8 +26,12 @@ let gridMinX = 0; let gridMinY = 0; let gridCols = 1; +/** 칸 번호 범위 — 엔티티가 놓인 칸(0~GRID_CELLS_PER_AXIS) 밖은 비었으니 잘라 냄(줌아웃 때 빈 칸 수억 개를 돌지 않게). */ function cellRange(min: number, max: number, gridMin: number): [number, number] { - return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; + return [ + Math.max(0, Math.floor((min - gridMin) / cellSize)), + Math.min(GRID_CELLS_PER_AXIS, Math.floor((max - gridMin) / cellSize)), + ]; } function ensureIndex(): void { diff --git a/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts new file mode 100644 index 000000000..754eae362 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts @@ -0,0 +1,191 @@ +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; + 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; + 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); + }); +});