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

This commit is contained in:
2026-07-26 20:41:48 +09:00
parent 9ac4612641
commit 99babfacaf
6 changed files with 206 additions and 7 deletions
@@ -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;
+22 -6
View File
@@ -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;
}
}