auto: 2026-07-26 20:41 (ESD_LAPTOP)
This commit is contained in:
@@ -238,6 +238,29 @@ export class ScreenCanvasDrawController implements DrawController {
|
||||
this.context.fillStyle = fillColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily redirect all draw calls to another 2d context (eg an
|
||||
* offscreen canvas used as static scene cache), reusing the current
|
||||
* offset/scale/canvasSize without touching state or react triggers.
|
||||
*/
|
||||
public withContext(temporaryContext: CanvasRenderingContext2D, renderFunction: () => void) {
|
||||
const originalContext = this.context;
|
||||
this.context = temporaryContext;
|
||||
try {
|
||||
renderFunction();
|
||||
} finally {
|
||||
this.context = originalContext;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit a pre-rendered bitmap (static scene cache) onto the canvas at a
|
||||
* pixel offset. Used while panning to avoid re-stroking every entity.
|
||||
*/
|
||||
public blitImage(source: CanvasImageSource, dx: number, dy: number) {
|
||||
this.context.drawImage(source, dx, dy);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.canvasSize === null) return;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
getDebugEntities,
|
||||
getEntities,
|
||||
getGhostHelperEntities,
|
||||
getHighlightedEntityIds,
|
||||
getHoveredSnapPoints,
|
||||
getInputController,
|
||||
getShouldDrawCursor,
|
||||
@@ -21,13 +22,31 @@ import {
|
||||
getSnapPointOnAngleGuide,
|
||||
} from '../state';
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
import { drawScene } from './scene-cache';
|
||||
|
||||
/**
|
||||
* Hover highlight is excluded from the static scene cache (it changes every
|
||||
* mouse move) — re-draw the few highlighted entities on top of the blit.
|
||||
*/
|
||||
function drawHighlightedEntities(drawController: ScreenCanvasDrawController) {
|
||||
const highlightedIds = getHighlightedEntityIds();
|
||||
if (!highlightedIds.length) return;
|
||||
const idSet = new Set(highlightedIds);
|
||||
drawEntities(
|
||||
drawController,
|
||||
getEntities().filter(entity => idSet.has(entity.id)),
|
||||
);
|
||||
}
|
||||
|
||||
export function draw(drawController: ScreenCanvasDrawController) {
|
||||
drawController.clear();
|
||||
|
||||
// Static scene (all entities): cached bitmap blit, rebuilt only when needed.
|
||||
drawScene(drawController, performance.now());
|
||||
drawHighlightedEntities(drawController);
|
||||
|
||||
drawHelpers(drawController, getAngleGuideEntities());
|
||||
drawEntities(drawController, getGhostHelperEntities());
|
||||
drawEntities(drawController, getEntities());
|
||||
drawDebugEntities(drawController, getDebugEntities());
|
||||
|
||||
const { snapPoint: closestSnapPoint } = getClosestSnapPoint(
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
|
||||
import {
|
||||
getEntities,
|
||||
getGridEnabled,
|
||||
getHighlightedEntityIds,
|
||||
setHighlightedEntityIds,
|
||||
} from '../state';
|
||||
import { drawEntities } from './draw-functions';
|
||||
import { getSceneVersion } from './scene-version';
|
||||
|
||||
/**
|
||||
* 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<string, unknown>).__aisloCadPerf = scenePerf;
|
||||
|
||||
export function invalidateSceneCache(): void {
|
||||
rendered = null;
|
||||
}
|
||||
|
||||
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();
|
||||
drawEntities(drawController, getEntities());
|
||||
});
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Scene version counter — incremented whenever content that is baked into the
|
||||
* cached static scene bitmap changes (entities, layers, selection, grid).
|
||||
* Kept dependency-free so both state.ts and scene-cache.ts can import it
|
||||
* without a cycle.
|
||||
*/
|
||||
let sceneVersion = 0;
|
||||
|
||||
export const bumpSceneVersion = (): void => {
|
||||
sceneVersion++;
|
||||
};
|
||||
|
||||
export const getSceneVersion = (): number => sceneVersion;
|
||||
@@ -7,6 +7,7 @@ import App from './App.tsx';
|
||||
import { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
|
||||
import { draw } from './helpers/draw';
|
||||
import { findClosestEntity } from './helpers/find-closest-entity';
|
||||
import { scenePerf } from './helpers/scene-cache';
|
||||
import { getNewLayer } from './helpers/get-new-layer.ts';
|
||||
import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points';
|
||||
import { InputController } from './inputController/input-controller.ts';
|
||||
@@ -40,6 +41,13 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render(
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// Hover highlight throttle: the closest-entity scan is O(entities) so it runs
|
||||
// at most every HOVER_THROTTLE_MS and only when the mouse actually moved.
|
||||
const HOVER_THROTTLE_MS = 30;
|
||||
let lastHoverCheckAt = 0;
|
||||
let lastHoverMouseX = Number.NaN;
|
||||
let lastHoverMouseY = Number.NaN;
|
||||
|
||||
function startDrawLoop(
|
||||
screenCanvasDrawController: ScreenCanvasDrawController,
|
||||
timestamp: DOMHighResTimeStamp
|
||||
@@ -48,6 +56,7 @@ function startDrawLoop(
|
||||
|
||||
const elapsedTime = timestamp - lastDrawTimestamp;
|
||||
setLastDrawTimestamp(timestamp);
|
||||
scenePerf.avgFrameMs = scenePerf.avgFrameMs * 0.9 + elapsedTime * 0.1;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
const activeToolSnapshot: MachineSnapshot<any, any, any, any, any, any, any, any> | undefined =
|
||||
@@ -70,13 +79,20 @@ function startDrawLoop(
|
||||
if (!screenCanvasDrawController) {
|
||||
throw new Error('getScreenCanvasDrawController() returned null');
|
||||
}
|
||||
const { distance, entity: closestEntity } = findClosestEntity(
|
||||
screenCanvasDrawController.getWorldMouseLocation(),
|
||||
getEntities()
|
||||
);
|
||||
const mouseLocation = screenCanvasDrawController.getScreenMouseLocation();
|
||||
const mouseMoved = mouseLocation.x !== lastHoverMouseX || mouseLocation.y !== lastHoverMouseY;
|
||||
if (mouseMoved && timestamp - lastHoverCheckAt >= HOVER_THROTTLE_MS) {
|
||||
lastHoverCheckAt = timestamp;
|
||||
lastHoverMouseX = mouseLocation.x;
|
||||
lastHoverMouseY = mouseLocation.y;
|
||||
const { distance, entity: closestEntity } = findClosestEntity(
|
||||
screenCanvasDrawController.getWorldMouseLocation(),
|
||||
getEntities()
|
||||
);
|
||||
|
||||
if (distance < HIGHLIGHT_ENTITY_DISTANCE) {
|
||||
setHighlightedEntityIds([closestEntity.id]);
|
||||
if (distance < HIGHLIGHT_ENTITY_DISTANCE) {
|
||||
setHighlightedEntityIds([closestEntity.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './App.types';
|
||||
import type { ScreenCanvasDrawController } from './drawControllers/screenCanvas.drawController';
|
||||
import type { Entity } from './entities/Entity';
|
||||
import { bumpSceneVersion } from './helpers/scene-version';
|
||||
import { createStack, StateVariable, type UndoState } from './helpers/undo-stack';
|
||||
import type { InputController } from './inputController/input-controller.ts'; // state variables
|
||||
|
||||
@@ -214,6 +215,7 @@ export const getNotSelectedEntities = (): Entity[] => {
|
||||
};
|
||||
export const isEntitySelected = (entity: Entity) => selectedEntityIds.includes(entity.id);
|
||||
export const isEntityHighlighted = (entity: Entity) => highlightedEntityIds.includes(entity.id);
|
||||
export const getHighlightedEntityIds = () => highlightedEntityIds;
|
||||
export const getLayers = () => {
|
||||
return layers;
|
||||
};
|
||||
@@ -279,6 +281,7 @@ export const setEntities = (newEntities: Entity[], trackInUndoStack = false) =>
|
||||
trackUndoState(StateVariable.entities, newEntities);
|
||||
}
|
||||
entities = newEntities;
|
||||
bumpSceneVersion();
|
||||
if (trackInUndoStack) {
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.DRAWING_CHANGED));
|
||||
}
|
||||
@@ -288,6 +291,7 @@ export const setHighlightedEntityIds = (newEntityIds: string[]) => {
|
||||
};
|
||||
export const setSelectedEntityIds = (newEntityIds: string[]) => {
|
||||
selectedEntityIds = newEntityIds;
|
||||
bumpSceneVersion(); // selection style (dashed) is baked into the scene cache
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
export const setShouldDrawCursor = (newValue: boolean) => {
|
||||
@@ -374,6 +378,7 @@ export const setActiveTextStyle = (
|
||||
};
|
||||
export const setLayers = (newLayers: Layer[], triggerReact = true) => {
|
||||
layers = newLayers;
|
||||
bumpSceneVersion(); // layer visibility/lock affects what the scene cache shows
|
||||
|
||||
if (triggerReact) {
|
||||
triggerReactUpdate(StateVariable.layers);
|
||||
@@ -398,6 +403,7 @@ export const setSnapEnabled = (enabled: boolean) => {
|
||||
};
|
||||
export const setGridEnabled = (enabled: boolean) => {
|
||||
gridEnabled = enabled;
|
||||
bumpSceneVersion();
|
||||
window.dispatchEvent(new CustomEvent(HtmlEvent.UPDATE_STATE));
|
||||
};
|
||||
export const setDesignMeta = (newMeta: DesignMeta | null) => {
|
||||
@@ -462,6 +468,7 @@ function updateStates(undoState: UndoState) {
|
||||
switch (variable) {
|
||||
case StateVariable.entities:
|
||||
entities = value;
|
||||
bumpSceneVersion();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user