auto: 2026-07-26 20:54 (ESD_LAPTOP)

This commit is contained in:
2026-07-26 20:54:20 +09:00
parent 99babfacaf
commit eb5bdfebad
7 changed files with 316 additions and 14 deletions
@@ -27,12 +27,26 @@ import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController';
*
* To convert between the 2 coordinate systems, you need the screenOffset and screenScale
*/
// Batch-mode stroke decimation: skip chain segments shorter than this (screen px)
const BATCH_LOD_PX = 0.5;
// Batch-mode text smaller than this (screen px) is unreadable — skip drawing it
const BATCH_MIN_TEXT_PX = 2;
export class ScreenCanvasDrawController implements DrawController {
private screenOffset: Point = new Point(0, 0);
private screenScale = 1;
private screenMouseLocation: Point;
private canvasSize: Point = new Point(100, 100);
// Style-run batching (static scene rendering): consecutive stroke calls
// with the same style are collected into one Path2D and stroked once.
private batching = false;
private batchPath: Path2D | null = null;
private batchKey: string | null = null;
private batchStyle: { color: string; lineWidth: number; dash: number[] } | null = null;
private batchLastX = Number.NaN;
private batchLastY = Number.NaN;
constructor(private context: CanvasRenderingContext2D) {
this.screenMouseLocation = new Point(this.canvasSize.x / 2, this.canvasSize.y / 2);
this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down
@@ -221,6 +235,18 @@ export class ScreenCanvasDrawController implements DrawController {
lineWidth: number,
dash: number[] = []
) {
if (this.batching) {
const effectiveWidth = isHighlighted ? lineWidth + 1 : lineWidth;
const effectiveDash = isSelected ? [5, 5] : dash;
const key = `${color}|${effectiveWidth}|${effectiveDash.join(',')}`;
if (key !== this.batchKey) {
this.flushBatch();
this.batchKey = key;
this.batchStyle = { color, lineWidth: effectiveWidth, dash: effectiveDash };
}
return;
}
this.context.strokeStyle = color;
this.context.lineWidth = lineWidth;
this.context.setLineDash(dash);
@@ -234,6 +260,43 @@ export class ScreenCanvasDrawController implements DrawController {
}
}
/**
* Start style-run batching: consecutive stroke calls sharing a style are
* accumulated into a single Path2D and stroked once (with sub-pixel
* segment decimation). Used while rendering the static scene cache.
*/
public beginBatch() {
this.flushBatch();
this.batching = true;
this.batchKey = null;
this.batchStyle = null;
}
public endBatch() {
this.flushBatch();
this.batching = false;
this.batchKey = null;
this.batchStyle = null;
}
private flushBatch() {
if (this.batchPath && this.batchStyle) {
this.context.strokeStyle = this.batchStyle.color;
this.context.lineWidth = this.batchStyle.lineWidth;
this.context.setLineDash(this.batchStyle.dash);
// Round caps/joins replace the per-segment endpoint dots drawn in
// the unbatched path (see _drawRoundedEndpoint)
this.context.lineCap = 'round';
this.context.lineJoin = 'round';
this.context.stroke(this.batchPath);
this.context.lineCap = 'butt';
this.context.lineJoin = 'miter';
}
this.batchPath = null;
this.batchLastX = Number.NaN;
this.batchLastY = Number.NaN;
}
public setFillStyles(fillColor: string) {
this.context.fillStyle = fillColor;
}
@@ -265,6 +328,7 @@ export class ScreenCanvasDrawController implements DrawController {
if (this.canvasSize === null) return;
if (!this.context) return;
if (this.batching) this.flushBatch();
this.context.fillStyle = CANVAS_BACKGROUND_COLOR;
this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y);
@@ -305,6 +369,33 @@ export class ScreenCanvasDrawController implements DrawController {
* @param screenEndPoint
*/
public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {
if (this.batching) {
const startX = screenStartPoint.x;
const startY = this.canvasSize.y - screenStartPoint.y;
const endX = screenEndPoint.x;
const endY = this.canvasSize.y - screenEndPoint.y;
if (!this.batchPath) this.batchPath = new Path2D();
// Chain break: start is not where the previous segment ended
const chainBroken =
Math.abs(startX - this.batchLastX) > BATCH_LOD_PX ||
Math.abs(startY - this.batchLastY) > BATCH_LOD_PX;
if (chainBroken || Number.isNaN(this.batchLastX)) {
this.batchPath.moveTo(startX, startY);
this.batchLastX = startX;
this.batchLastY = startY;
}
const isTinyStep =
Math.abs(endX - this.batchLastX) < BATCH_LOD_PX &&
Math.abs(endY - this.batchLastY) < BATCH_LOD_PX;
// Decimate sub-pixel steps inside a chain; isolated segments always draw
if (!isTinyStep || chainBroken) {
this.batchPath.lineTo(endX, endY);
this.batchLastX = endX;
this.batchLastY = endY;
}
return;
}
this.context.beginPath();
this.context.moveTo(screenStartPoint.x, this.canvasSize.y - screenStartPoint.y);
this.context.lineTo(screenEndPoint.x, this.canvasSize.y - screenEndPoint.y);
@@ -357,6 +448,22 @@ export class ScreenCanvasDrawController implements DrawController {
endAngle: number,
counterClockWise: boolean
) {
if (this.batching) {
if (screenRadius < BATCH_LOD_PX) return; // invisible at this zoom
if (!this.batchPath) this.batchPath = new Path2D();
const centerX = screenCenterPoint.x;
const centerY = this.canvasSize.y - screenCenterPoint.y;
this.batchPath.moveTo(
centerX + screenRadius * Math.cos(startAngle),
centerY + screenRadius * Math.sin(startAngle)
);
this.batchPath.arc(centerX, centerY, screenRadius, startAngle, endAngle, counterClockWise);
// Arc end becomes the new chain tail
this.batchLastX = centerX + screenRadius * Math.cos(endAngle);
this.batchLastY = centerY + screenRadius * Math.sin(endAngle);
return;
}
this.context.beginPath();
this.context.arc(
screenCenterPoint.x,
@@ -434,6 +541,10 @@ export class ScreenCanvasDrawController implements DrawController {
...DEFAULT_TEXT_OPTIONS,
...options,
};
if (this.batching) {
if (opts.fontSize < BATCH_MIN_TEXT_PX) return; // unreadable at this zoom
this.flushBatch(); // keep draw order: strokes so far go under this text
}
this.context.save();
this.context.translate(basePoint.x, this.canvasSize.y - basePoint.y);
const angle = getAngleWithXAxis(
@@ -466,6 +577,7 @@ export class ScreenCanvasDrawController implements DrawController {
height: number,
angle: number
): void {
if (this.batching) this.flushBatch();
const [screenBasePoint, screenDimensions] = this.worldsToTargets([
new Point(xMin, yMin),
new Point(width, height),
@@ -516,6 +628,7 @@ export class ScreenCanvasDrawController implements DrawController {
* @param color
*/
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {
if (this.batching) this.flushBatch();
// TODO see if we need to replace this with a call to fillPolygon
this.context.fillStyle = color;
this.context.fillRect(xMin, this.canvasSize.y - yMin, width, height);
@@ -526,6 +639,7 @@ export class ScreenCanvasDrawController implements DrawController {
* @param points
*/
public fillPolygon(...points: Point[]) {
if (this.batching) this.flushBatch();
const screenPoints = points.map(this.worldToTarget.bind(this));
this.context.beginPath();
screenPoints.forEach((screenPoint, index) => {
@@ -1,8 +1,8 @@
import {
getAngleGuideOriginPoint,
getAngleStep,
getEntities,
getHoveredSnapPoints,
getLayerById,
getScreenCanvasDrawController,
getShouldDrawHelpers,
setAngleGuideEntities,
@@ -11,6 +11,7 @@ import {
} from '../state.ts';
import { HOVERED_SNAP_POINT_TIME, SNAP_POINT_DISTANCE } from '../App.consts.ts';
import { getDrawHelpers } from './get-draw-guides.ts';
import { queryEntitiesNearPoint } from './spatial-index.ts';
import { compact } from 'es-toolkit';
/**
@@ -19,9 +20,16 @@ import { compact } from 'es-toolkit';
export function calculateAngleGuidesAndSnapPoints() {
const angleStep = getAngleStep();
const screenCanvasDrawController = getScreenCanvasDrawController();
const entities = getEntities();
const screenScale = screenCanvasDrawController.getScreenScale();
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
// 스냅 후보: 공간 인덱스로 마우스 주변만 조회 (전 엔티티 O(n²) 교차 계산 제거),
// 잠금 레이어(b07-frame 등 참조용)는 스냅 대상에서 제외한다.
const maxSnapDistance = SNAP_POINT_DISTANCE / screenScale;
const entities = queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
maxSnapDistance * 2,
).filter(entity => !getLayerById(entity.layerId)?.isLocked);
const hoveredSnapPoints = getHoveredSnapPoints();
const eligibleHoveredSnapPoints = hoveredSnapPoints.filter(
@@ -39,7 +47,7 @@ export function calculateAngleGuidesAndSnapPoints() {
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
worldMouseLocation,
angleStep,
SNAP_POINT_DISTANCE / screenScale,
maxSnapDistance,
);
setAngleGuideEntities(angleGuides);
setSnapPoint(entitySnapPoint);
@@ -4,12 +4,12 @@ import {type SnapPoint, SnapPointType} from '../App.types';
import type {DrawController} from '../drawControllers/DrawController';
import type {ScreenCanvasDrawController} from '../drawControllers/screenCanvas.drawController';
import type {Entity} from '../entities/Entity';
import {getLayers, isEntityHighlighted, isEntitySelected} from '../state';
import {getLayerById, isEntityHighlighted, isEntitySelected} from '../state';
import {toast} from 'react-toastify';
export function drawEntities(drawController: DrawController, entities: Entity[]) {
for (const entity of entities) {
const layer = getLayers().find((layer) => layer.id === entity.layerId);
const layer = getLayerById(entity.layerId);
if (!layer) {
toast.error(`Failed to find layer for entity: ${entity?.id}`);
console.error('Failed to find layer for entity: ', entity);
@@ -1,12 +1,12 @@
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import {
getEntities,
getGridEnabled,
getHighlightedEntityIds,
setHighlightedEntityIds,
} from '../state';
import { drawEntities } from './draw-functions';
import { getSceneVersion } from './scene-version';
import { queryEntitiesInBox } from './spatial-index';
/**
* Static scene cache: all entities are rendered once into an offscreen canvas.
@@ -46,6 +46,25 @@ export function invalidateSceneCache(): void {
rendered = null;
}
/**
* Viewport culling: only entities whose bbox intersects the current view
* (expanded by one viewport on each side, so short pans stay covered by the
* blit before the settle-rebuild) are rendered into the scene cache.
*/
function sceneEntitiesForViewport(drawController: ScreenCanvasDrawController) {
const size = drawController.getCanvasSize();
const scale = drawController.getScreenScale();
const offset = drawController.getScreenOffset();
const viewWidth = size.x / scale;
const viewHeight = size.y / scale;
return queryEntitiesInBox(
offset.x - viewWidth,
offset.y - viewHeight,
offset.x + 2 * viewWidth,
offset.y + 2 * viewHeight
);
}
function rebuildScene(drawController: ScreenCanvasDrawController): void {
const size = drawController.getCanvasSize();
if (!offscreenCanvas) {
@@ -64,7 +83,10 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void {
if (savedHighlight.length) setHighlightedEntityIds([]);
drawController.withContext(offscreenContext, () => {
drawController.clear();
drawEntities(drawController, getEntities());
// Style-run batching + sub-pixel decimation: one stroke per style run
drawController.beginBatch();
drawEntities(drawController, sceneEntitiesForViewport(drawController));
drawController.endBatch();
});
if (savedHighlight.length) setHighlightedEntityIds(savedHighlight);
@@ -0,0 +1,140 @@
import type { Entity } from '../entities/Entity';
import { getEntities } from '../state';
import { getSceneVersion } from './scene-version';
/**
* Uniform-grid spatial index over top-level entity bounding boxes.
* Rebuilt lazily whenever the scene version changes (entity edits bump it).
* Queries return entities in original array order so z-order is preserved.
*/
const GRID_CELLS_PER_AXIS = 64;
interface IndexedEntity {
entity: Entity;
minX: number;
minY: number;
maxX: number;
maxY: number;
}
let indexVersion = -1;
let indexedEntities: IndexedEntity[] = [];
let unindexedEntities: Entity[] = []; // bbox unavailable — always included in results
let cells: Map<number, number[]> = new Map();
let cellSize = 1;
let gridMinX = 0;
let gridMinY = 0;
let gridCols = 1;
function cellRange(min: number, max: number, gridMin: number): [number, number] {
return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)];
}
function ensureIndex(): void {
const version = getSceneVersion();
if (version === indexVersion) return;
indexedEntities = [];
unindexedEntities = [];
cells = new Map();
const entities = getEntities();
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const entity of entities) {
let box: { xmin: number; ymin: number; xmax: number; ymax: number } | null = null;
try {
box = entity.getBoundingBox();
} catch {
box = null;
}
if (
!box ||
!Number.isFinite(box.xmin) ||
!Number.isFinite(box.ymin) ||
!Number.isFinite(box.xmax) ||
!Number.isFinite(box.ymax)
) {
unindexedEntities.push(entity);
continue;
}
indexedEntities.push({
entity,
minX: box.xmin,
minY: box.ymin,
maxX: box.xmax,
maxY: box.ymax,
});
if (box.xmin < minX) minX = box.xmin;
if (box.ymin < minY) minY = box.ymin;
if (box.xmax > maxX) maxX = box.xmax;
if (box.ymax > maxY) maxY = box.ymax;
}
if (indexedEntities.length) {
const extent = Math.max(maxX - minX, maxY - minY, 1e-9);
cellSize = extent / GRID_CELLS_PER_AXIS;
gridMinX = minX;
gridMinY = minY;
gridCols = GRID_CELLS_PER_AXIS + 2;
indexedEntities.forEach((item, index) => {
const [cx0, cx1] = cellRange(item.minX, item.maxX, gridMinX);
const [cy0, cy1] = cellRange(item.minY, item.maxY, gridMinY);
for (let cy = cy0; cy <= cy1; cy++) {
for (let cx = cx0; cx <= cx1; cx++) {
const key = cy * gridCols + cx;
const bucket = cells.get(key);
if (bucket) bucket.push(index);
else cells.set(key, [index]);
}
}
});
}
indexVersion = version;
}
/** 뷰포트/사각 영역과 bbox가 겹치는 엔티티 (원본 배열 순서 유지). */
export function queryEntitiesInBox(
minX: number,
minY: number,
maxX: number,
maxY: number
): Entity[] {
ensureIndex();
if (!indexedEntities.length) return [...unindexedEntities];
const seen = new Set<number>();
const [cx0, cx1] = cellRange(minX, maxX, gridMinX);
const [cy0, cy1] = cellRange(minY, maxY, gridMinY);
for (let cy = cy0; cy <= cy1; cy++) {
for (let cx = cx0; cx <= cx1; cx++) {
const bucket = cells.get(cy * gridCols + cx);
if (!bucket) continue;
for (const index of bucket) seen.add(index);
}
}
const result: Entity[] = [];
let unindexedCursor = 0;
for (let index = 0; index < indexedEntities.length; index++) {
if (!seen.has(index)) continue;
const item = indexedEntities[index];
if (item.maxX < minX || item.minX > maxX || item.maxY < minY || item.minY > maxY) continue;
result.push(item.entity);
}
// bbox 불명 엔티티는 항상 포함 (뒤에 붙여도 소수라 시각 영향 없음)
for (; unindexedCursor < unindexedEntities.length; unindexedCursor++) {
result.push(unindexedEntities[unindexedCursor]);
}
return result;
}
/** 점 주변 반경 후보 엔티티 (스냅·호버용). */
export function queryEntitiesNearPoint(x: number, y: number, radius: number): Entity[] {
return queryEntitiesInBox(x - radius, y - radius, x + radius, y + radius);
}
+9 -3
View File
@@ -8,6 +8,7 @@ import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawC
import { draw } from './helpers/draw';
import { findClosestEntity } from './helpers/find-closest-entity';
import { scenePerf } from './helpers/scene-cache';
import { queryEntitiesNearPoint } from './helpers/spatial-index';
import { getNewLayer } from './helpers/get-new-layer.ts';
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
import { InputController } from './inputController/input-controller.ts';
@@ -15,7 +16,6 @@ import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.t
import {
getActiveToolActor,
getCanvas,
getEntities,
getHoveredSnapPoints,
getLastDrawTimestamp,
getScreenCanvasDrawController,
@@ -85,9 +85,15 @@ function startDrawLoop(
lastHoverCheckAt = timestamp;
lastHoverMouseX = mouseLocation.x;
lastHoverMouseY = mouseLocation.y;
const worldMouseLocation = screenCanvasDrawController.getWorldMouseLocation();
const { distance, entity: closestEntity } = findClosestEntity(
screenCanvasDrawController.getWorldMouseLocation(),
getEntities()
worldMouseLocation,
// 공간 인덱스로 후보를 좁혀 O(전체) 스캔 제거
queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
HIGHLIGHT_ENTITY_DISTANCE
)
);
if (distance < HIGHLIGHT_ENTITY_DISTANCE) {
+16 -4
View File
@@ -42,11 +42,13 @@ let entities: Entity[] = [];
* Entities that are highlighted: when the mouse is close to an entity
*/
let highlightedEntityIds: string[] = [];
let highlightedEntityIdSet: Set<string> = new Set();
/**
* Entities that are selected by the user by clicking on them with the select tool or by selecting them with a selection rectangle
*/
let selectedEntityIds: string[] = [];
let selectedEntityIdSet: Set<string> = new Set();
/**
* Whether to draw the cursor or not
@@ -163,6 +165,12 @@ let layers: Layer[] = [
*/
let activeLayerId: string = layers[0].id;
/**
* layerId → Layer lookup, kept in sync with `layers` (drawEntities runs this
* lookup once per entity per frame — a linear find() was a hot spot)
*/
let layersById: Map<string, Layer> = new Map(layers.map((layer) => [layer.id, layer]));
let snapEnabled = true;
let gridEnabled = false;
@@ -208,17 +216,18 @@ export const getInputController = (): InputController => {
};
export const getSelectedEntities = (): Entity[] => {
return entities.filter((e) => selectedEntityIds.includes(e.id));
return entities.filter((e) => selectedEntityIdSet.has(e.id));
};
export const getNotSelectedEntities = (): Entity[] => {
return entities.filter((e) => !selectedEntityIds.includes(e.id));
return entities.filter((e) => !selectedEntityIdSet.has(e.id));
};
export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id);
export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id);
export const isEntitySelected = (entity: Entity) => selectedEntityIdSet.has(entity.id);
export const isEntityHighlighted = (entity: Entity) => highlightedEntityIdSet.has(entity.id);
export const getHighlightedEntityIds = () => highlightedEntityIds;
export const getLayers = () => {
return layers;
};
export const getLayerById = (layerId: string): Layer | undefined => layersById.get(layerId);
export const getActiveLayerId = (): string => {
return activeLayerId;
};
@@ -288,9 +297,11 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) =>
};
export const setHighlightedEntityIds = (newEntityIds: string[]) => {
highlightedEntityIds = newEntityIds;
highlightedEntityIdSet = new Set(newEntityIds);
};
export const setSelectedEntityIds = (newEntityIds: string[]) => {
selectedEntityIds = newEntityIds;
selectedEntityIdSet = new Set(newEntityIds);
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
};
@@ -378,6 +389,7 @@ export const setActiveTextStyle = (
};
export const setLayers = (newLayers: Layer[], triggerReact = true) => {
layers = newLayers;
layersById = new Map(newLayers.map((layer) => [layer.id, layer]));
bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows
if (triggerReact) {