import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; import { 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. * While panning, the cached bitmap is blitted at a pixel offset instead of * re-stroking every entity each frame. The cache is rebuilt when the scene * version bumps (entities/layers/selection changed), when zoom or canvas size * changes, or after the pan offset has settled for PAN_SETTLE_MS. * * Highlight is intentionally NOT baked into the cache (it changes on every * mouse move) — draw.ts re-draws highlighted entities on top each frame. */ const PAN_SETTLE_MS = 120; interface RenderedParams { version: number; scale: number; offsetX: number; offsetY: number; sizeX: number; sizeY: number; } let offscreenCanvas: HTMLCanvasElement | null = null; let rendered: RenderedParams | null = null; let prevOffsetX = Number.NaN; let prevOffsetY = Number.NaN; let lastOffsetChangeAt = 0; export const scenePerf = { sceneRebuilds: 0, lastRebuildMs: 0, avgFrameMs: 0, }; (window as unknown as Record).__aisloCadPerf = scenePerf; 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) { offscreenCanvas = document.createElement('canvas'); } if (offscreenCanvas.width !== size.x || offscreenCanvas.height !== size.y) { offscreenCanvas.width = Math.max(1, size.x); offscreenCanvas.height = Math.max(1, size.y); } const offscreenContext = offscreenCanvas.getContext('2d'); if (!offscreenContext) return; const startedAt = performance.now(); // Exclude the (rapidly changing) hover highlight from the baked bitmap. const savedHighlight = getHighlightedEntityIds(); if (savedHighlight.length) setHighlightedEntityIds([]); drawController.withContext(offscreenContext, () => { drawController.clear(); // Style-run batching + sub-pixel decimation: one stroke per style run drawController.beginBatch(); drawEntities(drawController, sceneEntitiesForViewport(drawController)); drawController.endBatch(); }); if (savedHighlight.length) setHighlightedEntityIds(savedHighlight); scenePerf.lastRebuildMs = performance.now() - startedAt; scenePerf.sceneRebuilds++; const offset = drawController.getScreenOffset(); rendered = { version: getSceneVersion(), scale: drawController.getScreenScale(), offsetX: offset.x, offsetY: offset.y, sizeX: size.x, sizeY: size.y, }; } /** * Draw the static scene: rebuild the cache when needed, otherwise blit the * cached bitmap (shifted by the pan delta). Called once per frame by draw(). */ export function drawScene(drawController: ScreenCanvasDrawController, now: number): void { const size = drawController.getCanvasSize(); const scale = drawController.getScreenScale(); const offset = drawController.getScreenOffset(); if (offset.x !== prevOffsetX || offset.y !== prevOffsetY) { lastOffsetChangeAt = now; prevOffsetX = offset.x; prevOffsetY = offset.y; } const paramsChanged = !rendered || rendered.version !== getSceneVersion() || rendered.scale !== scale || rendered.sizeX !== size.x || rendered.sizeY !== size.y; 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)) { rebuildScene(drawController); } if (!offscreenCanvas || !rendered) return; // screenX = (worldX - offsetX) * scale, canvasY is y-flipped afterwards: // content shifts left when offset.x grows, down when offset.y grows. const dx = (rendered.offsetX - offset.x) * scale; const dy = (offset.y - rendered.offsetY) * scale; drawController.blitImage(offscreenCanvas, dx, dy); }