Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-10 16:34:57 +09:00
3 changed files with 200 additions and 10 deletions
@@ -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);
}
@@ -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 {
@@ -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<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);
});
});